From 0eb05453c801a94fdfdcd6862641f5644fabaedd Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 14:33:43 +0530 Subject: [PATCH 01/27] chore(packaging): point the tap and the bucket at 0.38.2 Regenerated by scripts/update-tap.sh from the published Release's own SHA256SUMS, so the four checksums are the ones an installer will verify against rather than ones computed here. --- Formula/io.rb | 12 ++++++------ bucket/io.json | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Formula/io.rb b/Formula/io.rb index 29fb974..6609fd4 100644 --- a/Formula/io.rb +++ b/Formula/io.rb @@ -32,13 +32,13 @@ class Io < Formula on_macos do if Hardware::CPU.arm? - url "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-aarch64-apple-darwin.tar.gz" - sha256 "ba2cc2b821c238686c835a886b5eb4eb392365b87873ffcd5960ef6560aa73d0" + url "https://github.com/initorigin/io-cli/releases/download/v0.38.2/io-0.38.2-aarch64-apple-darwin.tar.gz" + sha256 "78ee8d07d9219aa91d1aca28fcabc3d09fc5ee4c57b3b539909fb7bb5d5721d7" end if Hardware::CPU.intel? - url "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-x86_64-apple-darwin.tar.gz" - sha256 "2d273e01d430de2d6f4d70f44c393d282646f421724fa78b5f4d023008d5efe7" + url "https://github.com/initorigin/io-cli/releases/download/v0.38.2/io-0.38.2-x86_64-apple-darwin.tar.gz" + sha256 "587a5d14c0820db3e66780eab506eee912574ecd84940a4ed3adac370ce8133a" end end @@ -47,8 +47,8 @@ class Io < Formula # rather than a URL that 404s halfway through an install. on_linux do if Hardware::CPU.intel? - url "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-x86_64-unknown-linux-musl.tar.gz" - sha256 "2e99e143d1c0803d7e07e005146395e82d7ed20a4d883fc4bbb27be634d4040e" + url "https://github.com/initorigin/io-cli/releases/download/v0.38.2/io-0.38.2-x86_64-unknown-linux-musl.tar.gz" + sha256 "6052126fab14221ae76806cd2d8f290441c4eb279c0788da5dcc6a90eee75045" end end diff --git a/bucket/io.json b/bucket/io.json index f518825..d6fb2f9 100644 --- a/bucket/io.json +++ b/bucket/io.json @@ -1,13 +1,13 @@ { - "version": "0.38.1", + "version": "0.38.2", "description": "Terminal agent that shows what it may do, what it spends and what it refused", "homepage": "https://github.com/initorigin/io-cli", "license": "Apache-2.0", "architecture": { "64bit": { - "url": "https://github.com/initorigin/io-cli/releases/download/v0.38.1/io-0.38.1-x86_64-pc-windows-msvc.zip", - "hash": "f3cb3c0d6b31a8d37d9603e9faf1b38a8056c1558f0299cf2b6ac4eab689cbbf", - "extract_dir": "io-0.38.1-x86_64-pc-windows-msvc" + "url": "https://github.com/initorigin/io-cli/releases/download/v0.38.2/io-0.38.2-x86_64-pc-windows-msvc.zip", + "hash": "be8ea3dc313635a86909cd0cc386130102cda97bcabb78bc0e5986ec688a89f8", + "extract_dir": "io-0.38.2-x86_64-pc-windows-msvc" } }, "bin": "io.exe", From ee3f3e9cd04e165303b6b0c45461c0d797b4b468 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 17:53:25 +0530 Subject: [PATCH 02/27] chore(deps): pin io-harness 0.82.0 and enable otel, mcp-server and codeact The pin moves to the newest published io-harness, which is what answers initorigin/io-cli#105: 0.81.0 built the whole context-sizing path and shipped it inert, so every provider this crate can construct still assembled under a flat 24,000. 0.82.0 gives all four providers a source and splits the fallback into 128,000 remote and 24,000 loopback. The three feature flags are 0.39.0's fourth part. Each is claimed by io-harness to add no crate, and the claim is verified rather than believed: `cargo tree --depth 1` is still exactly ten names with all six features on. `otel` writes OTLP over the reqwest and serde_json already in the default build, `mcp-server` is two more types inside tokio, and `codeact` turns nothing on at all because its interpreter is a host binary discovered the way a browser is. Enabling `codeact` does not hand the agent a tool. This is worth stating because `media` did exactly that in 0.9.0 and it was a capability change arriving inside an interface release: `run_program` is advertised only when a contract carries a `[codeact]` configuration, because `codeact_ready` opens with `contract.codeact.clone()?`. With the section absent the tool catalogue is byte for byte what 0.38.2 sent. `libc` is the second dev-dependency this crate has ever had. It is here for the pty harness the four keystroke contracts need, and it is declared unconditionally rather than under a target table because `tests/dependencies.rs` refuses the latter by name -- a target-gated dev-dependency is a test-only crate arriving where the count cannot see it. The platform gate lives on the code. --- Cargo.lock | 7 ++++--- Cargo.toml | 42 ++++++++++++++++++++++++++++++++++++++++-- tests/dependencies.rs | 13 +++++++++++-- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ecc06d3..e852bd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1850,12 +1850,13 @@ dependencies = [ [[package]] name = "io-cli" -version = "0.38.2" +version = "0.39.0" dependencies = [ "clap", "crossterm", "image", "io-harness", + "libc", "ratatui", "serde", "serde_json", @@ -1867,9 +1868,9 @@ dependencies = [ [[package]] name = "io-harness" -version = "0.81.0" +version = "0.82.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e2c7ea3bc197d56862b6acf2c65ee32c07e7096174cf11233f56a328f48b7a" +checksum = "1af4df08e2107814bd31166464852840953335730ed35179e1d136a290a6ac6d" dependencies = [ "base64 0.23.1", "calamine", diff --git a/Cargo.toml b/Cargo.toml index 99877d4..fa2b370 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.38.2" +version = "0.39.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 @@ -63,7 +63,24 @@ path = "src/lib.rs" # still correct about what this *renderer* can be handed; it is simply no longer # load-bearing on the build. Turning `barcode` back off is what would restore # it, and `documents` would then have to be spelled out as its other four. -io-harness = { version = "0.81", features = ["media", "browser", "documents"] } +# **`otel`, `mcp-server` and `codeact` are on from 0.39.0, and every one of them +# is claimed by io-harness to add no crate.** `otel = []` writes OTLP over the +# `reqwest` and `serde_json` already in the default build; `mcp-server = +# ["tokio/io-std"]` is two more types inside a name already here, the JSON-RPC +# framing hand-written precisely to avoid the nine packages `rmcp`'s server half +# pulls in; and `codeact = []` turns nothing on at all, because its interpreter +# is a host binary discovered the way a browser is and never downloaded. The +# claim is verified against `cargo tree --depth 1` with all six features on +# rather than believed from the harness's manifest — see `tests/dependencies.rs`, +# which asserts the ten names in both directions. +io-harness = { version = "0.82", features = [ + "media", + "browser", + "documents", + "otel", + "mcp-server", + "codeact", +] } # **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.** # The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28 # because the composer was built out of that widget and the 0.30 line carried @@ -186,3 +203,24 @@ image = { version = "0.25", default-features = false, features = [ [dev-dependencies] tempfile = "3" +# **The second dev-dependency this crate has ever had, and it exists so that four +# keystroke contracts have an instrument at all.** The three ways 0.38.2 could +# lose an operator's work by a keystroke were all found at a real terminal and +# none of them was visible to the 2,000-odd offline tests: `src/main.rs` is what +# decides them and nothing under `tests/` links it, and the recorder backend +# every other test uses is not a tty. `openpty` is thirty lines over a name that +# is already in the transitive tree; `portable-pty` was the alternative and it +# brings a dozen packages on Windows into a repository whose dependency argument +# is that the set is small and enumerated. Unix only — the pty arms are compiled +# out on Windows and `tests/pty.rs` says so out loud rather than reporting a skip +# that reads like a pass. +# +# **It is declared here and not under `[target.'cfg(unix)'.dev-dependencies]`, +# deliberately.** That is where it belongs by platform and it is exactly where +# `tests/dependencies.rs` refuses to let it go: a target-gated dev-dependency is +# a test-only crate arriving where N1's count cannot see it, and the gate has +# said so since before this crate had a second one. The cost of declaring it +# unconditionally is that Windows compiles a crate it never links; the benefit is +# that `cargo tree --depth 1` prints the honest set. The platform gate lives on +# the code instead, as `#[cfg(unix)]` in `tests/pty.rs`. +libc = "0.2" diff --git a/tests/dependencies.rs b/tests/dependencies.rs index e0a9058..9edc7b3 100644 --- a/tests/dependencies.rs +++ b/tests/dependencies.rs @@ -145,15 +145,24 @@ fn direct_dependencies() -> BTreeSet { /// 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. +/// +/// **0.39.0 added the second name, and it is the first one this function has ever +/// had to admit.** `libc` is here for `tests/pty.rs`, which drives the built +/// binary at a real terminal over `openpty` — the instrument four keystroke +/// contracts have no gate without, because they are decided in `src/main.rs`, +/// which nothing under `tests/` links, and because the recorder backend every +/// other test uses is not a tty. `portable-pty` was the alternative and it brings +/// a dozen packages on Windows. The argument is in the 0.39.0 release record, as +/// this function's own doc comment requires of any name after the first. #[test] -fn n1_the_only_test_only_crate_is_the_one_that_makes_a_temporary_directory() { +fn n1_the_only_test_only_crates_are_the_temporary_directory_and_the_terminal() { let manifest = manifest(); let dev: Vec = names_in(manifest.get("dev-dependencies")) .into_iter() .collect(); assert_eq!( dev, - vec!["tempfile".to_string()], + vec!["libc".to_string(), "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.", From fe25166cf897ac60e939d5d167fdb197219c5925 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 18:27:55 +0530 Subject: [PATCH 03/27] feat(context): the ceiling is the model's real window, not a flat 24,000 Closes #105. Every release before this one assembled inside 24,000 tokens on every provider this crate could build, whatever model was configured. 0.38.2 delegated `context_window` and `max_output_tokens` through all four wrappers and moved three surfaces onto the announced ceiling, then said plainly that the number would not move -- because in io-harness 0.81.0 only `Compatible` answered the window, and only from a catalogue nobody had fetched. That was initorigin/io-harness#266, and 0.82.0 is what answers it. io-cli's half: - The Anthropic and OpenAI providers are built `with_reference_catalogue` by default, in `maker_for` and nowhere else. OpenRouter needs no opt-in -- its catalogue is its own completions endpoint, so for that vendor a reference is not a reference. One `Reference` is cloned into every link that takes one and its cache is an `Arc`, so a chain of three reads the catalogue once. - `[app.io-cli] reference_catalogue = false` turns it off, and that is the whole escape hatch for an air-gapped box or a proxy that lies. Which catalogue is read is `[app.io-cli.prices] source_url`'s question and has been since 0.24.0; a second key naming the same address would be a second thing to keep true. - `Vendor`, `Chain`, `Watched` and `Printable` now forward `warm_sizing` and `assumed_window`. Both are defaulted, so a wrapper that forgot one would have substituted the default silently -- io-harness's own 0.82.0 review found `Record`, `Fallback` and `eval::Capture` doing exactly that. The delegation gate caught all four here on the pin bump, named the wrapper and named the method. `Chain` takes the minimum for both the window and the assumption, because both are divided into, and warms every link rather than its head. - `/context` says which rung decided the number. `EventKind::ContextCeiling` has carried `source` since io-harness 0.81.0 and this crate destructured it away with `..`. It is rendered as a sentence rather than as the bare word: an operator reading `fallback` learns which enum arm fired, not that nothing sized their provider and what to do about it. The number and the word now travel as one `context::Ceiling`, set and cleared together, so no caller can draw a `model` rung over a contract's number. - A run refused because the egress policy denies the catalogue host says so and names the key. Turning the catalogue on puts a second host in `Provider::endpoints`, io-harness authorises every endpoint before the first step, and a policy that denies this one refuses the run rather than skipping the lookup. That is the sharp edge of turning it on by default, and the operator otherwise meets a refusal naming a host they never configured for a request they never made. The host is recorded by the builder rather than threaded to fourteen call sites in `main.rs`. - No shipped surface names a number for the fallback rung any more. It stopped meaning 24,000 in io-harness 0.82.0, and a gate now fails on any sentence that quotes one of the harness's constants. Sabotaged before being believed: dropping the builder from the Anthropic arm fails F16's vendor arm alone and names the vendor; ignoring the switch and building unconditionally fails the escape-hatch arm and, independently, the pre-existing egress host-count gate; deleting the rung block from the page fails the page arm while every sentence arm stays green. The 35 `io-harness-0.81.0/...` source citations were re-verified against 0.82.0 rather than rewritten: twelve of the thirteen cited files are byte-identical between the two, and the four line numbers that moved are all in one comment about resume entry points, whose claim still holds. --- CHANGELOG.md | 53 ++++++++++ docs/CONTRACT.md | 3 +- docs/guide/accounting.md | 44 ++++++++ docs/guide/configuration.md | 3 +- src/acp.rs | 1 + src/adapt.rs | 12 +-- src/commands.rs | 4 +- src/configure.rs | 14 +-- src/context.rs | 85 ++++++++++++++- src/contract.rs | 6 +- src/exec.rs | 13 ++- src/failure.rs | 33 ++++++ src/home.rs | 2 +- src/import.rs | 2 +- src/main.rs | 28 ++++- src/manage.rs | 2 +- src/memory.rs | 4 +- src/provider.rs | 204 ++++++++++++++++++++++++++++++++---- src/reload.rs | 2 +- src/settings.rs | 75 +++++++++++++ src/status.rs | 33 +++++- tests/adapt.rs | 2 +- tests/context.rs | 147 ++++++++++++++++++++++++-- tests/contract.rs | 2 +- tests/docs.rs | 5 +- tests/failure.rs | 68 ++++++++++++ tests/hooks.rs | 2 +- tests/instructions.rs | 2 +- tests/live.rs | 6 +- tests/provider.rs | 203 ++++++++++++++++++++++++++++++++++- tests/reload.rs | 2 +- 31 files changed, 986 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3196006..ee2f14d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,58 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.39.0] - 2026-09-06 + +### Changed + +- **The context ceiling is the model's real window, and reading it contacts one + more host.** Every release before this one assembled inside a flat 24,000 + tokens on every provider `io` could build, whatever model was configured — + io-harness had the whole sizing path but nothing answered it + (initorigin/io-cli#105, io-harness#266). The pin moves to io-harness 0.82.0, + which gives each provider a source, and **io-cli turns the reference catalogue + on by default for the Anthropic and OpenAI providers**. OpenRouter needs no + opt-in — its catalogue is its own endpoint. + + Read this if you upgrade and nothing else. Three consequences, all real: + + - **A run costs more per step.** It stops throwing history away at 24,000 and + sends what the model holds. That is the point, and it is not free. + - **One more host is contacted, once per process, before the first step** — and + because io-harness authorises every endpoint a provider declares, an egress + policy that denies it **refuses the run** rather than skipping the lookup. The + refusal names the key that turns it off. + - **A local endpoint now reports a much smaller ceiling than a hosted one.** + That is io-harness being careful about a runtime that publishes nothing, not + `io` shrinking. + + `[app.io-cli] reference_catalogue = false` restores the previous behaviour for + those two providers. `[run.context] max_tokens` still sets the ceiling yourself + and still wins over everything. Which catalogue is read is + `[app.io-cli.prices] source_url`'s question, unchanged since 0.24.0. + +- **`source: "fallback"` no longer means 24,000.** io-harness 0.82.0 splits it + into one default for a remote endpoint and a much smaller one for a loopback + endpoint. Nothing io-cli ships quotes a number for that rung any more, and a + gate keeps it that way. + +### Added + +- **`/context` says which rung decided the ceiling**, as a sentence rather than + as io-harness's bare word: what set the number, and what to do if it is an + assumption. `EventKind::ContextCeiling` has carried this since io-harness + 0.81.0 and io-cli discarded it. + +- **`[app.io-cli] reference_catalogue`**, documented in `docs/CONTRACT.md` and in + the configuration guide. Absent is `true`. + +- **`otel`, `mcp-server` and `codeact` are enabled on the io-harness + dependency.** Each adds no crate — `cargo tree --depth 1` is still exactly ten + names — and **enabling `codeact` hands the agent no tool**: `run_program` is + advertised only where a `[codeact]` section configures it, so a session with + none sends the catalogue 0.38.2 sent, byte for byte. That is worth saying + because `media` behaved differently in 0.9.0. + ## [0.38.2] - 2026-09-06 The rest of what the 2026-09-05 field test found, and the io-harness pin that turned out to carry @@ -3590,6 +3642,7 @@ client, tool, sandbox, policy engine or session store of its own. - No test in this release asserts on wall-clock time. [Unreleased]: https://github.com/initorigin/io-cli/compare/v0.38.0...HEAD +[0.39.0]: https://github.com/initorigin/io-cli/compare/v0.38.2...v0.39.0 [0.38.2]: https://github.com/initorigin/io-cli/compare/v0.38.1...v0.38.2 [0.38.1]: https://github.com/initorigin/io-cli/compare/v0.38.0...v0.38.1 [0.38.0]: https://github.com/initorigin/io-cli/compare/v0.37.0...v0.38.0 diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index ffc1bbb..b097289 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -127,7 +127,7 @@ the directory; otherwise the platform default. io-cli's only contribution is to `$IO_CONFIG_HOME` to its own home when the operator has set neither. `[app.io-cli]` is the one section io-harness deliberately does not validate, so it is this -crate's own and this page is its contract. It carries **seventeen** keys: +crate's own and this page is its contract. It carries **eighteen** keys: | Key | Shape | | --- | --- | @@ -148,6 +148,7 @@ crate's own and this page is its contract. It carries **seventeen** keys: | `gates` | the verification criterion | | `conversational` | bool | | `routing` | which model answers, and what happens when a provider stops | +| `reference_catalogue` | bool — absent is `true`, and `false` stops `io` reading a model catalogue before the first step | **A sub-table's fields are optional and refused by name, never required.** A required field is a *deserialization* failure that takes the whole `[app.io-cli]` section down with it — the theme, diff --git a/docs/guide/accounting.md b/docs/guide/accounting.md index 71e0736..378846c 100644 --- a/docs/guide/accounting.md +++ b/docs/guide/accounting.md @@ -138,6 +138,50 @@ act on. [What this release is not](limits.md) — the catalogue sent is the same either way, by design. The lever these numbers inform is turning a bundle or a server off. +### What fills the window + +Every figure above is measured against a **ceiling** — the number of tokens the +run assembles inside. Until 0.39.0 that number was 24,000 on every provider `io` +could build, whatever model you had configured, because nothing told the harness +how large the real window was. `ctx 12%` was a percentage of an assumption. + +It is now read. `/context` says which of three rungs decided it, under the total: + +- **sized by `[run.context]`** — you said so yourself, and that wins over + everything the provider knows. +- **sized from the model's own context window** — read from a model catalogue. + This is the one you want, and it is what a 200,000-token model finally reports. +- **an assumption** — nothing sized this provider, so io-harness used a default + for its kind. + +The last one is worth reading carefully, because a **local endpoint assumes far +less than a hosted one**. Pointing `io` at Ollama or llama.cpp and seeing a much +smaller ceiling than you had on a hosted model is the harness being careful about +a runtime that publishes nothing, not `io` getting smaller. + +**Reading the real window means contacting a catalogue, and `io` does that by +default.** For OpenRouter the catalogue is the provider's own endpoint, so nothing +changes. For Anthropic and OpenAI it is one extra host, contacted once per +process, before the first step — and because io-harness authorises every endpoint +a provider declares, **an egress policy that denies that host refuses the run** +rather than quietly skipping the lookup. If that happens, the refusal names the +key. Turn it off with: + +```toml +[app.io-cli] +reference_catalogue = false +``` + +Which catalogue is a different question, and `[app.io-cli.prices] source_url` +already answers it — set that to a mirror and both prices and windows come from +your copy. + +**What this costs you is real and is not a rounding error.** A run that used to +throw its history away at 24,000 tokens now sends what the model can hold. That +buys back the conversation, and you pay per step for it. `[run.context] +max_tokens` is still there if you want the old ceiling back deliberately rather +than by accident. + --- [README](../../README.md) · [All guides](../CAPABILITIES.md) · [What you may depend on](../CONTRACT.md) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e71661a..2550b58 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -278,7 +278,7 @@ path does not: that directory is one you already have. **`/profile`** switches to a named `[profile.]` for the session, and `--profile ` picks one for a single run without writing anything. -Nine keys live there, and eight tables: +Ten keys live there, and eight tables: | Key | Is | | --- | --- | @@ -299,6 +299,7 @@ Nine keys live there, and eight tables: | `[app.io-cli.gates]` | what "done" means for this repository: one of `command` (with `expect_exit`), `file` (with `contains`), or `rubric` (with `reviewer`, and `allow_self_review` if the judge may be the model that did the work), plus `retries`, which defaults to 1 and is report-only at 0. Naming none of the three, or more than one, is refused rather than resolved by precedence. See [Verification gates](verification.md#verification-gates). | | `[app.io-cli.routing]` | when a run should change models, and to which: `escalate_after` with `failures` and `model`, `downshift_under` with `bytes` and `model`, each a sub-table and both optional. Absent, a run asks one model from the first token to the last. **The rules do not fire under `[app.io-cli.containment]`**, which the session says at start, on `/config`, and when `/contain on` is typed. A rule that cannot be obeyed — half a rule, a threshold of zero, or an empty model — is refused by name and leaves the run unrouted. See [Which model a run asks](providers.md#which-model-a-run-asks). | | `[app.io-cli.prices]` | where the rates in `[prices]` came from: `source_url` names a catalogue to read instead of io-harness's default, and `source` and `models` record what the last read was and how many models it priced. The last two are written by a fetch rather than by hand. See [Where a price comes from](accounting.md#where-a-price-comes-from). | +| `reference_catalogue` | whether `io` may read a model catalogue before the first step, so the context ceiling is the model's real window rather than an assumption. **Absent means yes**, which is a decision this release takes on your behalf: it contacts one more host per process, and an egress policy that denies that host refuses the run rather than skipping the lookup — the refusal names this key. `false` leaves the Anthropic and OpenAI providers assuming, which is what every release before 0.39.0 did. Which catalogue is `[app.io-cli.prices] source_url`'s question, not this one. See [What fills the window](accounting.md#what-fills-the-window). | Because the section is unvalidated by design, an unrecognised *value* reads as the default rather than stopping a session from starting. A section io-harness cannot diff --git a/src/acp.rs b/src/acp.rs index 5eacfa5..2e726ae 100644 --- a/src/acp.rs +++ b/src/acp.rs @@ -958,6 +958,7 @@ pub async fn main( crate::provider::build( spec, model_override, + crate::settings::reference_catalogue(crate::settings::stored(&config).0.as_ref()), Editor { store, session, diff --git a/src/adapt.rs b/src/adapt.rs index 2ee80b0..512bb8e 100644 --- a/src/adapt.rs +++ b/src/adapt.rs @@ -70,7 +70,7 @@ //! `self.root.join(d)` and `Path::join` of an absolute argument discards the base. //! io-harness 0.74.0 refuses that spelling for `skills` and for `templates` in //! **every** scope, `Scope::User` included -//! (`io-harness-0.81.0/src/plugin.rs:1097`), and the reason is the one this +//! (`io-harness-0.82.0/src/plugin.rs:1097`), and the reason is the one this //! product would give: every `*.md` under such a directory is read into the //! model's system prompt on every turn, so a manifest contributes a directory it //! *ships* rather than one it points at somewhere else on the machine. Copying is @@ -377,7 +377,7 @@ pub const SKILLS_DIR: &str = "skills"; /// io-harness calls the same thing a **template**, and the two formats are the /// same file: markdown, optional frontmatter, `$ARGUMENTS` for what the operator /// typed. `Templates::discover` takes every `*.md` in a directory -/// (`io-harness-0.81.0/src/template.rs:279`), so what is translated is the +/// (`io-harness-0.82.0/src/template.rs:279`), so what is translated is the /// directory and never the files in it — the bytes are copied verbatim into /// [`TEMPLATES_DIR`] and nothing in them is rewritten. pub const COMMANDS_DIR: &str = "commands"; @@ -578,7 +578,7 @@ pub fn at(root: &Path, owner: &str, repo: &str, name: &str) -> PathBuf { /// /// **The two directories are copied into `into` and named relative to it.** /// io-harness 0.74.0 refuses an absolute or a `..`-carrying `skills` or -/// `templates` in every scope (`io-harness-0.81.0/src/plugin.rs:1097`), because +/// `templates` in every scope (`io-harness-0.82.0/src/plugin.rs:1097`), because /// every `*.md` under one is read into the model's system prompt — so a bundle /// contributes a directory it ships. An adapter comes to ship one by copying it, /// and [`Adapter::copied`] reports which moved. The copy refuses a symbolic link @@ -714,7 +714,7 @@ pub fn generate(bundle: &Path, name: &str, into: &Path) -> Result Option { /// adapted, so expanding it reads nothing of the host — it writes down a path io /// already knew. `${env:…}`, `${file:…}`, `${cmd:…}` and every shell variable a /// bundle writes are the opposite: io-harness refuses each of them inside a -/// manifest in every scope (`io-harness-0.81.0/src/plugin.rs:977`), because a +/// manifest in every scope (`io-harness-0.82.0/src/plugin.rs:977`), because a /// bundle is a third party's directory even when the file naming it is the /// operator's own. /// @@ -1252,7 +1252,7 @@ fn unquoted(said: &str) -> &str { /// /// **A transport io-harness does not speak is a refusal naming the server**, not /// a server quietly left out. io-harness speaks stdio and streamable HTTP -/// (`io-harness-0.81.0/src/mcp.rs:301`), so a bundle declaring `sse` is declaring +/// (`io-harness-0.82.0/src/mcp.rs:301`), so a bundle declaring `sse` is declaring /// a capability no adapter can deliver — and a manifest carrying one fewer server /// than the bundle does is exactly the silent absence this module's docs exist to /// end. The whole generation stops, because a bundle is installed for what it diff --git a/src/commands.rs b/src/commands.rs index e0c343f..d570904 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1489,7 +1489,7 @@ pub const READ_MARK: &str = "+"; /// The case this page exists for. A file that is there and is not read looks, /// from everywhere else in this product, exactly like one that is read: nothing /// warns, and a missing or skipped instruction file is passed over in silence -/// (`read_instructions`, `io-harness-0.81.0/src/config.rs:3120`). +/// (`read_instructions`, `io-harness-0.82.0/src/config.rs:3120`). pub const UNREAD_MARK: &str = "-"; /// What one row of the memory page stands for. @@ -1705,7 +1705,7 @@ pub fn memory_notes(view: &crate::recall::View, glyphs: &crate::glyphs::Glyphs) /// writing: a file that exists and is not read. It names what would make it /// read rather than only reporting the fact, because `[instructions] files` /// **replaces** the default list rather than adding to it -/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.81.0/src/config.rs:190`, read at +/// (`DEFAULT_INSTRUCTIONS`, `io-harness-0.82.0/src/config.rs:190`, read at /// `:3129`) and there is nothing on any /// other surface to pull on. pub fn instruction_said( diff --git a/src/configure.rs b/src/configure.rs index 550a49f..3ea889b 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -291,7 +291,7 @@ pub enum Kind { /// /// **Both halves are the dependency's since io-harness 0.71.0, and neither is /// written here any more**: the list is `Effect::ALL` -/// (`io-harness-0.81.0/src/policy.rs:129`) and each spelling is `Effect::as_str` +/// (`io-harness-0.82.0/src/policy.rs:129`) and each spelling is `Effect::as_str` /// (`:145`), which is the word io-harness's own deserializer reads. /// /// Until this release io-cli held a copy of both — an array naming three variants @@ -318,7 +318,7 @@ pub fn effects() -> Vec { /// The `ExecMode` variants, spelled by io-harness itself. /// -/// **The list is `ExecMode::ALL` (`io-harness-0.81.0/src/sandbox.rs:453`) and the +/// **The list is `ExecMode::ALL` (`io-harness-0.82.0/src/sandbox.rs:453`) and the /// spellings are `ExecMode::as_str` (`:460`).** io-cli wrote the variant list out /// by hand until this release for a reason that was the dependency's and not a /// choice made here: `ExecMode` is `#[non_exhaustive]` (`sandbox.rs:407`), and @@ -692,7 +692,7 @@ pub fn kind_of(key: &str) -> Option { /// preference — but half of the old reason is now false and the correction is /// worth writing down.** io-harness 0.71.0 names its own defaults: /// `DEFAULT_MAX_STEPS` = 8, `DEFAULT_WORKSPACE_MAX_STEPS` = 12 and -/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.81.0/src/contract.rs:755,773,789`), +/// `DEFAULT_MAX_RETRIES` = 2 (`io-harness-0.82.0/src/contract.rs:755,773,789`), /// re-exported at the crate root. "There is nothing to read" was true when this /// was written and is not true now. What is still true is that none of it anchors /// *this* ladder: @@ -843,7 +843,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// The models `[prices.models]` names, across every scope, sorted and deduplicated. /// /// **Read from the dependency's own table since io-harness 0.71.0, not scraped -/// out of the files.** `PriceTable::models` (`io-harness-0.81.0/src/pricing.rs:268`) +/// out of the files.** `PriceTable::models` (`io-harness-0.82.0/src/pricing.rs:268`) /// lists every model the table can actually price, and [`Config::prices`] has /// always built that table out of the three scopes — so the merged question this /// used to hand-roll is precisely the one the accessor answers, and the gap filed @@ -869,7 +869,7 @@ pub fn shape_of(key: &str, config: &Config) -> Option { /// /// **This takes the `Config` the caller already holds, and must never re-discover /// one.** `Config::discover` resolves every `${env:}`, `${file:}` and `${cmd:}` as -/// it reads (`io-harness-0.81.0/src/config.rs:627`), so a second discovery re-runs +/// it reads (`io-harness-0.82.0/src/config.rs:627`), so a second discovery re-runs /// an operator's credential commands — which for a `${cmd:}` fetching a key out of /// a keychain means a Touch-ID prompt raised in order to draw a menu, every time /// the picker opens. Taking a `&Config` is not an optimisation; it is the @@ -950,7 +950,7 @@ pub fn destination(config: &Config, key: &str) -> (Scope, bool) { #[must_use] pub fn widens_workspace(key: &str, value: &str) -> bool { /// The clause io-harness's widening refusal always carries - /// (`io-harness-0.81.0/src/config.rs:2949`). Matched rather than the whole + /// (`io-harness-0.82.0/src/config.rs:2949`). Matched rather than the whole /// sentence, which interpolates the path, the key and the destination scope. const WIDENS: &str = "widens the boundary"; @@ -1085,7 +1085,7 @@ fn is_credential(path: &str) -> bool { /// /// **There are three substitution forms and not two.** io-harness resolves /// `${env:...}`, `${file:...}` **and** `${cmd:...}` -/// (`substitute`, `io-harness-0.81.0/src/config.rs:3159`, the `cmd` arm at +/// (`substitute`, `io-harness-0.82.0/src/config.rs:3159`, the `cmd` arm at /// `:3279`); this comment claimed two until /// 0.21.0, and the sentence it claimed it in was the argument for which forms /// pass through here. The third is deliberately not one of them: a `${env:}` or diff --git a/src/context.rs b/src/context.rs index eb0c6d4..edf6912 100644 --- a/src/context.rs +++ b/src/context.rs @@ -128,7 +128,7 @@ pub const SKILLS_HEAD: &str = "Skills available to you"; /// **The directive is glued to the last catalogue line with no newline between /// them, so a line scan cannot see the boundary.** `compose` builds the prompt as /// `with_skill_catalog(..)` and then `out.push_str(&directive)` -/// (io-harness-0.81.0/src/run/prompts.rs:73-76); `Skills::catalog()` ends with its +/// (io-harness-0.82.0/src/run/prompts.rs:73-76); `Skills::catalog()` ends with its /// last `- name: description` line carrying **no trailing newline** /// (src/skills.rs:554-565); and `planning_directive` returns a string beginning /// with a space (src/run/gate.rs:322-326). So with a plan gate registered — which @@ -817,6 +817,76 @@ pub fn window(contract: &TaskContract, remaining: Option, announced: Option .unwrap_or_else(|| contract.context.effective_tokens(remaining)) } +/// What a `source` word from `EventKind::ContextCeiling` means, in a sentence. +/// +/// **The word alone is not readable and that is why this exists.** io-harness +/// names one of three rungs — `contract`, `model`, `fallback` — and drawing the +/// bare word would tell an operator which enum arm fired rather than what +/// happened to their run. Each sentence says what decided the number and, where +/// there is one, what to do about it. +/// +/// **`fallback` no longer means 24,000, and this sentence is where io-cli stops +/// saying it does.** Through io-harness 0.81.0 the fallback was one constant for +/// every case; 0.82.0 splits it into 128,000 for a remote endpoint and 24,000 for +/// a loopback one, sized down to 7,616. So the honest sentence names an +/// assumption rather than a number, and points at the two things that turn an +/// assumption into a reading — the reference catalogue for a vendor provider, and +/// `[run.context]` for anybody who would rather state it themselves. +/// +/// **A local ceiling is not a regression and the sentence says so**, because it +/// looks exactly like one: an operator moving from a hosted model to Ollama or +/// llama.cpp sees the number fall, and nothing else on the page explains that the +/// harness is being careful rather than that this crate is wrong. +/// +/// An unrecognised word is passed through rather than swallowed. `source` is a +/// `String` on a `#[non_exhaustive]` variant, so io-harness may grow a fourth +/// rung, and a surface that silently drew nothing for it would hide exactly the +/// case somebody needs to see. +/// What a run announced about the window it assembles inside. +/// +/// **One value rather than two parameters, because they are one fact.** The +/// number and the word that says where the number came from are set together by +/// [`crate::status::Status::note_ceiling`] and cleared together by +/// `forget_run`; passing them separately through the page would give a caller a +/// way to draw a `model` rung over a contract's number, which is the exact class +/// of disagreement this page has already been fixed for twice. +/// +/// Both halves are `Option` and they are not the same question. A run against a +/// harness older than io-harness 0.81.0 announces nothing at all and gets +/// `Ceiling::default()`; the source may be absent on its own for the moment +/// between a run starting and its first event. +#[derive(Debug, Default, Clone, Copy)] +pub struct Ceiling<'a> { + /// The ceiling in tokens, or `None` for a run that has not announced one — + /// in which case the page falls back to the contract, exactly as it did + /// before 0.38.2. + pub max_tokens: Option, + /// Which rung decided it: `contract`, `model` or `fallback`, io-harness's + /// own word. Rendered through [`rung`], never drawn bare. + pub source: Option<&'a str>, +} + +/// `dash` is the glyph set's own, so the sentence degrades with every other one +/// under `--plain` and the ASCII set rather than putting an em dash on a terminal +/// that cannot draw it. +pub fn rung(source: &str, dash: &str) -> String { + match source { + "contract" => "sized by `[run.context]` in your configuration, which wins over \ + everything the provider knows" + .to_string(), + "model" => "sized from the model's own context window, read from a catalogue".to_string(), + "fallback" => format!( + "an assumption {dash} nothing sized this provider, so io-harness used a default \ + for its kind (a local endpoint assumes far less than a hosted one, which is \ + the harness being careful rather than a smaller model). \ + `{}` reads the real window for Anthropic and OpenAI; `[run.context] max_tokens` \ + states it yourself", + crate::settings::REFERENCE_CATALOGUE_KEY, + ), + other => format!("announced as `{other}`, which this release has no sentence for"), + } +} + /// The page, committed into the scrollback. /// /// A page and never a modal: the viewport does not grow, and the @@ -834,7 +904,7 @@ pub fn committed( // they disagreed once already — the page totalled 4,363 of 24,000 while the // line said `ctx 0%` — and the fix then was to make them one expression. A // second reader of the announcement would undo that. - announced: Option, + announced: Ceiling<'_>, // What the next turn may not call. Drawn on this page because this is where // the tools are named, and an operator who withheld one an hour ago should // not have to remember it — but drawn only when there is one, so the row is @@ -880,13 +950,22 @@ pub fn committed( ); } let total = total(§ions); - let window = window(contract, remaining, announced); + let window = window(contract, remaining, announced.max_tokens); push( format!( "total: {total} tokens of {window} {dash} the window this run assembles inside" ), Tone::Normal, ); + // **Which rung decided that number, in io-harness's own word.** Drawn + // under the total rather than beside it: the number is what an + // operator reads first and the provenance is what they read when the + // number surprises them. Absent for a run that announced nothing, + // which is every run against a harness older than 0.81.0 — a row + // reading "unknown" would be furniture. + if let Some(source) = announced.source { + push(rung(source, dash), Tone::Muted); + } // The catalogue itself, named. The counts above say how much it // costs; this says what the model was actually handed, which is the diff --git a/src/contract.rs b/src/contract.rs index b447662..0316a25 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -500,7 +500,7 @@ pub fn buying(contract: TaskContract, effort: Option) -> Tas /// sits ahead of a cache breakpoint and removing a definition would save its /// tokens once and pay a cache *write* on every later turn (`src/tools/mod.rs:40`). /// A mask in fact **adds** a sentence to the user prompt naming the withheld tools -/// (`io-harness-0.81.0/src/run/prompts.rs:1381`, `withheld_sentence`), placed after the observations +/// (`io-harness-0.82.0/src/run/prompts.rs:1381`, `withheld_sentence`), placed after the observations /// precisely so it costs no cache entry. A turn that withholds three tools is /// marginally more expensive than the same turn without the mask, not less. #[must_use] @@ -657,7 +657,7 @@ pub fn skills_dir(config: &Config, capabilities: &Capabilities, root: PathBuf) - /// **The existence test is not caution, it is the whole of what makes this /// default safe.** `Skills::discover` does not return early on a directory that /// is not there — it returns `Error::Config("skills directory … does not exist")` -/// (`io-harness-0.81.0/src/skills.rs`), and `TaskContract::discover_skills` +/// (`io-harness-0.82.0/src/skills.rs`), and `TaskContract::discover_skills` /// propagates it from `run.rs` at run start, before the first completion. A /// contract that named this directory unconditionally would therefore fail every /// turn of every operator who has never made one, which is almost all of them. @@ -682,7 +682,7 @@ fn default_skills() -> Option { /// /// **One expansion for two keys, applied after both have had their say.** /// io-harness substitutes `${env:…}`, `${file:…}` and `${cmd:…}` and nothing else -/// (`substitute`, `io-harness-0.81.0/src/config.rs:3159` — there is no tilde +/// (`substitute`, `io-harness-0.82.0/src/config.rs:3159` — there is no tilde /// branch anywhere in it, and 0.71.0 narrowed the forms rather than widening /// them: a plugin manifest now refuses all three), so a `~` an operator wrote in /// `[run] skills` or `[app.io-cli] skills` diff --git a/src/exec.rs b/src/exec.rs index 0a72036..618a3de 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -839,6 +839,7 @@ pub async fn main( provider::build( spec, model_override, + settings::reference_catalogue(settings::stored(&config).0.as_ref()), Headless { store, session, @@ -1552,6 +1553,14 @@ pub async fn resume_main( provider::build( spec, model_override, + // **The resume door sizes from what the provider already knows.** + // io-harness 0.82.0's two decision-resume entry points do not warm, so a + // provider built here that has never fetched takes the `fallback` rung + // whatever this says. It is passed anyway, and identically to the other + // three doors, because `carry_on` reaches an entry point that does warm + // and because a door that quietly disagreed with the others about the + // operator's setting is the shape of a bug nobody looks for. + settings::reference_catalogue(settings::stored(&config).0.as_ref()), Resuming { store, config, @@ -1656,8 +1665,8 @@ impl WithProvider for Resuming { // other pause kind has both forms — `resume_tree_with_answer` beside // `resume_with_answer`, `resume_tree_with_plan_decision` beside its flat // one, `resume_tree_with_decision` beside `resume_with_decision` - // (`io-harness-0.81.0/src/run.rs:1816`, `:2150`, `:3204`). Recovery has - // `resume_with_recovery_observed` (`:2617`) and nothing tree-aware, so it + // (`io-harness-0.82.0/src/run.rs:1824`, `:2158`, `:3234`). Recovery has + // `resume_with_recovery_observed` (`:2639`) and nothing tree-aware, so it // is the one pause a contained run cannot be resumed from. Not an oversight // this crate can route around: a fleet's shared ceiling lives in the tree // entry points, and resuming through the flat one would drop it. diff --git a/src/failure.rs b/src/failure.rs index 352f532..8d35a76 100644 --- a/src/failure.rs +++ b/src/failure.rs @@ -71,6 +71,39 @@ fn head_conflict(error: &Error) -> bool { /// `String`: a lost head race is worth naming the session it was lost on, and a /// `&'static str` cannot. pub fn advice(error: &Error) -> Option { + advice_with(error, crate::provider::catalogue_host()) +} + +/// [`advice`], with the catalogue host handed in rather than read from the +/// process. +/// +/// Split out so the reference-host arm has a site a test can reach at all: the +/// host is written into a `OnceLock` by `provider::build`, which no test drives, +/// and an arm whose only input is process state is an arm nothing can falsify. +/// [`advice`] is this function with the process's own answer. +pub fn advice_with(error: &Error, catalogue_host: Option<&str>) -> Option { + // **The reference catalogue's own host, before the generic net arms.** + // Turning the catalogue on puts a second host in `Provider::endpoints`, and + // io-harness authorises every one of them before the first step — so an + // egress policy that denies this one refuses the whole run rather than + // quietly skipping the lookup. That is the sharp edge of the release's + // decision to turn it on by default, and the operator meets it as a refusal + // naming a host they never configured, for a request they never made. + // + // Matched on the host rather than on the words of the refusal, because the + // host is what the policy actually denied and the wording is io-harness's to + // change. + if let (Error::Refused { act, target, .. }, Some(host)) = (error, catalogue_host) { + if act == "net" && target.contains(host) { + return Some(format!( + "this run was refused because your egress policy denies {host}, which \ + `io` reads before the first step to learn the model's real context \ + window. Allow that host, or set `{} = false` to size from an \ + assumption instead.", + crate::settings::REFERENCE_CATALOGUE_KEY, + )); + } + } if let Error::Conflict { run_id, owner, .. } = error { return Some(if owner.is_empty() { // `run_id` is the session id here, and is called one. No expiry is diff --git a/src/home.rs b/src/home.rs index 78e2827..71a7ac7 100644 --- a/src/home.rs +++ b/src/home.rs @@ -9,7 +9,7 @@ //! This module names one. It is deliberately **not** a second configuration //! system: io-harness resolves `$IO_CONFIG`, then `$IO_CONFIG_HOME`, then the //! platform's own place, reading the environment at call time -//! (`io-harness-0.81.0/src/config.rs:2411`, the Windows branch at `:2429`), and +//! (`io-harness-0.82.0/src/config.rs:2411`, the Windows branch at `:2429`), and //! there is no caller-supplied home //! anywhere in `Config`'s public surface — no `discover_in`, no builder. So the //! one lever is `$IO_CONFIG_HOME`, set once, before the first diff --git a/src/import.rs b/src/import.rs index f4f003c..103b303 100644 --- a/src/import.rs +++ b/src/import.rs @@ -30,7 +30,7 @@ //! decision="allow")` and Claude's `permissions.ask` says `Bash(cargo yank *)` — //! both of which match a *command line*. io-harness's `Act::Exec` matches a //! **binary name and nothing else**; it has no argument matching at all -//! (`Act::Exec`, `io-harness-0.81.0/src/policy.rs:63`). So the nearest thing to a faithful +//! (`Act::Exec`, `io-harness-0.82.0/src/policy.rs:63`). So the nearest thing to a faithful //! import of `bun install` is a blanket allow on `bun`, which is a wider //! permission than the operator ever granted, written by a tool they were //! trusting to be careful. A boundary half-imported is worse than one left alone, diff --git a/src/main.rs b/src/main.rs index 88b6c99..e8794d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -476,6 +476,12 @@ async fn drive( // 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(); + // The catalogue the two vendor providers may size themselves from, read from + // the same settings and in the same place as the caps above. On by default, + // and `[app.io-cli] reference_catalogue = false` is what turns it off — see + // `settings::reference_catalogue`, which owns both halves of that decision so + // that no door has to reconstruct it. + let catalogue = settings::reference_catalogue(stored.as_ref()); let capabilities = io_cli::contract::Capabilities::stored(stored.as_ref()); // The agent's own skills, walked once beside the templates and for the same // reasons — the palette filters on every character typed, and a directory @@ -634,6 +640,7 @@ async fn drive( provider::build( provider::chain_of(&config), model_override, + catalogue, Interactive { screen, inputs, @@ -6563,7 +6570,10 @@ async fn loop_over Result>( seen.latest().as_ref(), &reading, reading.max_tokens, - app.status.ceiling, + io_cli::context::Ceiling { + max_tokens: app.status.ceiling, + source: app.status.ceiling_source.as_deref(), + }, &mask, &app.theme, screen.width(), @@ -8096,7 +8106,10 @@ async fn turn( seen.latest().as_ref(), &contract, contract.max_tokens, - app.status.ceiling, + io_cli::context::Ceiling { + max_tokens: app.status.ceiling, + source: app.status.ceiling_source.as_deref(), + }, mask, &app.theme, screen.width(), @@ -8581,8 +8594,15 @@ fn note_context( // event, and a fourth arm at one of the three is how the headless path or the // resume path silently keeps the contract's number. The event arrives once, // beside `Started`, so this runs before the first share is computed. - if let io_harness::EventKind::ContextCeiling { max_tokens, .. } = &event.kind { - app.status.note_ceiling(*max_tokens); + // **`source` is taken as well as the number, and 0.38.2 threw it away.** The + // rest is still `..` because the variant is `#[non_exhaustive]`; the two + // fields it has today are both wanted, and a rung an operator cannot read is + // a number they have to guess the provenance of. + if let io_harness::EventKind::ContextCeiling { + max_tokens, source, .. + } = &event.kind + { + app.status.note_ceiling(*max_tokens, source); } if let Some(request) = seen.latest() { // **What is LEFT of the run budget, not all of it.** io-harness assembles diff --git a/src/manage.rs b/src/manage.rs index ac8375c..0c1cc0c 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -1408,7 +1408,7 @@ fn mcp_add(args: &Args) -> Result { // Built by hand rather than through `McpServer::stdio(…).with_args(…)`, // because `env` and `headers` have no builder at all and `with_args` is a - // silent no-op on an HTTP server (`io-harness-0.81.0/src/mcp.rs:439-447`: the + // silent no-op on an HTTP server (`io-harness-0.82.0/src/mcp.rs:439-447`: the // body writes only into the `Stdio` arm) — a constructor chain here would drop // the arguments of half the servers it was handed and say nothing. // Asked of the harness rather than written as literals, the way `servers::add` diff --git a/src/memory.rs b/src/memory.rs index ec9f195..8cc1916 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -27,7 +27,7 @@ //! //! Worth knowing before a surface promises otherwise. `read_instructions` joins //! each name in `[instructions] files` to the **discovery root** -//! (`io-harness-0.81.0/src/config.rs:3120`), and the default list is exactly +//! (`io-harness-0.82.0/src/config.rs:3120`), and the default list is exactly //! `["AGENTS.md"]` (`DEFAULT_INSTRUCTIONS`, `config.rs:190`). So `AGENTS.md` is read //! by every project with no configuration at all; `AGENTS.local.md` is read only //! where a file names it; and `IO.md` is not reachable by a bare name at all, @@ -464,7 +464,7 @@ fn bullet(line: &str) -> Option { /// The order the three files are named in, and it is the order they reach the /// model in: `read_instructions` pushes one constraint per name in list order -/// (`read_instructions`, `io-harness-0.81.0/src/config.rs:3120`). +/// (`read_instructions`, `io-harness-0.82.0/src/config.rs:3120`). /// /// Project, then local, then user — widest audience first, so the operator's own /// standing note is the last thing said. It also matches the order the three diff --git a/src/provider.rs b/src/provider.rs index 70a7602..994d2bf 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -19,12 +19,29 @@ use io_harness::{ Anthropic, Auth, Compatible, CompletionRequest, CompletionResponse, ModelInfo, OpenAi, - OpenRouter, PromptFamily, Provider, ProviderSpec, ToolCall, + OpenRouter, PromptFamily, Provider, ProviderSpec, Reference, ToolCall, }; use crate::cli::FromEnv; use crate::context::Seen; +/// The host this process reads a model catalogue from, once one is built. +/// +/// See [`build`], which is the only writer, and [`catalogue_host`], which is the +/// only reader. +static CATALOGUE_HOST: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// The catalogue host in force, or `None` when the operator turned it off or no +/// provider has been built yet. +/// +/// Read by [`crate::failure::advice`] so that a run refused over this host says +/// which key turns it off. It is deliberately not read anywhere that decides +/// behaviour — a `OnceLock` is fine for a sentence and wrong for a rule, because +/// nothing clears it. +pub fn catalogue_host() -> Option<&'static str> { + CATALOGUE_HOST.get().map(String::as_str) +} + /// What a caller does once there is a provider to build. /// /// Implemented once per entry point — the interactive session and `io exec` — so @@ -177,22 +194,35 @@ impl Provider for Vendor { // left to the trait.** io-harness 0.81.0 added `context_window` and // `max_output_tokens` with `None` defaults, so every wrapper in this file // compiles without them and answers "this provider is not saying" — which - // sends the run back to `context::FALLBACK_MAX_TOKENS`, the 24,000 the - // harness release exists to stop being universal. + // sends the run to the fallback rung. // - // **What this does NOT do today, stated because the first draft of this - // comment claimed otherwise.** In io-harness 0.81.0 exactly one shipped - // provider implements either method — `Compatible`, at - // `io-harness-0.81.0/src/provider/compatible.rs:512` — and it answers only - // from a catalogue `models()` has already fetched *on that instance*. - // `OpenRouter`, `Anthropic` and `OpenAi` declare neither and take the trait's - // `None`, and this crate never awaits `models()` on the provider it hands a - // run. So on every configuration io-cli builds today the chain answers `None`, - // the harness emits `source: "fallback"`, and the ceiling is the same 24,000 - // it was before. Delegating is still right and still necessary — it is the - // wrapper's whole job, and a wrapper that answers for itself is wrong whatever - // the inner provider says — but the operator-visible change waits on - // io-harness, and `US-IO-CLI-0.38.2-I01` records that. + // **That rung is `context::FALLBACK_WINDOW` since 0.82.0, not + // `FALLBACK_MAX_TOKENS`, and the distinction is the release.** + // `FALLBACK_MAX_TOKENS` still exists and is still 24,000, but it is now only + // `ContextBudget::default`'s ceiling; what an unanswered provider gets is + // `assumed_window` — 128,000 for a remote endpoint and 24,000 for a loopback + // one, which sizes down to far less. A sentence here naming 24,000 as *the* + // fallback would be describing the harness this crate stopped pinning. + // + // **It answers for real as of 0.39.0, and the paragraph this replaces said it + // did not.** Through io-harness 0.81.0 exactly one shipped provider + // implemented either method — `Compatible` — and only from a catalogue + // `models()` had already fetched on that instance, so every configuration this + // crate could build answered `None`, the harness emitted `source: "fallback"`, + // and the ceiling was 24,000 whatever model was configured. That was + // initorigin/io-cli#105 and io-harness#266, and `US-IO-CLI-0.38.2-I01` records + // it. io-harness 0.82.0 closes it: `warm_sizing` is a real call before step 0, + // `OpenRouter` reads its own `/models`, and `Anthropic` and `OpenAi` read a + // reference catalogue this crate now turns on by default — see + // [`crate::settings::reference_catalogue`]. + // + // **`warm_sizing` and `assumed_window` are 0.82.0's, and they are delegated + // here for the reason every other method is**: both are defaulted, so a + // wrapper that forgets one substitutes the default silently. The cost of + // forgetting is not academic — io-harness's own 0.82.0 review found `Record`, + // `Fallback` and `eval::Capture` doing exactly that, flipping a wrapped local + // provider's ceiling from 7,616 to 111,616 with no compiler error, no warning + // and no failing test. // // There is no compiler error and no test failure for a defaulted method: the // only gate is `f2_every_provider_method_is_delegated_by_every_wrapper`, which @@ -217,6 +247,27 @@ impl Provider for Vendor { } } + /// An `async` block rather than a returned future, because the four arms have + /// four future types and a `match` cannot be the body of an `impl Future` + /// return. It is the same shape every other async method on this enum takes. + async fn warm_sizing(&self) -> io_harness::Result<()> { + match self { + Self::OpenRouter(p) => p.warm_sizing().await, + Self::Anthropic(p) => p.warm_sizing().await, + Self::OpenAi(p) => p.warm_sizing().await, + Self::Compatible(p) => p.warm_sizing().await, + } + } + + fn assumed_window(&self) -> u64 { + match self { + Self::OpenRouter(p) => p.assumed_window(), + Self::Anthropic(p) => p.assumed_window(), + Self::OpenAi(p) => p.assumed_window(), + Self::Compatible(p) => p.assumed_window(), + } + } + fn name(&self) -> &str { match self { Self::OpenRouter(p) => p.name(), @@ -452,6 +503,52 @@ impl Provider for Chain

{ .max() } + /// **Every link, not the head, and for the same reason the window is the + /// minimum.** The number the run sizes with is the minimum over the links, so + /// a link that never warmed contributes nothing to it and the chain sizes as + /// though it did not exist — which is precisely the fall-through case + /// `context_window` above exists to be conservative about. + /// + /// **It costs one fetch, not one per link**, because `provider::build` clones + /// a single [`Reference`] into every vendor that takes one and that type's + /// cache is an `Arc`. Warming each link in turn is therefore the + /// cheap option as well as the correct one. + /// + /// A failure is swallowed by io-harness at the call site and swallowed here + /// too: a chain whose second link could not be sized is a chain that sizes + /// from its first, not a run that refuses to start. The one failure that is + /// **not** swallowed is an egress policy denying the reference host, which + /// io-harness raises before this is ever reached — see + /// [`crate::failure::reference_refusal`]. + async fn warm_sizing(&self) -> io_harness::Result<()> { + // The one loop this module is permitted, over `self.links` and nothing + // else — the shape `tests/dependencies.rs` holds this file to by exact + // path. Spelled `self.links.iter()` rather than `&self.links` because + // that gate matches the spelling, not the meaning: it is a text sweep + // over a file it cannot execute, and a needle loose enough to accept both + // spellings would accept a loop over something else whose name merely + // began the same way. + for link in self.links.iter() { + let _ = link.warm_sizing().await; + } + Ok(()) + } + + /// The smallest assumption any link makes, for `context_window`'s reason + /// exactly: this is the number that is divided into, and a chain that assumed + /// its largest link's window would overflow on its smallest. + /// + /// It cannot be empty — `Chain::of` refuses an empty list — but `min` returns + /// an `Option` regardless, and the trait's own default is the honest answer if + /// one ever did. + fn assumed_window(&self) -> u64 { + self.links + .iter() + .map(Provider::assumed_window) + .min() + .unwrap_or(io_harness::context::FALLBACK_WINDOW) + } + fn name(&self) -> &str { self.head().name() } @@ -507,12 +604,19 @@ impl Provider for Chain

{ type Maker = Box Result + Send + Sync>; /// The maker for one spec, and the model that spec names. -fn maker_for(spec: ProviderSpec) -> Result<(Maker, String), String> { +fn maker_for(spec: ProviderSpec, catalogue: Option) -> Result<(Maker, String), String> { match spec { ProviderSpec::OpenRouter { model, api_key } => { let key = key_for(api_key, "OPENROUTER_API_KEY")?; Ok(( Box::new(move |name: &str| { + // **No opt-in here, and its absence is not an oversight.** + // OpenRouter derives its catalogue URL from its own + // completions endpoint, so for this vendor the reference + // catalogue is not a reference at all — it is the provider + // speaking for itself, on a host the run already authorises. + // `src/verify.rs` has drawn the same distinction for prices + // since 0.24.0. Ok(Vendor::OpenRouter(OpenRouter::new(key.clone(), name))) }), model, @@ -522,7 +626,11 @@ fn maker_for(spec: ProviderSpec) -> Result<(Maker, String), String> { let key = key_for(api_key, "ANTHROPIC_API_KEY")?; Ok(( Box::new(move |name: &str| { - Ok(Vendor::Anthropic(Anthropic::new(key.clone(), name))) + let built = Anthropic::new(key.clone(), name); + Ok(Vendor::Anthropic(match &catalogue { + Some(reference) => built.with_reference_catalogue(reference.clone()), + None => built, + })) }), model, )) @@ -530,7 +638,13 @@ fn maker_for(spec: ProviderSpec) -> Result<(Maker, String), String> { ProviderSpec::OpenAi { model, api_key } => { let key = key_for(api_key, "OPENAI_API_KEY")?; Ok(( - Box::new(move |name: &str| Ok(Vendor::OpenAi(OpenAi::new(key.clone(), name)))), + Box::new(move |name: &str| { + let built = OpenAi::new(key.clone(), name); + Ok(Vendor::OpenAi(match &catalogue { + Some(reference) => built.with_reference_catalogue(reference.clone()), + None => built, + })) + }), model, )) } @@ -587,11 +701,36 @@ fn maker_for(spec: ProviderSpec) -> Result<(Maker, String), String> { /// than by rewriting the spec. A fallback link keeps the model its own entry names — /// naming one model for a whole chain would ask a second vendor for a model id only /// the first one serves. +/// `catalogue` is the reference catalogue the two vendor providers may size +/// themselves from, from [`crate::settings::reference_catalogue`]. `None` is the +/// operator having turned it off, and it is the only way to get 0.38.2's +/// behaviour back: with it absent both providers answer `assumed_window` rather +/// than the model's real one. +/// +/// **It is threaded rather than constructed here on purpose.** One `Reference` +/// is cloned into every link that takes it, and the type's cache is an +/// `Arc` — so a chain of three vendor providers reads the catalogue +/// once between them, not three times. Building one per link inside this +/// function would have been three fetches and three hosts to authorise. pub async fn build( specs: Vec, model_override: Option, + catalogue: Option, with: W, ) -> Result { + // **Recorded here because here is the only place that knows.** A run refused + // because the egress policy denies the catalogue's host arrives at the turn + // as an ordinary `Error::Refused`, fourteen call sites away in `src/main.rs`, + // and telling it apart from a refused `web` fetch means knowing which host + // this process is sizing from. Threading that to fourteen sites is thirteen + // chances to miss one; a value the builder writes cannot disagree with what + // the builder built. + // + // Set once and never cleared: the provider chain is constructed once per + // process, so this is a fact about the process rather than about a turn. + if let Some(reference) = catalogue.as_ref().and_then(Reference::host) { + let _ = CATALOGUE_HOST.set(reference.to_string()); + } // **Iterators and not a `for`, which is a rule rather than a taste.** // `tests/dependencies.rs` refuses a loop in any file that calls a provider, // because a loop beside a provider call is the shape of a second agent loop. @@ -599,7 +738,10 @@ pub async fn build( // that gate now names it by path and holds it to iterating nothing else — so // turning configuration into closures, which is what this does, has to be // written without one. - let built: Result, String> = specs.into_iter().map(maker_for).collect(); + let built: Result, String> = specs + .into_iter() + .map(|spec| maker_for(spec, catalogue.clone())) + .collect(); let (makers, mut models): (Vec, Vec) = built?.into_iter().unzip(); let Some(head) = models.first().cloned() else { return Err("no provider is configured; run `io setup`".into()); @@ -713,6 +855,17 @@ impl Provider for Watched

{ self.inner.max_output_tokens() } + /// Delegated because it is defaulted, which is the whole argument: a decorator + /// that does not forward `warm_sizing` never warms the provider it wraps, and + /// the run then sizes from an assumption with nothing to say it did. + fn warm_sizing(&self) -> impl std::future::Future> + Send { + self.inner.warm_sizing() + } + + fn assumed_window(&self) -> u64 { + self.inner.assumed_window() + } + fn name(&self) -> &str { self.inner.name() } @@ -827,6 +980,17 @@ impl Provider for Printable

{ self.inner.max_output_tokens() } + /// Delegated because it is defaulted, which is the whole argument: a decorator + /// that does not forward `warm_sizing` never warms the provider it wraps, and + /// the run then sizes from an assumption with nothing to say it did. + fn warm_sizing(&self) -> impl std::future::Future> + Send { + self.inner.warm_sizing() + } + + fn assumed_window(&self) -> u64 { + self.inner.assumed_window() + } + fn name(&self) -> &str { self.inner.name() } diff --git a/src/reload.rs b/src/reload.rs index 17618b6..0238806 100644 --- a/src/reload.rs +++ b/src/reload.rs @@ -2,7 +2,7 @@ //! //! **io-harness takes the instruction files as a snapshot and never takes //! another.** `read_instructions` runs inside `Config::discover` -//! (`io-harness-0.81.0/src/config.rs:3120`), composes each named file into a +//! (`io-harness-0.82.0/src/config.rs:3120`), composes each named file into a //! constraint, and stores the result in a private `Config.instructions` field. //! There is no `Config::reload`, and `Config::with_profile` clones the field //! rather than going back to disk — so a `Config` is exactly as old as the call diff --git a/src/settings.rs b/src/settings.rs index b19c801..9845861 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -201,6 +201,39 @@ pub struct CliSettings { /// goes through the harness's three builders. #[serde(default, skip_serializing_if = "Option::is_none")] pub routing: Option, + /// `reference_catalogue` — whether the Anthropic and OpenAI providers may read + /// a model catalogue before the first step, to learn the real context window. + /// + /// **Absent means yes, and that is a decision this crate takes on the + /// operator's behalf rather than a default it inherited.** io-harness 0.82.0 + /// gives every provider a source for `Provider::context_window`, but for these + /// two the source is opt-in: `with_reference_catalogue` is a builder nobody is + /// obliged to call, and without it both providers answer + /// `assumed_window` — 128,000 for a remote endpoint. That assumption is + /// usually close for these two vendors and it is still an assumption, and a + /// model smaller than 128,000 that no catalogue carries is assumed *larger* + /// than it is, which io-harness recovers from by compacting on a + /// `ContextOverflow` and a run with compaction off does not recover from at + /// all. + /// + /// **What saying yes costs the operator, stated here because every surface + /// that draws the ceiling has to say it too.** One more host is contacted + /// before the first step, which puts it in `Provider::endpoints` and therefore + /// under the run's egress policy — and a policy that denies it **refuses the + /// run** rather than quietly skipping the lookup. That refusal is recognised + /// and rendered with this key's name in it; see [`crate::provider`]. + /// + /// **Which catalogue is not this key's question.** `[app.io-cli.prices] + /// source_url` already names one, for a mirror or an air-gapped copy, and + /// [`crate::verify::served`] has read it since 0.24.0. A second spelling of + /// "where the catalogue is" would be a second thing to keep true, so this key + /// is the switch and that one is the address. + /// + /// `false` is the whole point of the key: an air-gapped box, a proxy that + /// lies, or an operator who would simply rather this process talked to one + /// host. It leaves both providers exactly where 0.38.2 left them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference_catalogue: Option, } /// Where prices come from, and what the last read was. @@ -251,6 +284,39 @@ pub fn containment(stored: Option<&CliSettings>) -> Option<&io_harness::Containm stored.and_then(|settings| settings.containment.as_ref()) } +/// The catalogue the vendor providers may size themselves from, if any. +/// +/// One function so that "is it on" and "which one" are answered together and in +/// one place. Both halves come from `[app.io-cli]` — the switch from +/// `reference_catalogue`, the address from `[app.io-cli.prices] source_url`, +/// which has meant exactly this since 0.24.0 — and the default is `Some`, which +/// is the release's own decision rather than io-harness's. +/// +/// A function rather than a field read at the call site for the reason +/// [`containment`] and [`plain`] are: `src/main.rs` cannot be linked from +/// anything under `tests/`, so a decision made there has no gate at all. +pub fn reference_catalogue(stored: Option<&CliSettings>) -> Option { + if stored.is_some_and(|settings| settings.reference_catalogue == Some(false)) { + return None; + } + let source = stored + .and_then(|settings| settings.prices.as_ref()) + .and_then(|prices| prices.source_url.as_deref()) + .filter(|url| !url.is_empty()); + Some(match source { + Some(url) => io_harness::Reference::at(url), + None => io_harness::Reference::new(), + }) +} + +/// The key that turns the reference catalogue off, spelled as an operator writes +/// it. +/// +/// A constant because three surfaces name it — the refusal a denied reference +/// host produces, `/context`, and the guide — and a message that names a key +/// slightly wrong is worse than one that names none. +pub const REFERENCE_CATALOGUE_KEY: &str = "app.io-cli.reference_catalogue"; + /// What a contained turn decides, in the words the session says it in. /// /// **Disclosure rather than decoration**, and through 0.11.0 the disclosure was @@ -640,6 +706,15 @@ pub fn render( // wizard has asked about exactly one. A rule pointing at a model // the operator never chose is worse than no rule. routing: None, + // Left out because absent is the answer this release chose, and + // writing `true` would state that choice as though the operator + // had made it. The key exists for the operator who wants the + // other answer — an air-gapped box, or a preference that this + // process talk to one host — and a wizard that wrote it down + // would be answering a question it never asked. Its absence is + // also what keeps the file the wizard writes byte-identical to + // the one 0.38.2's wizard wrote. + reference_catalogue: None, }, }, }; diff --git a/src/status.rs b/src/status.rs index 76bf484..bb46a66 100644 --- a/src/status.rs +++ b/src/status.rs @@ -352,6 +352,29 @@ pub struct Status { /// not. [`crate::context::window`] falls back to the contract there, which is /// the pre-0.38.2 expression byte for byte. pub ceiling: Option, + /// Which rung decided [`Status::ceiling`], in io-harness's own word (0.39.0). + /// + /// `EventKind::ContextCeiling` has carried this since io-harness 0.81.0 and + /// io-cli discarded it — the arm in `src/main.rs` destructured `max_tokens` + /// and matched the rest with `..`. It is one of three words, `contract`, + /// `model` or `fallback`, and the whole reason it is worth keeping is that + /// they are not interchangeable: `contract` is the operator's own + /// `[run.context]` answer, `model` is the real window read from a catalogue, + /// and `fallback` is an assumption nothing confirmed. An operator watching a + /// large model trim early should be able to read which of the three happened + /// rather than infer it from the number. + /// + /// **Set and cleared in exactly the same two places as `ceiling`**, which is + /// what keeps a word and a number that describe one fact from drifting: one + /// setter ([`Status::note_ceiling`], called once per run) and one clearer + /// ([`Status::forget_run`]). A field with its own setter somewhere else is how + /// this crate has twice ended up with two answers to one question. + /// + /// A `String` rather than an enum of three, deliberately. The event's field is + /// a `String` and io-harness may grow a fourth rung; an enum here would turn + /// that into either a silent wrong word or a parse this crate has no business + /// doing. What io-cli owns is the *sentence* around it, not the vocabulary. + pub ceiling_source: Option, /// How this run's commands are contained: the mode asked for and the backend /// that actually answered on this host. /// @@ -657,6 +680,7 @@ impl Status { cost: None, context: None, ceiling: None, + ceiling_source: None, containment: None, boundary: None, branch: None, @@ -740,6 +764,10 @@ impl Status { // Beside the share it is the denominator of. A ceiling belongs to the run // that announced it, and the next run may be a different model. self.ceiling = None; + // And the word beside the number, in the same statement rather than a + // line elsewhere in this function: a rung left behind by a cleared + // ceiling would describe the previous run's model on this run's page. + self.ceiling_source = None; self.containment = None; // Beside the containment word it qualifies, and for the same reason: a // measurement belongs to the run that took it, and carrying it onto the @@ -1014,9 +1042,12 @@ impl Status { /// too, and both are deliberate: a window that divides is a number somebody /// eventually divides by, and this crate has already spent two releases on /// exactly that. - pub fn note_ceiling(&mut self, max_tokens: u64) { + pub fn note_ceiling(&mut self, max_tokens: u64, source: &str) { if max_tokens > 0 { self.ceiling = Some(max_tokens); + // Inside the same guard, so a refused zero does not leave a word + // describing a number that was never stored. + self.ceiling_source = Some(source.to_string()); } } diff --git a/tests/adapt.rs b/tests/adapt.rs index 36bf1a8..6255a5f 100644 --- a/tests/adapt.rs +++ b/tests/adapt.rs @@ -510,7 +510,7 @@ fn loaded(into: &Path) -> Plugins { /// that io wrote until 0.35.0. The generator still runs and the fixture still /// builds — and `dropped()` carries the whole bundle, because io-harness 0.74.0 /// refuses an absolute `skills` or `templates` in every scope, `Scope::User` -/// included (`io-harness-0.81.0/src/plugin.rs:1097`): a manifest contributes a +/// included (`io-harness-0.82.0/src/plugin.rs:1097`): a manifest contributes a /// directory it ships rather than one it points at somewhere else on the machine, /// since every `*.md` under it is read into the model's system prompt. #[test] diff --git a/tests/context.rs b/tests/context.rs index beeacc2..6bb3c32 100644 --- a/tests/context.rs +++ b/tests/context.rs @@ -401,9 +401,9 @@ fn f1_an_announced_ceiling_is_the_window_the_share_is_taken_against() { // And the same value reaches `ctx N%`, through `Status`, rather than being a // second reader of the event. let mut status = io_cli::status::Status::new("a-model"); - status.note_ceiling(128_000); + status.note_ceiling(128_000, "model"); assert_eq!(status.ceiling, Some(128_000)); - status.note_ceiling(0); + status.note_ceiling(0, "model"); assert_eq!( status.ceiling, Some(128_000), @@ -441,7 +441,7 @@ fn f7_the_catalogue_names_a_tool_only_the_request_knew_about() { Some(&Request::of(&request())), &contract(), None, - None, + context::Ceiling::default(), &ToolMask::none(), &ascii(), 80, @@ -499,7 +499,7 @@ fn f7_the_system_block_is_tokens_of_its_own_text_and_not_a_byte_count() { Some(&seen), &contract, None, - None, + context::Ceiling::default(), &ToolMask::none(), &ascii(), 80, @@ -534,7 +534,7 @@ fn f7_the_page_draws_in_ascii_and_says_so_before_a_turn_has_run() { None, &contract(), None, - None, + context::Ceiling::default(), &ToolMask::none(), &ascii(), 80, @@ -551,7 +551,7 @@ fn f7_the_page_draws_in_ascii_and_says_so_before_a_turn_has_run() { Some(&Request::of(&request())), &contract(), None, - None, + context::Ceiling::default(), &ToolMask::none(), &ascii(), 80, @@ -629,7 +629,7 @@ fn f10_the_status_share_is_the_page_total_over_the_page_window() { const ANNOUNCED: u64 = 103_424; let mut status = io_cli::status::Status::new("a-model"); status.budgets = io_cli::status::Budgets::in_force(&contract); - status.note_ceiling(ANNOUNCED); + status.note_ceiling(ANNOUNCED, "model"); status.note_context_request(&seen, &contract, contract.max_tokens); let announced_window = context::window(&contract, contract.max_tokens, Some(ANNOUNCED)); @@ -649,7 +649,7 @@ fn f10_the_status_share_is_the_page_total_over_the_page_window() { // `budgets.window` alone, so one screen carried two denominators for one word. let mut folded = io_cli::status::Status::new("a-model"); folded.budgets = io_cli::status::Budgets::in_force(&contract); - folded.note_ceiling(ANNOUNCED); + folded.note_ceiling(ANNOUNCED, "model"); folded.note_context(30_000); assert_eq!( folded.context, @@ -959,7 +959,7 @@ fn f9_the_withheld_row_is_absent_until_there_is_one_and_then_states_its_cost() { Some(&Request::of(&request())), &contract(), None, - None, + context::Ceiling::default(), &mask, &ascii(), 80, @@ -1006,7 +1006,7 @@ fn f9_allow_reports_a_no_op_and_a_clear_names_what_returned() { /// **The defect this replaces was the safety lever being silently inert.** /// io-harness keeps an unknown mask name rather than rejecting it, deliberately, /// so a mask stays portable across builds with different cargo features -/// (`io-harness-0.81.0/src/tools/mod.rs:55-58`). That means `mask_gate` matches on +/// (`io-harness-0.82.0/src/tools/mod.rs:55-58`). That means `mask_gate` matches on /// the exact string and a misspelling withholds nothing — while the operator was /// told "calling it will be refused before anything starts" and `/context` drew it /// on the withheld row. `/context withhold Docx_Write` and the file gets written. @@ -1155,3 +1155,130 @@ fn f1_the_longest_configured_server_id_wins_not_the_first() { "`github` was charged for a tool that is not its own: {costs:?}" ); } + +/// **F17 — the rung that decided the ceiling is a sentence, not a bare word.** +/// +/// `EventKind::ContextCeiling` names one of three rungs and io-cli discarded the +/// word entirely through 0.38.2 — the arm in `src/main.rs` destructured +/// `max_tokens` and matched the rest with `..`. Drawing the word alone would +/// still not be readable: `fallback` tells an operator which enum arm fired, not +/// that nothing sized their provider and what to do about it. +/// +/// Asserted against the *rendered* sentence rather than the word, because the +/// word is io-harness's and the sentence is this crate's, and it is the sentence +/// that is the feature. +#[test] +fn f17_each_rung_is_drawn_as_a_sentence_an_operator_can_act_on() { + let dash = ASCII.dash; + + let contract = context::rung("contract", dash); + assert!( + contract.contains("[run.context]"), + "the contract rung has to name the section that decided it, or an \ + operator cannot find the number they set: {contract}", + ); + + let model = context::rung("model", dash); + assert!( + model.contains("model") && model.contains("catalogue"), + "the model rung has to say the window was read rather than assumed: {model}", + ); + + let fallback = context::rung("fallback", dash); + assert!( + fallback.contains("assumption"), + "`fallback` means nothing sized this provider, and the sentence has to \ + say so: {fallback}", + ); + assert!( + fallback.contains(io_cli::settings::REFERENCE_CATALOGUE_KEY), + "the fallback sentence has to name the key that turns the catalogue on, \ + which is the one thing that turns this assumption into a reading: {fallback}", + ); + assert!( + fallback.contains("local"), + "a local endpoint assuming less than a hosted one looks exactly like a \ + regression, and this is the only surface that says it is not: {fallback}", + ); + + // io-harness may grow a fourth rung: `source` is a `String` on a + // `#[non_exhaustive]` variant. Passing it through is what stops the one case + // somebody needs to see from being the one case nothing draws. + let unknown = context::rung("something-new", dash); + assert!( + unknown.contains("something-new"), + "an unrecognised rung is passed through, never swallowed: {unknown}", + ); +} + +/// **F17 — the page draws it, which is the half a sentence test cannot prove.** +/// +/// `rung` above could be correct, complete and called by nothing, and every arm +/// in the test above it would still be green. This is the arm that drives +/// `committed` and reads the rows it produced. It also asserts the absence: +/// before the first `ContextCeiling` event — and against any harness that emits +/// none — there is no rung to name, and a row reading "unknown" would be +/// furniture on the one page that exists to be read closely. +/// +/// Sabotage: delete the `if let Some(source) = announced_source` block from +/// `committed`. The sentence arms stay green and this one fails. +#[test] +fn f17_the_context_page_draws_the_rung_when_the_run_announced_one() { + let with = drawn(&context::committed( + Some(&Request::of(&request())), + &contract(), + None, + context::Ceiling { + max_tokens: Some(128_000), + source: Some("fallback"), + }, + &ToolMask::none(), + &ascii(), + 80, + )); + assert!( + with.iter().any(|row| row.contains("assumption")), + "the page announced a ceiling and drew no rung, so the number stands there \ + with no provenance at all: {with:#?}", + ); + + let without = drawn(&context::committed( + Some(&Request::of(&request())), + &contract(), + None, + context::Ceiling { + max_tokens: Some(128_000), + source: None, + }, + &ToolMask::none(), + &ascii(), + 80, + )); + assert!( + !without.iter().any(|row| row.contains("assumption")), + "a run that announced no rung was given one anyway: {without:#?}", + ); +} + +/// **F17 — `fallback` no longer means 24,000, and no sentence here says it does.** +/// +/// Through io-harness 0.81.0 the fallback was one constant. 0.82.0 splits it into +/// 128,000 remote and 24,000 loopback, so any surface still naming a number for +/// this rung is stating something that stopped being true under the pin this +/// release moves. +/// +/// Sabotage: put `24,000` back into the fallback sentence. Only this fails. +#[test] +fn f17_no_rung_sentence_names_a_number_the_pin_moved() { + for source in ["contract", "model", "fallback", "whatever"] { + let said = context::rung(source, ASCII.dash); + for stale in ["24,000", "24000", "128,000", "128000", "7,616", "7616"] { + assert!( + !said.contains(stale), + "the `{source}` sentence names `{stale}`. These are io-harness's \ + constants and it has moved them once already; a sentence that \ + quotes one is a sentence that goes stale at the next pin: {said}", + ); + } + } +} diff --git a/tests/contract.rs b/tests/contract.rs index 5c85bf5..bc9a8da 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -1829,7 +1829,7 @@ fn f5_servers_in_both_scopes_are_merged_and_a_collision_is_named() { /// **F7 — a tilde is a home directory, never a directory named `~`.** /// /// io-harness substitutes `${env:…}` and `${file:…}` and nothing else — there is -/// no tilde branch anywhere in `io-harness-0.81.0/src/config.rs` — so a `~` an +/// no tilde branch anywhere in `io-harness-0.82.0/src/config.rs` — so a `~` an /// operator writes in `[run] skills` reaches `Skills::discover` verbatim and the /// harness looks inside a directory whose name is one character long. The /// operator's skills sit exactly where they said they would, and the session diff --git a/tests/docs.rs b/tests/docs.rs index e7fed03..a8c6775 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -319,6 +319,7 @@ fn the_readme_documents_every_key_of_the_io_cli_section() { model: Some("a-cheaper-model".into()), }), }), + reference_catalogue: Some(false), }; let value = serde_json::to_value(&every).expect("[app.io-cli] serializes"); let keys = value.as_object().expect("a table"); @@ -2517,9 +2518,9 @@ fn f6_the_headless_guide_carves_out_the_provider_endpoint() { /// the risk is a sentence rather than a branch.** io-harness offers a masked turn a /// byte-identical catalogue on purpose: the tool array sits ahead of the provider's /// cache breakpoint, so dropping a definition would save its tokens once and pay a -/// cache *write* on every later turn (`io-harness-0.81.0/src/tools/mod.rs:33-42`). +/// cache *write* on every later turn (`io-harness-0.82.0/src/tools/mod.rs:33-42`). /// Withholding in fact makes the request marginally **larger**, by one sentence -/// naming what is withheld (`io-harness-0.81.0/src/run/prompts.rs:1381`). +/// naming what is withheld (`io-harness-0.82.0/src/run/prompts.rs:1381`). /// /// The roadmap entry 0.37.0 was planned from assumed the opposite and said so in /// its headline. That framing is what a writer reaches for, because "withhold" means diff --git a/tests/failure.rs b/tests/failure.rs index 2af4492..434bfcb 100644 --- a/tests/failure.rs +++ b/tests/failure.rs @@ -203,3 +203,71 @@ fn a_run_another_process_holds_still_says_the_holder_and_the_expiry_it_has() { "a held run is not a moved head: {said}", ); } + +fn refused_net(target: &str) -> Error { + Error::Refused { + act: "net".into(), + target: target.into(), + rule: None, + layer: None, + } +} + +/// **N6 — a run refused over the catalogue host names the key that turns it off.** +/// +/// Turning the reference catalogue on by default is this release's decision, and +/// this is its sharp edge: the catalogue's host joins `Provider::endpoints`, +/// io-harness authorises every endpoint before the first step, and a policy that +/// denies this one **refuses the run** rather than skipping the lookup. Without +/// this arm the operator meets a refusal naming a host they never configured for +/// a request they never made, with nothing on screen connecting it to a setting. +/// +/// Sabotage: return the bare refusal — drop the arm from `advice_with`. Only this +/// fails. +#[test] +fn n6_a_refused_catalogue_host_names_the_key_that_turns_it_off() { + let refusal = refused_net("openrouter.ai"); + let advice = io_cli::failure::advice_with(&refusal, Some("openrouter.ai")) + .expect("a refusal on the catalogue host is recognised"); + + assert!( + advice.contains(io_cli::settings::REFERENCE_CATALOGUE_KEY), + "the way out has to be in the same message as the refusal, or it is a \ + message that tells an operator they are stuck: {advice}", + ); + assert!( + advice.contains("openrouter.ai"), + "and it has to name the host, because the operator's policy is written in \ + hosts: {advice}", + ); + assert!( + advice.contains("context window"), + "and say what the host was for, because a run refused for a reason nobody \ + explained reads as a bug in `io`: {advice}", + ); +} + +/// **N6 — every other net refusal is left alone.** +/// +/// The arm above matches a host, not the word `net`. A `web` fetch the policy +/// denied is a refusal the operator asked for and understands, and dressing it up +/// with advice about a catalogue key would be worse than saying nothing — it +/// would name a setting that has no bearing on what they did. +/// +/// The `None` case is the operator who turned the catalogue off: there is no host +/// in force, so no refusal can be about it. +#[test] +fn n6_a_refusal_on_any_other_host_is_not_dressed_up_as_a_catalogue_refusal() { + let elsewhere = refused_net("example.invalid"); + assert!( + io_cli::failure::advice_with(&elsewhere, Some("openrouter.ai")).is_none(), + "a denied `web` fetch was explained as a catalogue refusal", + ); + + let off = refused_net("openrouter.ai"); + assert!( + io_cli::failure::advice_with(&off, None).is_none(), + "there is no catalogue host in force, so nothing can have been refused \ + over one", + ); +} diff --git a/tests/hooks.rs b/tests/hooks.rs index 8efe008..8dd00a0 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -410,7 +410,7 @@ fn a_hook_naming_an_event_that_does_not_exist_is_refused_rather_than_installed() // --------------------------------------------------------------------------- // // `io_harness::Hooks::new` creates every `append` path it is given, empty, as the -// value is constructed (`io-harness-0.81.0/src/hooks.rs`) — deliberately, so that +// value is constructed (`io-harness-0.82.0/src/hooks.rs`) — deliberately, so that // "the filter matched nothing" stays distinguishable from "the hook was never // installed". That makes *constructing* a `Hooks` a write to the filesystem, and // it resolves a relative `append` against the directory it was handed. Handed diff --git a/tests/instructions.rs b/tests/instructions.rs index 8c1ea32..14d9de9 100644 --- a/tests/instructions.rs +++ b/tests/instructions.rs @@ -5,7 +5,7 @@ //! other half and `tests/memory.rs` covers it. This file covers the half that //! decides whether any of it reaches a model: `[instructions] files`, which is //! the only mechanism io-harness has for finding two of the three -//! (`io-harness-0.81.0/src/config.rs:190` — the automatic set is exactly +//! (`io-harness-0.82.0/src/config.rs:190` — the automatic set is exactly //! `["AGENTS.md"]`). //! //! Every test sets `IO_CONFIG_HOME`, because that is the only way to move the diff --git a/tests/live.rs b/tests/live.rs index 9dffe13..6c2c8d7 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -3735,6 +3735,10 @@ async fn live_f5_an_unreachable_primary_falls_through_to_the_provider_underneath let (reply, served) = io_cli::provider::build( specs, None, + // No catalogue: this arm is about the chain falling through, and a + // reference read would put a second host in front of the behaviour it + // measures. + None, Falling { session: &mut session, store: &store, @@ -4059,7 +4063,7 @@ async fn live_f6_a_withheld_tool_is_refused_by_the_mask_and_says_so() { // working mask, which is the finding worth keeping: io-harness *announces* the // mask in the user prompt — "Unavailable this turn — these tools are listed // above but calling one is refused and starts nothing: write_file" - // (`io-harness-0.81.0/src/run/prompts.rs:1381`) — so a compliant model never attempts the call and + // (`io-harness-0.82.0/src/run/prompts.rs:1381`) — so a compliant model never attempts the call and // never produces the refusal. The run above said so in its own reasoning: "the // previous turns show write_file was refused, so I used a shell redirect // instead". That is the mask working at its best, not evidence of absence. diff --git a/tests/provider.rs b/tests/provider.rs index 5be210b..aa7b61f 100644 --- a/tests/provider.rs +++ b/tests/provider.rs @@ -491,7 +491,7 @@ async fn f5_build_asks_the_operators_first_choice_first() { }, ]; - let (asked_first, hosts) = io_cli::provider::build(specs, None, Probe) + let (asked_first, hosts) = io_cli::provider::build(specs, None, None, Probe) .await .expect("a chain of two"); @@ -522,7 +522,7 @@ async fn f5_a_model_override_replaces_the_heads_model_and_not_the_tails() { }, ]; - let (asked_first, _) = io_cli::provider::build(specs, Some("chosen".into()), Probe) + let (asked_first, _) = io_cli::provider::build(specs, Some("chosen".into()), None, Probe) .await .expect("a chain of two"); @@ -709,6 +709,14 @@ fn body_after<'a>(source: &'a str, marker: &str) -> &'a str { /// provider catalogue already held — which is precisely the behaviour that /// harness release exists to end. /// +/// **It caught two more at 0.82.0, which is the argument for reading the method +/// list rather than writing it.** That release added `warm_sizing` and +/// `assumed_window`, both defaulted; this gate failed on the pin bump alone, +/// named the wrapper and named the method, and a wrapper that had not forwarded +/// `warm_sizing` would simply never have warmed the provider it wraps. (The +/// constant named above is also no longer the fallback rung — 0.82.0 moved that +/// to `FALLBACK_WINDOW`. The sentence is kept as the account of what 0.81.0 did.) +/// /// **The method list is read out of the locked harness, never written here.** A /// literal list is a gate that goes stale at the next pin and is then repaired by /// editing the literal, which is the failure this repository has already paid @@ -791,3 +799,194 @@ fn f2_every_provider_method_is_delegated_by_every_wrapper() { } } } + +/// The reference catalogue an arm hands to [`io_cli::provider::build`]. +/// +/// A URL that resolves to nothing, deliberately: every arm below reads +/// `endpoints()`, which is a list of strings the provider was constructed with +/// and requires no socket. An arm that started needing one would be measuring +/// something other than whether the builder was called. +const CATALOGUE: &str = "https://catalogue.invalid/v1/models"; + +/// Every host a built chain may reach, in order. +/// +/// [`Probe`] returns the count and that was enough while there was one endpoint +/// per link. It is not enough now: the whole of F16 is *which* provider gained +/// a second host, and a count cannot tell the reference apart from a mirror of +/// the completions endpoint. +struct Hosts; + +impl io_cli::provider::WithProvider for Hosts { + type Out = Vec; + + async fn call( + self, + make: impl Fn(&str) -> Result, + model: String, + ) -> Self::Out { + let provider = make(&model).expect("the spec builds"); + provider + .endpoints() + .into_iter() + .map(str::to_string) + .collect() + } +} + +fn spec_of(endpoint: &str) -> io_harness::ProviderSpec { + match endpoint { + "openrouter" => io_harness::ProviderSpec::OpenRouter { + model: "m".into(), + api_key: Some("k".into()), + }, + "anthropic" => io_harness::ProviderSpec::Anthropic { + model: "m".into(), + api_key: Some("k".into()), + }, + "openai" => io_harness::ProviderSpec::OpenAi { + model: "m".into(), + api_key: Some("k".into()), + }, + _ => panic!("no such spec"), + } +} + +async fn hosts_of(endpoint: &str, catalogue: Option) -> Vec { + io_cli::provider::build(vec![spec_of(endpoint)], None, catalogue, Hosts) + .await + .expect("one valid spec builds") +} + +/// **F16 — the opt-in reaches the two vendors that have one, and only those.** +/// +/// io-harness 0.82.0 gives `Anthropic` and `OpenAi` a `with_reference_catalogue` +/// builder and gives `OpenRouter` none, because OpenRouter derives its catalogue +/// from its own completions endpoint — for that vendor the reference is not a +/// reference at all. So the observable is `Provider::endpoints()`: the builder is +/// exactly what puts the catalogue's URL in that list, which is also what puts it +/// under the run's egress policy. +/// +/// Asserted as membership rather than as a count, because a count cannot tell the +/// reference apart from any other second host a provider might grow. +/// +/// Sabotage: drop the `with_reference_catalogue` call from either vendor arm of +/// `maker_for`. Only this fails, and it names which vendor. +#[tokio::test] +async fn f16_the_reference_catalogue_reaches_the_two_vendors_that_take_an_opt_in() { + let reference = io_harness::Reference::at(CATALOGUE); + + for endpoint in ["anthropic", "openai"] { + let hosts = hosts_of(endpoint, Some(reference.clone())).await; + assert!( + hosts.iter().any(|host| host == CATALOGUE), + "{endpoint} was built without the reference catalogue, so it answers \ + `assumed_window` and the ceiling an operator sees is 128,000 whatever \ + model they configured. Its hosts were {hosts:?}", + ); + } + + let hosts = hosts_of("openrouter", Some(reference)).await; + assert!( + !hosts.iter().any(|host| host == CATALOGUE), + "OpenRouter was handed a reference catalogue. It has no `with_reference_\ + catalogue` builder and needs none — it derives its catalogue from its own \ + completions endpoint — so a second host here is one more thing the egress \ + policy has to allow for nothing. Its hosts were {hosts:?}", + ); +} + +/// **F16 — with the key off, every provider is where 0.38.2 left it.** +/// +/// The escape hatch has to be a real one: `reference_catalogue = false` means no +/// second host, which means no extra egress to authorise and no run refused over +/// a catalogue the operator did not ask for. One endpoint each is the whole +/// assertion. +/// +/// Sabotage: build the reference unconditionally in `maker_for`, ignoring the +/// argument. The arm above still passes — it is the one that would be written by +/// somebody who only wanted the feature to work — and this one fails. +#[tokio::test] +async fn f16_no_catalogue_leaves_every_provider_where_0_38_2_left_it() { + for endpoint in ["openrouter", "anthropic", "openai"] { + let hosts = hosts_of(endpoint, None).await; + assert_eq!( + hosts.len(), + 1, + "{endpoint} reaches {} hosts with the catalogue turned off. The key is \ + the only way back to 0.38.2's behaviour and it has to mean it. Its \ + hosts were {hosts:?}", + hosts.len(), + ); + } +} + +/// **F16 — the key decides whether, and `[app.io-cli.prices] source_url` decides +/// which.** +/// +/// Both halves in one function because they are one decision, and because the +/// alternative — a second key naming the catalogue's address — would be a second +/// spelling of something `src/verify.rs` has read since 0.24.0. +/// +/// The default is the release's own choice rather than io-harness's: absent means +/// *on*, and `settings::reference_catalogue(None)` is what an operator who has +/// never written `[app.io-cli]` gets. +#[tokio::test] +async fn f16_the_key_decides_whether_and_the_prices_url_decides_which() { + let absent = io_cli::settings::reference_catalogue(None); + assert_eq!( + absent.as_ref().map(io_harness::Reference::url), + Some("https://openrouter.ai/api/v1/models"), + "an operator who configured nothing gets the default catalogue. Absent \ + meaning off would leave every Anthropic and OpenAI run assembling under \ + an assumed window, which is what initorigin/io-cli#105 is about", + ); + + let off = io_cli::settings::CliSettings { + reference_catalogue: Some(false), + ..Default::default() + }; + assert!( + io_cli::settings::reference_catalogue(Some(&off)).is_none(), + "`{}` = false has to mean it: it is the whole escape hatch for an \ + air-gapped box and for an operator who would rather this process talked \ + to one host", + io_cli::settings::REFERENCE_CATALOGUE_KEY, + ); + + let mirrored = io_cli::settings::CliSettings { + prices: Some(io_cli::settings::PriceSettings { + source_url: Some(CATALOGUE.into()), + source: None, + models: None, + }), + ..Default::default() + }; + assert_eq!( + io_cli::settings::reference_catalogue(Some(&mirrored)) + .as_ref() + .map(io_harness::Reference::url), + Some(CATALOGUE), + "the mirror an operator already named for prices is the mirror this reads. \ + A second key for the same address would be a second thing to keep true, \ + and the two would drift", + ); + + // An empty string is a key somebody wrote and left blank, and `Reference::at` + // would take it literally — a URL with no host, which authorises nothing and + // fetches nothing. It has to fall back to the default rather than build one. + let blank = io_cli::settings::CliSettings { + prices: Some(io_cli::settings::PriceSettings { + source_url: Some(String::new()), + source: None, + models: None, + }), + ..Default::default() + }; + assert_eq!( + io_cli::settings::reference_catalogue(Some(&blank)) + .as_ref() + .map(io_harness::Reference::url), + Some("https://openrouter.ai/api/v1/models"), + "a blank `source_url` is not an address", + ); +} diff --git a/tests/reload.rs b/tests/reload.rs index ddf2e7c..452a139 100644 --- a/tests/reload.rs +++ b/tests/reload.rs @@ -79,7 +79,7 @@ fn workspace(io_toml: &str, agents: &str) -> (tempfile::TempDir, Configuration) /// Whether any composed instruction carries `line`. /// /// io-harness words each instruction file as `Project instructions from -/// `AGENTS.md`:\n` (`io-harness-0.81.0/src/config.rs:3143-3146`), so the +/// `AGENTS.md`:\n` (`io-harness-0.82.0/src/config.rs:3143-3146`), so the /// assertion is a containment and not an equality — this file is not the place /// that pins the harness's wording. fn instructs(held: &Configuration, line: &str) -> bool { From 6756feb73ba10d886a3c1ac56210cc4b13aa51c4 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 18:42:06 +0530 Subject: [PATCH 04/27] feat(approval): the modal says what it takes, and the whole change can be read Two of the three ways the 2026-09-05 field test lost an operator's work, both in the surface that asks for a permission. **A key this modal does not take now says so.** Every key that was not `y`, `a`, `n`, an arrow or `Enter` returned `None` and changed nothing on screen, so an operator who had begun typing a sentence saw their words go nowhere with no sign that the interface was waiting for one of three keys. The answers row now leads with `press y, a or n` when the last keystroke was refused, built through `Theme::notice` like every other toned line in the product so the meaning survives `NO_COLOR` -- this row is where an operator learns their typing did nothing, and a colour alone would tell only half of them. It is cleared by the next key the surface does take, so it describes the keystroke that just arrived rather than one at some point in the past. **The proposed change can be read to its end.** It was cut at whatever the viewport had with `... N more lines` on the last row, which is a fair summary of a change and no way to read one: an operator approving a write whose end they cannot see is approving the elision. `Up`, `Down`, `PageUp`, `PageDown` and `Home` move the diff under a header that stays put, because the counts are what the decision is about. None of the five can answer anything -- reading a change is not agreeing to it -- and the row that says how much is left now says how to reach it. Clamping happens where the rows are counted rather than where the key was pressed, since the row count depends on the width the frame turns out to have. **The `+`/`-` header counts the diff drawn under it.** io-harness is explicit that `Edit::measure` is handed the fragment an `edit_file` replaced while `Edit::with_hunk` is computed from the file's two whole texts, and that keeping them apart is deliberate: folding them together would silently change every number in every trace the harness has written. Correct for the library, wrong for a header sitting on the diff it claims to count -- the field test met the pair as `+3 -9` on an approval above an edit the transcript then recorded as `-9 +10`, two measurements of one change with nothing on screen saying they measured different things. `diff::counted` counts the hunk, both surfaces use it, and with no hunk the recorded fields are used unchanged because then there is nothing drawn for them to disagree with. Each arm was sabotaged before being believed: dropping the nudge assignment fails the refusal sweep and names the character; removing the clear fails only the arm about clearing; ignoring the scroll offset fails only the reachability arm; and putting the recorded fields back in the header fails only the counting arm in tests/diff.rs, where an `edit_file` row is the shape that can tell them apart. --- src/approval.rs | 157 ++++++++++++++++++++++++--- src/diff.rs | 42 +++++++- tests/approval.rs | 268 +++++++++++++++++++++++++++++++++++++++++++++- tests/diff.rs | 64 +++++++++++ 4 files changed, 509 insertions(+), 22 deletions(-) diff --git a/src/approval.rs b/src/approval.rs index 5f481bd..61ffad5 100644 --- a/src/approval.rs +++ b/src/approval.rs @@ -214,6 +214,21 @@ impl Answer { /// What the model is told when the operator says no. pub const REFUSED_BY_OPERATOR: &str = "the operator denied it"; +/// How far `PageUp` and `PageDown` move through a proposed change. +/// +/// A fixed number rather than the viewport's height, which this surface does not +/// know when a key arrives — the height is a property of the frame and the key +/// comes first. Eight rows is roughly a screen of this overlay at the size it is +/// actually drawn at, and being approximate costs a reader nothing: they are +/// scrolling to read, not to land on a row. +const PAGE: usize = 8; + +/// What the answers row says when a key that is not one of the three arrives. +/// +/// A constant because two things need it to be the same string: the row that +/// draws it, and the test that proves an ignored key is not ignored silently. +pub const ONLY_THREE_KEYS: &str = "press y, a or n"; + /// An open question, and the answer being chosen. /// /// It owns the [`Ask`], so the run stays paused for exactly as long as this @@ -231,6 +246,34 @@ pub struct Approval { /// falls back to showing the proposed content plainly, which is what 0.2.0 /// did for every write. proposed: Option, + /// Whether the last keystroke was one this overlay does not take (0.39.0). + /// + /// **A modal that ignores a key in silence is a modal an operator does not + /// know they are in.** Until 0.39.0 every key that was not `y`, `a`, `n`, an + /// arrow or `Enter` returned `None` and changed nothing on screen — so an + /// operator who had begun typing a sentence saw their words go nowhere, with + /// the interface giving no sign that it was waiting for one of three keys. + /// The 2026-09-05 field test walked into exactly that and lost the sentence. + /// + /// Set by the key that was refused and cleared by the next one this surface + /// does take, so it says "that key, just now" rather than "a key at some + /// point". It changes no decision and reaches no `Decision` — it is drawn and + /// nothing else. + nudged: bool, + /// How far down the proposed change the reader has scrolled, in rows + /// (0.39.0). + /// + /// The diff used to be cut at whatever the viewport had with `⋯ N more lines` + /// on the last row, which is a reasonable summary of a change and no way at + /// all to read one. An operator approving a write they cannot see the end of + /// is approving the elision. + /// + /// Rows and not hunks, because the thing being scrolled is what is drawn and + /// a hunk is not a unit the viewport knows about. Clamped when it is used + /// rather than when it is set: the number of rows depends on the width the + /// frame turns out to have, which this does not know at the moment a key + /// arrives. + scroll: usize, } impl Approval { @@ -254,6 +297,8 @@ impl Approval { ask, chosen: 0, proposed, + nudged: false, + scroll: 0, } } @@ -272,6 +317,11 @@ impl Approval { /// arrows with `Enter` for the one who does not. A key that only works when /// you already know it is not an interface. pub fn key(&mut self, key: KeyEvent) -> Option { + // Cleared up front and set again only by the arm that refuses, so the + // mark always describes the key that has just arrived. Doing it in the + // refusing arm alone would leave the last refusal showing for the rest of + // the overlay's life. + self.nudged = false; match key.code { KeyCode::Left => { self.chosen = self.chosen.saturating_sub(1); @@ -283,11 +333,43 @@ impl Approval { } None } + // **Reading the change is not answering it.** Four keys move the + // proposal under the question; none of them can decide anything, and + // none of them is a key an operator would press meaning "yes". + KeyCode::Up => { + self.scroll = self.scroll.saturating_sub(1); + None + } + KeyCode::Down => { + self.scroll += 1; + None + } + KeyCode::PageUp => { + self.scroll = self.scroll.saturating_sub(PAGE); + None + } + KeyCode::PageDown => { + self.scroll += PAGE; + None + } + KeyCode::Home => { + self.scroll = 0; + None + } KeyCode::Enter => Some(self.chosen()), - KeyCode::Char(c) => Answer::ALL - .into_iter() - .find(|answer| answer.key() == c.to_ascii_lowercase()), - _ => None, + KeyCode::Char(c) => { + let answer = Answer::ALL + .into_iter() + .find(|answer| answer.key() == c.to_ascii_lowercase()); + // A letter that is not one of the three is the operator typing + // prose into a modal. It answers nothing, and now it says so. + self.nudged = answer.is_none(); + answer + } + _ => { + self.nudged = true; + None + } } } @@ -487,13 +569,18 @@ impl Approval { /// hundred — a different decision, not a smaller one. fn as_diff(&self, edit: &Edit, room: usize, width: usize, theme: &Theme) -> Vec> { let separator = theme.glyphs.separator; + // **Counted from the hunk drawn below rather than from the recorded + // fields, and `crate::diff::counted` explains why at length.** The short + // version: io-harness's `lines_added`/`lines_removed` measure the + // *fragment* an edit replaced while the hunk is the whole file's diff, so + // a header taken from the fields describes something other than the rows + // underneath it. The field test met that as `+3 -9` above an edit that + // landed as `-9 +10`. + let (added, removed) = crate::diff::counted(edit); let mut lines = vec![Line::from(vec![ - Span::styled(format!(" +{}", edit.lines_added), theme.style(Tone::Added)), + Span::styled(format!(" +{added}"), theme.style(Tone::Added)), Span::styled(" ".to_string(), theme.style(Tone::Muted)), - Span::styled( - format!("-{}", edit.lines_removed), - theme.style(Tone::Removed), - ), + Span::styled(format!("-{removed}"), theme.style(Tone::Removed)), Span::styled( match &edit.hunk { Some(_) => format!("{separator}{}", edit.tool), @@ -517,12 +604,28 @@ impl Approval { .map(|line| fit_line(line, width, theme)), ); - let total = lines.len(); - let shown = total.min(room); - let cut = total.saturating_sub(shown); - lines.truncate(shown); - // Said, not silently dropped. A reader who thinks they have seen a whole - // change and has seen its first row is worse off than one who was told. + // **The header stays put and the change scrolls under it.** The counts + // are the one row that has to be on screen at every offset — they are + // what the decision is being made about — so the window is taken over + // the body and the first line is put back on top of it. + // + // Clamped here rather than where the key was pressed, because the number + // of rows is a function of the width this frame turned out to have and + // `key` does not know it. `Home` and repeated `Up` therefore always reach + // the top, and `Down` past the end simply stops. + let body = lines.split_off(1); + let room_for_body = room.saturating_sub(1); + let furthest = body.len().saturating_sub(room_for_body); + let from = self.scroll.min(furthest); + lines.extend(body.into_iter().skip(from).take(room_for_body)); + + lines.truncate(room); + // Said, not silently dropped — and now it says what to do about it. A + // reader who thinks they have seen a whole change and has seen its first + // rows is worse off than one who was told; a reader who is told and + // cannot reach the rest is only slightly better off, which is what this + // row read like before the change could be scrolled. + let cut = furthest.saturating_sub(from); if cut > 0 { if let Some(last) = lines.pop() { // **Room is made for it, rather than it being appended to a row @@ -532,7 +635,14 @@ impl Approval { // was hidden — which is the one thing on this overlay that must // not be lost, and which a taller viewport made visible by // changing which line ends up last. - let suffix = format!(" {} {cut} more lines", theme.glyphs.elision); + // No arrow glyph here: this overlay is drawn under the ASCII set + // as well, and a character the terminal cannot render is worse + // than the word it stands for. `theme.glyphs.elision` is the one + // symbol on the row and it comes from the chosen set. + let suffix = format!( + " {} {cut} more lines, scroll to read", + theme.glyphs.elision + ); let room = width.saturating_sub(suffix.chars().count()); let mut fitted = fit_line(last, room, theme); fitted @@ -561,6 +671,21 @@ impl Approval { let mut spans = Vec::new(); let mut width = 0usize; let mut column = 0usize; + // **The refused key, said in a word before the three that would work.** + // Built through `Theme::notice` like every other toned line in the + // product, so the meaning survives `NO_COLOR` and a terminal with no + // colour at all — this row is the one place an operator learns that what + // they typed did nothing, and a tone alone would tell only half of them. + // + // Ahead of the answers rather than after them: it is the reason to read + // the rest of the row. + if self.nudged { + let notice = theme.notice(Tone::Warning, ONLY_THREE_KEYS.to_string()); + width += notice.width(); + spans.extend(notice.spans); + spans.push(Span::styled(separator, theme.style(Tone::Muted))); + width += separator.chars().count(); + } for (index, answer) in Answer::ALL.into_iter().enumerate() { if index > 0 { spans.push(Span::styled(separator, theme.style(Tone::Muted))); diff --git a/src/diff.rs b/src/diff.rs index fb26484..a54558b 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -255,20 +255,52 @@ pub fn cell_styled(edit: &Edit, theme: &Theme, width: u16, style: DiffStyle) -> lines } +/// The `+`/`-` counts for an edit, measured from the diff that is drawn under +/// them. +/// +/// **`Edit::lines_added` and `Edit::lines_removed` do not describe the hunk, and +/// io-harness says so in its own documentation.** `Edit::measure` is handed the +/// *fragment* an `edit_file` replaced — its counts have meant "the size of the +/// replacement" since io-harness 0.18.0 — while `Edit::with_hunk` is computed +/// from the file's two whole texts, because a hunk needs the file's own line +/// numbers. The harness keeps them apart deliberately: folding them together +/// would silently change every number in every trace it has ever written. +/// +/// That is correct for a library and wrong for a header sitting directly on top +/// of the diff it claims to count. The 2026-09-05 field test met it as `+3 -9` on +/// an approval for an edit the transcript then recorded as `-9 +10`, and there is +/// no reading of that pair from which an operator learns what they approved. +/// +/// So the surfaces count what they draw. A hunk body carries no `---`/`+++` +/// headers — io-harness writes those only in `Store::patch`, the one caller that +/// knows the path — so a leading `+` or `-` is a changed line and nothing else, +/// and `@@` begins with neither. +/// +/// **With no hunk the recorded counts are used unchanged**, because then there is +/// nothing drawn for them to disagree with, and the row beside them already says +/// `no diff stored`. An absent hunk is reported as absent rather than as an empty +/// patch — three things cause it and none of them is "nothing happened". +pub fn counted(edit: &Edit) -> (u64, u64) { + let Some(hunk) = edit.hunk.as_deref() else { + return (edit.lines_added, edit.lines_removed); + }; + let added = hunk.lines().filter(|line| line.starts_with('+')).count(); + let removed = hunk.lines().filter(|line| line.starts_with('-')).count(); + (added as u64, removed as u64) +} + /// ` src/theme.rs · +1 -1 · edit_file`, and `· no diff stored` when there is /// no hunk to draw under it. fn header(edit: &Edit, theme: &Theme) -> Line<'static> { let separator = theme.glyphs.separator; + let (added, removed) = counted(edit); let mut spans = vec![ Span::styled(INDENT.to_string(), theme.style(Tone::Muted)), Span::styled(edit.path.clone(), theme.style(Tone::Accent)), Span::styled(separator.to_string(), theme.style(Tone::Muted)), - Span::styled(format!("+{}", edit.lines_added), theme.style(Tone::Added)), + Span::styled(format!("+{added}"), theme.style(Tone::Added)), Span::styled(" ".to_string(), theme.style(Tone::Muted)), - Span::styled( - format!("-{}", edit.lines_removed), - theme.style(Tone::Removed), - ), + Span::styled(format!("-{removed}"), theme.style(Tone::Removed)), Span::styled( format!("{separator}{}", edit.tool), theme.style(Tone::Muted), diff --git a/tests/approval.rs b/tests/approval.rs index 229c6f0..b991da2 100644 --- a/tests/approval.rs +++ b/tests/approval.rs @@ -494,7 +494,74 @@ async fn nothing_remembered_changes_nothing() { /// Rendered through a real `Screen` rather than by calling the private layout, /// because what matters is what reaches the terminal — and because the overlay /// is height-constrained, which is the whole reason its content flexes. +/// An approval over a plain write, with no file behind it — everything a +/// keyboard arm needs and nothing it does not. +/// +/// The arms below never answer: they press a key and read the frame. Dropping +/// the `Ask` without answering is a denial, which is what makes the abandoned +/// task safe. +async fn fresh() -> io_cli::approval::Approval { + use io_cli::approval::{self, Approval}; + use io_harness::{Act, ApprovalContext, Approver, Request}; + + let (asker, mut asks) = approval::channel(); + let request = Request::new(Act::Write, "notes.txt".to_string()) + .with_content("one\ntwo\nthree\n".to_string()); + tokio::spawn(async move { + let asker = asker; + asker + .decide_in_context(&request, &ApprovalContext::new("tidy the parser")) + .await + }); + let ask = asks.recv().await.expect("the question arrived"); + Approval::new(ask, std::path::Path::new("")) +} + +/// One frame of `approval` after `key`, as text. +fn draw_after(approval: &mut io_cli::approval::Approval, key: KeyCode) -> String { + use io_cli::theme::DARK; + + approval.key(KeyEvent::new(key, KeyModifiers::NONE)); + let (mut screen, _recorder) = support::screen_of(100, 12, 8); + screen + .draw(|frame| approval.render(frame, frame.area(), &DARK)) + .expect("frame"); + screen.viewport_text().to_string() +} + +/// A fresh approval struck once with `key`: what it drew, and what it answered. +async fn struck(key: KeyCode) -> (String, Option) { + use io_cli::theme::DARK; + + let mut approval = fresh().await; + let answer = approval.key(KeyEvent::new(key, KeyModifiers::NONE)); + let (mut screen, _recorder) = support::screen_of(100, 12, 8); + screen + .draw(|frame| approval.render(frame, frame.area(), &DARK)) + .expect("frame"); + (screen.viewport_text().to_string(), answer) +} + +/// [`overlay_for`], with a run of keys pressed before the frame is taken. +async fn overlay_after( + target: &std::path::Path, + content: &str, + height: u16, + keys: &[KeyCode], +) -> String { + overlay_with(target, content, height, keys).await +} + async fn overlay_for(target: &std::path::Path, content: &str, height: u16) -> String { + overlay_with(target, content, height, &[]).await +} + +async fn overlay_with( + target: &std::path::Path, + content: &str, + height: u16, + keys: &[KeyCode], +) -> String { use io_cli::approval::{self, Approval}; use io_cli::theme::DARK; use io_harness::{Act, ApprovalContext, Approver, Decision, Request}; @@ -513,7 +580,10 @@ async fn overlay_for(target: &std::path::Path, content: &str, height: u16) -> St // An empty root leaves an absolute target resolving to itself, which is what // these fixtures use; the relative case is covered by the live run, which is // where it was found. - let approval = Approval::new(ask, std::path::Path::new("")); + let mut approval = Approval::new(ask, std::path::Path::new("")); + for key in keys { + approval.key(KeyEvent::new(*key, KeyModifiers::NONE)); + } // `height` is the VIEWPORT's height, which is what the overlay is drawn // into — not the terminal's. A session's is four; a taller one is what a // reader gets on a terminal with room, and both are worth asserting. @@ -553,6 +623,202 @@ async fn f7_a_write_over_an_existing_file_is_shown_as_what_changes() { ); } +/// **F2 — the overlay's counts agree with the body it drew them over.** +/// +/// This overlay computes its own `Edit` from the file on disk and the content the +/// harness handed it, so `Edit::measure` and `Edit::with_hunk` are given the same +/// two texts here and have always agreed. That is *not* true of an edit read back +/// from the store — see `tests/diff.rs`, where `measure` was handed the fragment +/// an `edit_file` replaced and the hunk is the whole file's diff — and it is that +/// pair the 2026-09-05 field test met as `+3 -9` on an approval above an edit the +/// transcript recorded as `-9 +10`. +/// +/// So what this arm holds is the property that makes the two surfaces one answer: +/// both count the hunk. Here that is a consistency check rather than a fix, and +/// it is worth having because the alternative — going back to the recorded +/// fields on this one surface — is exactly how the two drifted apart before. +#[tokio::test] +async fn f2_the_counts_agree_with_the_diff_drawn_beneath_them() { + let directory = tempfile::tempdir().expect("a temporary directory"); + let target = directory.path().join("two-places.rs"); + let before = "fn first() {}\nfn b() {}\nfn c() {}\nfn d() {}\nfn last() {}\n"; + let after = "fn FIRST() {}\nfn b() {}\nfn c() {}\nfn d() {}\nfn LAST() {}\n"; + std::fs::write(&target, before).expect("the file exists first"); + + // Tall enough that the whole hunk is drawn, so the header is compared against + // the body rather than against an elision. + let drawn = overlay_for(&target, after, 16).await; + + // A drawn diff row is ` 1 -fn first() {}` — the line number gutter comes + // first, so the marker is the first character that is neither a space nor a + // digit. The header itself is excluded by that same rule: it carries the + // separator, and no body row does. + let separator = io_cli::theme::DARK.glyphs.separator; + let marker = |line: &str| { + (!line.contains(separator)) + .then(|| line.trim_start_matches(|c: char| c.is_ascii_digit() || c == ' ')) + .and_then(|rest| rest.chars().next()) + }; + let added = drawn + .lines() + .filter(|line| marker(line) == Some('+')) + .count(); + let removed = drawn + .lines() + .filter(|line| marker(line) == Some('-')) + .count(); + assert!( + drawn.contains(&format!("+{added} -{removed}")), + "the header does not count the rows drawn under it: {added} additions and \ + {removed} removals are on screen. {drawn}", + ); +} + +/// **F1 — a key this modal does not take says so, rather than doing nothing.** +/// +/// Every key that was not `y`, `a`, `n`, an arrow or `Enter` returned `None` and +/// changed nothing on screen. An operator who has begun typing a sentence sees +/// their words go nowhere and no sign that the interface is waiting for one of +/// three keys — which is how the 2026-09-05 field test lost one. +/// +/// The whole printable ASCII range is swept rather than a chosen letter, because +/// the interesting keys are the ones nobody thought of. The three that answer are +/// excluded in both cases, and so are the four that scroll and the two that move +/// the selection: those do something visible already. +/// +/// Sabotage: drop the `self.nudged = answer.is_none()` assignment. This fails and +/// names the character. +#[tokio::test] +async fn f1_a_key_the_modal_does_not_take_is_refused_out_loud() { + use io_cli::approval::ONLY_THREE_KEYS; + + for byte in b' '..=b'~' { + let c = byte as char; + if matches!(c.to_ascii_lowercase(), 'y' | 'a' | 'n') { + continue; + } + let (drawn, answer) = struck(KeyCode::Char(c)).await; + assert!( + answer.is_none(), + "{c:?} answered the approval. Three keys decide this, and a fourth \ + one that does is a permission given by a keystroke nobody meant as \ + one", + ); + assert!( + drawn.contains(ONLY_THREE_KEYS), + "{c:?} was ignored in silence. The operator is typing into a modal \ + and nothing on screen tells them so: {drawn}", + ); + } +} + +/// **F1 — and the mark describes the last key, not any key ever pressed.** +/// +/// A refusal that stayed on screen would be as bad as one that never appeared: +/// it would sit above the answers row for the rest of the overlay's life, saying +/// something untrue about the key that has just arrived. +#[tokio::test] +async fn f1_the_refusal_clears_on_the_next_key_the_modal_does_take() { + use io_cli::approval::ONLY_THREE_KEYS; + + let mut approval = fresh().await; + let refused = draw_after(&mut approval, KeyCode::Char('q')); + assert!(refused.contains(ONLY_THREE_KEYS), "{refused}"); + + // An arrow is taken, so the refusal is over. + let moved = draw_after(&mut approval, KeyCode::Right); + assert!( + !moved.contains(ONLY_THREE_KEYS), + "the refusal outlived the key it was about: {moved}", + ); +} + +/// **F2 — the proposed change can be read to its end.** +/// +/// The diff was cut at whatever the viewport had, with `⋯ N more lines` on the +/// last row. That is a reasonable summary of a change and no way to read one: an +/// operator approving a write whose end they cannot see is approving the elision. +/// +/// The fixture is forty distinct lines into a short viewport, so the last line +/// cannot be on the first screen by accident. `Home` is asserted as well, because +/// a scroll with no way back to the top strands a reader who has paged past the +/// counts they were deciding on. +/// +/// Sabotage: ignore `self.scroll` in `as_diff`. This fails; the header and +/// counting arms stay green. +#[tokio::test] +async fn f2_the_whole_change_is_reachable_rather_than_elided() { + let directory = tempfile::tempdir().expect("a temporary directory"); + let target = directory.path().join("long.rs"); + let after: String = (0..40).map(|n| format!("fn line{n}() {{}}\n")).collect(); + + let first = overlay_for(&target, &after, 8).await; + assert!( + first.contains("fn line0() {}"), + "the change opens at its beginning: {first}", + ); + assert!( + !first.contains("fn line39() {}"), + "the fixture is meant to be taller than the viewport, or this arm proves \ + nothing: {first}", + ); + assert!( + first.contains("more lines"), + "a change that does not fit has to say so: {first}", + ); + + let paged = overlay_after(&target, &after, 8, &[KeyCode::PageDown; 8]).await; + assert!( + paged.contains("fn line39() {}"), + "the end of the change is unreachable, so the operator is approving the \ + elision: {paged}", + ); + + let home = overlay_after( + &target, + &after, + 8, + &[KeyCode::PageDown, KeyCode::PageDown, KeyCode::Home], + ) + .await; + assert!( + home.contains("fn line0() {}"), + "there is no way back to the top of the change: {home}", + ); +} + +/// **F2 — scrolling reads and never answers.** +/// +/// The four keys that move the proposal are added to a surface whose entire job +/// is taking a permission. None of them may decide anything, and the counts row +/// stays on screen at every offset because it is what the decision is about. +#[tokio::test] +async fn f2_scrolling_the_change_decides_nothing_and_keeps_the_counts() { + for key in [ + KeyCode::Up, + KeyCode::Down, + KeyCode::PageUp, + KeyCode::PageDown, + KeyCode::Home, + ] { + let (_, answer) = struck(key).await; + assert!( + answer.is_none(), + "{key:?} answered an approval. Reading a change is not agreeing to it", + ); + } + + let directory = tempfile::tempdir().expect("a temporary directory"); + let target = directory.path().join("long.rs"); + let after: String = (0..40).map(|n| format!("fn line{n}() {{}}\n")).collect(); + let paged = overlay_after(&target, &after, 8, &[KeyCode::PageDown; 4]).await; + assert!( + paged.contains("+40"), + "the counts scrolled away with the diff. They are what is being decided \ + on, and every other row is context for them: {paged}", + ); +} + #[tokio::test] async fn f7_a_write_of_a_file_that_does_not_exist_yet_is_all_addition() { let directory = tempfile::tempdir().expect("a temporary directory"); diff --git a/tests/diff.rs b/tests/diff.rs index a2d0d8f..bbcff84 100644 --- a/tests/diff.rs +++ b/tests/diff.rs @@ -588,3 +588,67 @@ fn f6_the_style_is_read_from_the_setting_and_an_unknown_value_is_unified() { assert_eq!(DiffStyle::from_setting(Some("minmal")), DiffStyle::Unified); assert_eq!(DiffStyle::default(), DiffStyle::Unified); } + +/// **F2 — the header counts the hunk, because the recorded fields count +/// something else.** +/// +/// io-harness is explicit that `Edit::measure` and `Edit::with_hunk` are handed +/// *different texts* and that this is deliberate: `measure` is given the fragment +/// an `edit_file` replaced — "its counts have meant the size of the replacement +/// since 0.18.0" — while the hunk needs the file's own line numbers and so is +/// computed from the two whole texts. Folding them together upstream would +/// silently change every number in every trace the harness has written. +/// +/// Correct for the library, wrong for a header sitting on the diff it claims to +/// count. This is the pair the 2026-09-05 field test met: `+3 -9` on an approval +/// above an edit that landed as `-9 +10`, two measurements of one change, with +/// nothing on screen saying they were measuring different things. +/// +/// The fixture is that shape exactly — recorded counts of a small replacement, a +/// hunk of a larger whole-file change — and the header has to follow the rows a +/// reader can see and count. +/// +/// Sabotage: put `edit.lines_added`/`edit.lines_removed` back in `header`. Only +/// this fails. +#[test] +fn f2_the_header_counts_the_hunk_and_not_the_replaced_fragment() { + // Three lines out, four in, with two rows of context: what a reader can count + // on screen is four additions and three removals. + const WHOLE_FILE: &str = "@@ -1,5 +1,6 @@\n use std::io;\n-fn a() {}\n-fn b() {}\n-fn c() {}\n\ + +fn a(x: u8) {}\n+fn b(x: u8) {}\n+fn c(x: u8) {}\n+fn d() {}\n \ + fn tail() {}\n"; + + let edit = Edit { + step: 4, + tool: "edit_file".to_string(), + path: "src/parse.rs".to_string(), + // What `Edit::measure` records for an `edit_file`: the size of the + // fragment that was replaced, which is neither of the two numbers above. + lines_added: 1, + lines_removed: 1, + hunk: Some(WHOLE_FILE.to_string()), + }; + + let drawn: Vec = cell(&edit, &DARK, 100) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + let head = drawn.first().expect("the cell has a header").clone(); + + assert!( + head.contains("+4") && head.contains("-3"), + "the header has to count the rows drawn under it — four lines arrive and \ + three leave — not the fragment `Edit::measure` was handed: {head}", + ); + assert!( + !head.contains("+1 -1"), + "the header is reporting the replaced fragment's size over a diff of the \ + whole file, which is the two-measurements-of-one-change the field test \ + found: {head}", + ); +} From 9940219447a8704715713417bd4e03b536f85fe8 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 18:54:24 +0530 Subject: [PATCH 05/27] feat(undo): the rewind asks, and no keystroke arms it The third way the 2026-09-05 field test lost an operator's work: `Esc` dismissed a picker and also armed the undo, so the next `Esc` rewound the last turn and put two files back, with a footer line as the only warning. The rewind was bound to `esc esc` -- the first press armed and wrote what it would undo into the scrollback, the second acted -- on the reasoning that arming is a property of a keystroke while a typed command has already been deliberate once. That is a warning only for an operator who pressed the key meaning to undo something. Pressed for any other reason, the footer is a line that has already scrolled and the second press is not consent to anything. So the consent moved rather than went away. The binding is one chord, and the keystroke raises `/undo`'s own confirmation -- a surface that waits for an answer, naming what would be undone, declining at row 0 like the two beside it. Both paths reach one construction in `undo::confirm_turn`, so the word and the chord cannot come to mean different things about what an undo is. `Command::ArmRewind` is gone rather than left unproduced. The arming machinery itself is untouched and still means what it meant -- `Hit::Arm` is a binding whose second chord has not arrived -- so an operator who binds `ctrl+r ctrl+r` still presses both, and the question is raised by the second. What changed is that this action's consent no longer depends on the number of keystrokes, which is a stronger property than the one that shipped: a rebinding to a single chord used to collapse a two-press confirmation into one press that acted. The key tables, the README, the guide and the in-product key reference all now say `Esc`, and the gate that holds them to one another is what found each of them. --- README.md | 2 +- docs/guide/keys.md | 4 ++-- src/app.rs | 42 +++++++++++++++++++++++--------- src/commands.rs | 4 ++-- src/keys.rs | 18 ++++++++++++-- src/main.rs | 60 ++++++++++++++++++---------------------------- src/undo.rs | 31 ++++++++++++++++++++++++ tests/commands.rs | 12 ++++++---- tests/keys.rs | 50 +++++++++++++++++++++++++++----------- tests/palette.rs | 32 ++++++++++++++++++------- tests/rewind.rs | 60 +++++++++++++++++++++++----------------------- tests/undo.rs | 8 +++++++ 12 files changed, 212 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 23e4ea8..d0857c2 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ posture on the next.](assets/screenshot.png) | **Approvals in place** | A write stops the run and shows the diff it proposes; `y`, `a` or `n`, answered where it was asked | | **A fan-out you can watch** | Contained turns spawn children under one shared ceiling; `Ctrl+F` shows the tree and what it is costing | | **Your file, in force** | Every section of `io.toml` bounds a session turn as it bounds `io exec`; the budgets in force are on the status line with what is left of them, and `/status` commits the whole state — policy layers, sandbox backend, caps, budgets, connections — into the scrollback | -| **Undo** | `Esc Esc` at an empty prompt rewinds the last turn — its files, its memory and the conversation head | +| **Undo** | `Esc` at an empty prompt asks whether to rewind the last turn — its files, its memory and the conversation head | | **Conversations that survive** | `/resume` reopens an earlier session and answers whatever its last run stopped on, `/fork` continues from an earlier turn, `/clear` starts fresh without leaving | | **A paused run, answered** | A question, a plan or an interrupted call is decided where it was left and the run carries on from the step it stopped at — from a session, or from a script with `io resume` | | **Everything it asks, in one place** | Several questions asked at once arrive as one overlay, answered one at a time and sent together; an offer may explain itself and preview what taking it would do; `Space` marks where a question takes more than one answer | diff --git a/docs/guide/keys.md b/docs/guide/keys.md index 70c936a..8f3c805 100644 --- a/docs/guide/keys.md +++ b/docs/guide/keys.md @@ -12,7 +12,7 @@ | `Ctrl+D` | exit, on an empty prompt | | `Shift+Tab` | cycle the permission posture, from the next turn | | `Ctrl+L` | clear the viewport, never the scrollback | -| `Esc Esc` | at an empty prompt, undo the last turn — its files and all | +| `Esc` | at an empty prompt, ask whether to undo the last turn — its files and all | | `Ctrl+T` | put the whole conversation back into the scrollback | | `Ctrl+F` | show the fleet: the children this turn has spawned | | `y / a / n` | answer an approval: allow once, allow this session, deny | @@ -120,7 +120,7 @@ rewind = "ctrl+r ctrl+r" | `posture` | `Shift+Tab` | | `clear` | `Ctrl+L` | | `transcript` | `Ctrl+T` | -| `rewind` | `Esc Esc` | +| `rewind` | `Esc` | | `fleet` | `Ctrl+F` | A binding is a chord, or two chords separated by a space. Modifiers are `ctrl`, diff --git a/src/app.rs b/src/app.rs index a6bf4bc..cda2082 100644 --- a/src/app.rs +++ b/src/app.rs @@ -108,16 +108,21 @@ pub enum Command { /// stream this interface is drawing, so it would be a second copy of what is /// already on screen. Attach(i64), - /// The first `Esc` at an empty prompt: say what undoing the last turn would - /// undo, and wait for the second. - /// - /// Armed rather than fired, because this is the only key in the product that - /// changes the operator's files on io-cli's own initiative rather than the - /// agent's. Every write before it arrived through a tool call and passed a - /// policy layer; this one does not, so it asks. - ArmRewind, - /// The second `Esc`: undo the last turn — its files, its memory and the - /// conversation head. + /// `Esc` at an empty prompt: ask whether to undo the last turn — its files, + /// its memory and the conversation head. + /// + /// **It asks; it does not act, and it no longer arms.** This is the only key + /// in the product that changes the operator's files on io-cli's own + /// initiative rather than the agent's — every write before it arrived through + /// a tool call and passed a policy layer, and this one does not. + /// + /// Through 0.38.2 the first press armed and wrote what it would undo into the + /// scrollback, and the second press acted. That is a warning only for an + /// operator who pressed the key meaning to undo something; the 2026-09-05 + /// field test pressed it to dismiss a picker, never read the footer as a + /// question, and lost two files to the next press. The driver now raises + /// `/undo`'s own confirmation from `undo::confirm_turn`, so the word and the + /// chord cannot come to mean different things. Rewind, /// A question opened from the store has been answered, and nobody took the /// answer. @@ -2122,11 +2127,26 @@ impl App { // it does the thing the operator pressed it for. return self.interrupt_or_quit(); } + // **One press, and it asks.** Through 0.38.2 the first press + // armed and said what it would undo in a footer line, and the + // second acted. The 2026-09-05 field test walked into the case + // that reasoning does not cover: the first `Esc` was the operator + // dismissing a picker, so the footer was never read as a warning, + // and the second press put two files back. A line in the + // scrollback is not a question, and this is the one key in the + // product that changes an operator's files on io-cli's own + // initiative rather than the agent's. + // + // The arming machinery is untouched and still means what it + // means: `Hit::Arm` is a binding whose *second chord* has not + // arrived. The default rewind binding is one chord as of 0.39.0, + // so it fires here; an operator who binds two chords still + // presses both, and the question is raised by the second. match hit { Hit::Fire(_) => Command::Rewind, Hit::Arm(action) => { self.armed = Some(action); - Command::ArmRewind + Command::None } } } diff --git a/src/commands.rs b/src/commands.rs index d570904..990b915 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -83,8 +83,8 @@ pub const KEYS: &[(&str, &str)] = &[ ), ("Ctrl+L", "clear the viewport, never the scrollback"), ( - "Esc Esc", - "at an empty prompt, undo the last turn — its files and all", + "Esc", + "at an empty prompt, ask whether to undo the last turn — its files and all", ), ( "Ctrl+T", diff --git a/src/keys.rs b/src/keys.rs index 34f5710..71abd23 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -3,7 +3,7 @@ //! The session's own keys were a `match` on a `KeyEvent` written into //! [`crate::app::App::key`], which is fine until somebody's terminal eats //! `Ctrl+L`, or their muscle memory says `Ctrl+R` for the thing this product -//! spells `Esc Esc`. This module is the indirection that fixes that, and it is +//! spells `Esc`. This module is the indirection that fixes that, and it is //! deliberately the smallest one that does: a table of chords, a lookup, and a //! way to render the table that is *in force* rather than the one that shipped. //! @@ -347,7 +347,21 @@ impl Action { Self::Posture => "shift+tab", Self::Clear => "ctrl+l", Self::Transcript => "ctrl+t", - Self::Rewind => "esc esc", + // **One chord since 0.39.0, and the consent moved rather than went + // away.** It was `esc esc` — the first press armed and wrote what it + // would undo into the scrollback, the second acted — and that is a + // warning only for an operator who pressed the key meaning to undo. + // The 2026-09-05 field test pressed it to dismiss a picker, never + // read the footer as a question, and lost two files to the next + // press. The rewind now raises `/undo`'s own confirmation, which is a + // surface that waits for an answer rather than a line that has + // already scrolled. + // + // A two-chord binding is still a two-chord binding for anyone who + // writes one: `Hit::Arm` is returned whenever a binding has a second + // chord, and that machinery is untouched. What changed is that this + // action's consent no longer depends on it. + Self::Rewind => "esc", // It displaces the composer's forward-char, which the right arrow // already does — the same trade `Ctrl+T` already makes against its // transpose-chars, and the reason both are rebindable. The keymap it diff --git a/src/main.rs b/src/main.rs index e8794d7..9b63d2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4594,20 +4594,22 @@ async fn loop_over Result>( // The first `Esc`. Nothing has changed yet; this says what the second // one would change, in the turn's own words, so a confirmation is a // confirmation of something specific rather than of a keystroke. - Command::ArmRewind => match io_cli::rewind::preview(&session, &store) { - Some(about) => app.say( - Tone::Warning, - io_cli::rewind::armed_line(&about, &app.theme.glyphs), - ), + // **The chord asks, and it asks with `/undo`'s own picker.** Both + // reach `undo::confirm_turn`, so the word and the keystroke cannot + // come to mean different things; the answer is delivered by + // `Pick::UndoRun`, which is where the operator's files actually + // change. Through 0.38.2 this arm called `undo_whole_turn` directly, + // with an armed footer line as the only warning. + Command::Rewind => match io_cli::rewind::preview(&session, &store) { + Some(about) => { + let (title, rows) = io_cli::undo::confirm_turn(io_cli::rewind::armed_line( + &about, + &app.theme.glyphs, + )); + picker = Some((Picker::new(title, rows), Pick::UndoRun)); + } None => app.say(Tone::Muted, "there is no turn to undo"), }, - // The second. This is where the operator's files change. - // **Through `observing` since 0.27.0, which is what finally emits - // `EventKind::Rewound`.** The call was `rewind_run`, whose observed - // twin is the only thing that emits it. - Command::Rewind => { - undo_whole_turn(&mut app, screen, &mut session, &store, &seen)?; - } Command::Slash(text) => match commands::parse(&text, app.keys(), &app.theme) { // Rewritten into a `Command::Submit` above, for the reason // `/commit` is: invoking a skill hands work to the agent, so it @@ -6414,34 +6416,18 @@ async fn loop_over Result>( }, )); } - // The bare form confirms like the other two rather than - // arming like the chord. The arming is a property of a - // *keystroke* — one press to warn, a second to act — and - // a typed command has already been deliberate once. Both - // paths end in the same `rewind::last_turn`, so the word - // and the chord can never disagree about what an undo is. + // **The word and the chord raise the same picker**, from + // one construction in `undo::confirm_turn`. Through + // 0.38.2 only this half confirmed and the chord armed + // instead; see that function for what the field test did + // to the arming. io_cli::undo::Grain::Run => { match io_cli::rewind::preview(&session, &store) { Some(about) => { - let title = - io_cli::rewind::armed_line(&about, &app.theme.glyphs); - picker = Some(( - Picker::new( - title, - vec![ - Row::with_detail( - io_cli::store::LEAVE_IT, - "the turn stands", - ), - Row::with_detail( - "undo the whole turn", - "its files, its notes, its queued children \ - and the conversation head", - ), - ], - ), - Pick::UndoRun, - )); + let (title, rows) = io_cli::undo::confirm_turn( + io_cli::rewind::armed_line(&about, &app.theme.glyphs), + ); + picker = Some((Picker::new(title, rows), Pick::UndoRun)); } None => app.say(Tone::Muted, "there is no turn to undo"), } diff --git a/src/undo.rs b/src/undo.rs index d310799..491b836 100644 --- a/src/undo.rs +++ b/src/undo.rs @@ -191,6 +191,37 @@ pub fn confirm_file(path: &str) -> (String, Vec) { ) } +/// The confirmation for undoing a whole turn, over the line that describes it. +/// +/// **One construction for the two ways a turn is undone, and 0.39.0 is when that +/// stopped being optional.** `/undo` raised this picker; the `Esc` chord armed +/// instead — one press to warn with a footer line, a second to act — on the +/// reasoning that arming is a property of a keystroke while a typed command has +/// already been deliberate once. The 2026-09-05 field test showed what that misses: +/// the first press was the operator dismissing a picker, so the warning was never +/// read as one, and the second press put two files back. A footer line is not a +/// question, and this is the one key in the product that changes an operator's +/// files on io-cli's own initiative. +/// +/// `title` is [`crate::rewind::armed_line`], which is what the footer used to say +/// — kept, because it names what would be undone, and now it names it in a +/// surface that waits for an answer. +/// +/// Row 0 declines, as in the two confirmations beside it, and `tests/undo.rs` +/// asserts that by index rather than by reading the words. +pub fn confirm_turn(title: String) -> (String, Vec) { + ( + title, + vec![ + crate::picker::Row::with_detail(crate::store::LEAVE_IT, "the turn stands"), + crate::picker::Row::with_detail( + "undo the whole turn", + "its files, its notes, its queued children and the conversation head", + ), + ], + ) +} + /// The confirmation for undoing one step. pub fn confirm_step(step: u32) -> (String, Vec) { ( diff --git a/tests/commands.rs b/tests/commands.rs index f83278e..f884346 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -734,11 +734,15 @@ fn the_key_table_covers_every_key_this_release_binds() { "Ctrl+C", "Ctrl+D", "Ctrl+L", + // **One row since 0.39.0, where there were two.** `Esc` stops a turn and + // closes a picker, and at an empty prompt it is also the rewind — the one + // key in this product that changes the operator's files on io-cli's own + // initiative rather than the agent's. That used to be spelled `Esc Esc`, + // one press to warn in the scrollback and a second to act. The 2026-09-05 + // field test pressed the first to dismiss a picker, never read the footer + // as a question, and lost two files to the second; the consent is a + // confirmation the driver raises now, so there is one chord and one row. "Esc", - // The one key in this product that changes the operator's files on - // io-cli's own initiative rather than the agent's, which is why it is two - // presses and why it is documented as two. - "Esc Esc", "Shift+Tab", "Ctrl+T", // 0.8.0. It has a key as well as `/fleet` because the moment it is worth diff --git a/tests/keys.rs b/tests/keys.rs index 0664460..4c68edf 100644 --- a/tests/keys.rs +++ b/tests/keys.rs @@ -220,7 +220,10 @@ fn f9_the_table_marks_the_fixed_key() { .expect("Ctrl+C is in the table"); assert!(what.contains("(fixed)"), "{what:?}"); - for movable in ["Ctrl+D", "Ctrl+L", "Ctrl+T", "Shift+Tab", "Esc Esc"] { + // `Esc` rather than `Esc Esc` since 0.39.0 — the rewind is one chord and its + // consent is a confirmation the driver raises. It is still rebindable, which + // is what this loop is about. + for movable in ["Ctrl+D", "Ctrl+L", "Ctrl+T", "Shift+Tab", "Esc"] { let (_, what) = rows .iter() .find(|(key, _)| key == movable) @@ -345,30 +348,35 @@ fn the_defaults_are_unchanged() { assert_eq!(session.key(ctrl('t')), Command::Transcript); assert_eq!(session.key(ctrl('d')), Command::Exit); - // The sequence, and the arming that any other key clears. - assert_eq!(session.key(plain(KeyCode::Esc)), Command::ArmRewind); - assert!(session.armed()); - assert_eq!(session.key(ctrl('l')), Command::ClearViewport); - assert!(!session.armed(), "an unrelated key disarms it"); - assert_eq!(session.key(plain(KeyCode::Esc)), Command::ArmRewind); + // The rewind, which asks rather than arming since 0.39.0: one press raises + // `/undo`'s confirmation and nothing is left half-pressed behind it. assert_eq!(session.key(plain(KeyCode::Esc)), Command::Rewind); + assert!(!session.armed()); + assert_eq!(session.key(ctrl('l')), Command::ClearViewport); + assert!(!session.armed()); } -/// A sequence is rebindable as a sequence, and it still asks twice. +/// A sequence is rebindable as a sequence, and the rewind still asks. /// /// The rewind is the one key in the product that changes the operator's files on -/// io-cli's own initiative, and the second press is the whole of its consent. A -/// rebinding that collapsed it to one chord would be a rebinding that removed a -/// confirmation, which is not a preference. +/// io-cli's own initiative, and its consent is a confirmation the driver raises. +/// A rebinding cannot remove that, because the consent is no longer carried by +/// the number of keystrokes: **whichever chord an operator binds, and however +/// many presses it takes to complete, the last press raises the question.** That +/// is a stronger property than the one this test held before 0.39.0, where a +/// binding of one chord would have collapsed a two-press confirmation into one. #[test] -fn a_rebound_sequence_still_asks_twice() { +fn a_rebound_sequence_still_asks() { let (keys, notices) = Keys::resolve(Some(&asked(&[("rewind", "ctrl+r ctrl+r")]))); assert!(notices.is_empty(), "{notices:?}"); let mut session = app(keys); assert_eq!(session.key(plain(KeyCode::Esc)), Command::None, "Esc moved"); - assert_eq!(session.key(ctrl('r')), Command::ArmRewind); + // The first chord of a two-chord sequence completes nothing, so it neither + // asks nor acts — the arming machinery is untouched by 0.39.0. + assert_eq!(session.key(ctrl('r')), Command::None); assert!(session.armed()); + // The second completes it, and what it raises is the confirmation. assert_eq!(session.key(ctrl('r')), Command::Rewind); assert!(!session.armed()); } @@ -455,17 +463,31 @@ fn every_default_binding_is_a_row_of_the_table() { /// A chord bound to nothing is nothing, and a half-pressed sequence is not a /// press. +/// +/// **No shipped binding is a sequence as of 0.39.0**, so the arming half is +/// driven by a rebound one. The rewind was the only two-chord default; its +/// consent moved from a second keystroke to a confirmation the driver raises, +/// because a footer line the operator never read as a question is what the +/// 2026-09-05 field test lost two files to. #[test] fn an_unbound_chord_reaches_the_prompt() { let keys = Keys::default(); assert_eq!(keys.hit(Chord::of(ctrl('q')), None), None); assert_eq!( keys.hit(Chord::of(plain(KeyCode::Esc)), None), + Some(Hit::Fire(Action::Rewind)), + "the shipped rewind is one chord, and what it fires is a question", + ); + + let (rebound, notices) = Keys::resolve(Some(&asked(&[("rewind", "ctrl+r ctrl+r")]))); + assert!(notices.is_empty(), "{notices:?}"); + assert_eq!( + rebound.hit(Chord::of(ctrl('r')), None), Some(Hit::Arm(Action::Rewind)), "the first chord of a sequence arms; it does not fire", ); assert_eq!( - keys.hit(Chord::of(plain(KeyCode::Esc)), Some(Action::Rewind)), + rebound.hit(Chord::of(ctrl('r')), Some(Action::Rewind)), Some(Hit::Fire(Action::Rewind)), ); } diff --git a/tests/palette.rs b/tests/palette.rs index f765550..2dd5f44 100644 --- a/tests/palette.rs +++ b/tests/palette.rs @@ -142,15 +142,31 @@ fn f1_a_slash_at_an_empty_prompt_opens_the_palette_and_nowhere_else() { app.armed(), )); - // A half-pressed rewind is the one sequence in this product whose second - // press changes the operator's files. The driver opens the palette *in front - // of* `App::key`, which is what disarms — so the palette declines while - // something is armed, the `/` falls through to the session, and the arming is - // cleared by the keystroke exactly as every other key clears it. Without this - // the arming would survive the palette and a later `Esc` would fire a rewind - // nobody was still expecting. + // A half-pressed sequence is a keystroke the session is still waiting to + // complete. The driver opens the palette *in front of* `App::key`, which is + // what disarms — so the palette declines while something is armed, the `/` + // falls through to the session, and the arming is cleared by the keystroke + // exactly as every other key clears it. Without this the arming would survive + // the palette and a later chord would complete a sequence nobody was still + // expecting. + // + // **Driven by a rebound rewind since 0.39.0.** The default binding is one + // chord now — the rewind's consent is a confirmation the driver raises, not a + // second press — so nothing in the shipped keymap arms, and a test that used + // `Esc` here would be asserting the guard against a state it can no longer + // reach. A two-chord binding still arms, and that is what this drives. let mut app = App::new(DARK, "m"); - assert_eq!(app.key(key(KeyCode::Esc)), Command::ArmRewind); + let (keys, notices) = io_cli::keys::Keys::resolve(Some( + &[("rewind".to_string(), "ctrl+r ctrl+r".to_string())] + .into_iter() + .collect(), + )); + assert!(notices.is_empty(), "{notices:?}"); + app.set_keys(keys); + assert_eq!( + app.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)), + Command::None, + ); assert!(app.armed()); assert!( !commands::opens_palette(slash(), app.composer.is_empty(), app.armed()), diff --git a/tests/rewind.rs b/tests/rewind.rs index 1d14399..8a7008d 100644 --- a/tests/rewind.rs +++ b/tests/rewind.rs @@ -809,44 +809,45 @@ fn press(app: &mut App, code: KeyCode) -> Command { app.key(KeyEvent::new(code, KeyModifiers::NONE)) } +/// **F3 — one press asks, and no press arms.** +/// +/// Through 0.38.2 the first `Esc` armed and wrote what it would undo into the +/// scrollback; the second acted. That is a warning only for an operator who +/// pressed the key meaning to undo. The 2026-09-05 field test pressed it to +/// dismiss a picker, never read the footer as a question, and the next press put +/// two files back. +/// +/// So the keystroke now raises `/undo`'s own confirmation and nothing arms. The +/// arming flag is asserted at every step, because a stale one is exactly how a +/// later release would reintroduce a second press that acts. +/// +/// Sabotage: set `self.armed` in the rewind arm again. This fails on the flag +/// while the command stays `Rewind`, which is the failure worth having — the +/// return value alone would not have caught it. #[test] -fn f11_the_first_escape_arms_and_the_second_performs() { +fn f3_one_escape_asks_and_nothing_arms() { let mut app = app(); assert!(!app.armed(), "a session does not begin armed"); - assert_eq!(press(&mut app, KeyCode::Esc), Command::ArmRewind); - assert!(app.armed(), "the first press must arm rather than act"); - - assert_eq!(press(&mut app, KeyCode::Esc), Command::Rewind); + assert_eq!( + press(&mut app, KeyCode::Esc), + Command::Rewind, + "one press raises the question the driver confirms with", + ); assert!( !app.armed(), - "performing must disarm, or a third press would undo a second turn", + "the rewind armed. A second press of a key an operator may have pressed \ + for some other reason is not consent to change their files", ); -} -#[test] -fn f11_any_other_key_disarms_and_the_next_escape_arms_again() { - let mut app = app(); - assert_eq!(press(&mut app, KeyCode::Esc), Command::ArmRewind); - - // A keystroke in between. The operator went to type something and changed - // their mind; the arming must not survive it. - press(&mut app, KeyCode::Char('h')); - assert!(!app.armed(), "any other key cancels the arming"); - - // And the composer now holds text, so `Esc` is no longer the rewind key at - // all — clear it and check the arming restarts from the beginning rather than - // firing, which is what a stale flag would do. - press(&mut app, KeyCode::Backspace); - assert_eq!( - press(&mut app, KeyCode::Esc), - Command::ArmRewind, - "after a cancel the next Esc must arm, never perform", - ); + // And again: a question that was asked and (from this side) not answered + // leaves nothing behind that a repeat press could fire. + assert_eq!(press(&mut app, KeyCode::Esc), Command::Rewind); + assert!(!app.armed()); } #[test] -fn f11_escape_with_text_in_the_composer_is_not_a_rewind() { +fn f3_escape_with_text_in_the_composer_is_not_a_rewind() { let mut app = app(); press(&mut app, KeyCode::Char('n')); press(&mut app, KeyCode::Char('o')); @@ -854,10 +855,9 @@ fn f11_escape_with_text_in_the_composer_is_not_a_rewind() { let what = press(&mut app, KeyCode::Esc); assert_ne!( what, - Command::ArmRewind, - "a typed prompt is not an empty one" + Command::Rewind, + "a typed prompt is not an empty one, and `Esc` there is not an undo", ); - assert_ne!(what, Command::Rewind); assert!(!app.armed()); } diff --git a/tests/undo.rs b/tests/undo.rs index 3b07f33..b324030 100644 --- a/tests/undo.rs +++ b/tests/undo.rs @@ -415,6 +415,14 @@ fn the_undo_confirmations_decline_at_row_zero() { for (what, rows) in [ ("file", confirm_file("src/a.rs").1), ("step", confirm_step(4).1), + // The whole turn, which the `Esc` chord now raises as well as `/undo`. + // It is the largest of the three and the one the field test lost files + // to, so a decline that is not at row 0 here is the worst version of the + // mistake this loop exists to catch. + ( + "turn", + io_cli::undo::confirm_turn("undo the last turn?".to_string()).1, + ), ] { assert_eq!(rows[0].label, io_cli::store::LEAVE_IT, "{what}"); assert!(!io_cli::store::acts(0), "{what}"); From 0dc99753af8b211eac03a2fc63348994570da20b Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 19:03:30 +0530 Subject: [PATCH 06/27] feat(palette): a command line typed into the palette runs `/effort high` matched no palette row and `Enter` did nothing at all, so a line copied out of the guides -- or sent into a session by a script -- sat behind `No row matches` with no way forward. The row is `/effort`; the argument is not part of it, so the failure to match said nothing about whether the command existed. `Outcome::Typed` is the palette handing the whole line back, and the line itself comes through `Picker::query` so the enum stays `Copy` -- nine call sites match on it. Only the palette opts in, through `Picker::taking_a_line`: its rows are a vocabulary, while every other picker in the product offers the sessions that exist, the models a provider serves, the files a rewind would restore, and a query matching none of those is a query for something that is not there. At an idle prompt it runs on that one `Enter`. The line goes into the composer with its `/` put back -- the slash never reached the prompt, because opening the palette is what it did -- and the picker block deliberately does not `continue`, so the same keystroke falls through to `App::key` and is submitted exactly as a hand-typed line would be. A `continue` there would leave it waiting for a second press, which is better than doing nothing and still not what a pasted line should need. Mid-turn it fills the prompt and stops. Submitting mid-turn does not run a command -- it queues a prompt for the turn after this one -- so a line that ran itself there would be queueing work on a keystroke the operator pressed to close a palette. `Tab` never submits: completion on a query that completes to nothing is nothing, and an operator reaching for completion half-way through a command must not run it. An empty query is not a line either, or `Enter` on a freshly opened palette would submit a bare `/`. Every other `Outcome` match is named rather than wildcarded, so the next variant is a compile error at all nine sites rather than silence at eight of them. Sabotage: defaulting `takes_a_line` to true fails the arm that says why the flag exists. --- src/intent.rs | 5 ++- src/main.rs | 61 ++++++++++++++++++++++++++-- src/picker.rs | 58 +++++++++++++++++++++++++- src/wizard.rs | 28 +++++++++++-- tests/picker.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 10 deletions(-) diff --git a/src/intent.rs b/src/intent.rs index ccdd1b0..714adab 100644 --- a/src/intent.rs +++ b/src/intent.rs @@ -883,7 +883,10 @@ impl Intent { // question "does Ctrl+C decline, or interrupt?" with *it interrupts*. // An arm answering `Some(None)` here would be a second, contradictory // answer to that question, in a different file. - Outcome::Cancelled | Outcome::Idle => None, + // `Typed` cannot arrive either: it needs `Picker::taking_a_line`, and + // the palette is the only surface that asks for it. Named rather than + // wildcarded so the next variant is a compile error here too. + Outcome::Cancelled | Outcome::Idle | Outcome::Typed => None, } } diff --git a/src/main.rs b/src/main.rs index 9b63d2d..823fa12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1433,6 +1433,10 @@ async fn loop_over Result>( continue; } } + // Set only by `Outcome::Typed` below: the palette handed back a whole + // command line, and this keystroke has to reach `app.key` rather than + // stopping at the picker. + let mut submitting = false; // A picker owns the keyboard while it is open, which is what makes it a // modal overlay rather than a suggestion. if let Some((open, kind)) = picker.as_mut() { @@ -4357,9 +4361,36 @@ async fn loop_over Result>( picker = None; } Outcome::Idle => {} + // **The palette's query was a whole command line, so it runs on + // this `Enter` rather than the next one.** The line goes into the + // composer with its `/` put back — the slash never reached the + // prompt, because opening the palette is what it did — and then + // this block deliberately does *not* `continue`, so the same + // keystroke falls through to `app.key(key)` below and is + // submitted exactly as a hand-typed line would be. + // + // That fall-through is the whole of "one Enter". A `continue` + // here would leave the line sitting in the prompt waiting for a + // second press, which is better than 0.38.2 — where `Enter` on an + // unmatched query did nothing at all and `/effort high` was stuck + // behind `No row matches` — and still not what a line pasted from + // the guides should need. + Outcome::Typed => { + let line = format!("/{}", open.query()); + picker = None; + app.composer.set(&line); + submitting = true; + } + } + // Every outcome but `Typed` ends the keystroke here. A flag rather + // than a condition read back off the composer, because the states are + // otherwise indistinguishable: a cancelled palette also leaves + // `picker` empty, and the prompt it leaves behind may well begin with + // a slash the operator typed a minute ago. + if !submitting { + paint_picker(screen, &mut app, picker.as_mut())?; + continue; } - paint_picker(screen, &mut app, picker.as_mut())?; - continue; } // `/` at an empty prompt opens the palette, in front of the session @@ -4383,7 +4414,13 @@ async fn loop_over Result>( // is what `/model` already does against four hundred models. That is // the trade the release contract records, and the fallback if it turns // out wrong is *not* to restore the round trip. - picker = Some((Picker::new("Which command?", rows), Pick::Palette)); + // `.taking_a_line()` for the mid-turn palette's reason, and it has to + // be on both: a line pasted at an idle prompt is the case an operator + // meets first. + picker = Some(( + Picker::new("Which command?", rows).taking_a_line(), + Pick::Palette, + )); paint_picker(screen, &mut app, picker.as_mut())?; continue; } @@ -7260,6 +7297,17 @@ fn mid_turn_picker( } Outcome::Cancelled => *picker = None, Outcome::Idle => {} + // **Mid-turn the line goes into the prompt and is not submitted, and + // that is a difference with a reason.** Submitting mid-turn does not run + // a command — it queues a prompt for the turn after this one — so a line + // that ran itself here would be queueing work on a keystroke the operator + // pressed to close a palette. The idle door submits because there the + // same `Enter` means the same thing it would have meant at the prompt. + Outcome::Typed => { + let line = format!("/{}", open.query()); + *picker = None; + app.composer.set(&line); + } } } @@ -7792,7 +7840,12 @@ async fn turn( Picker::new( "Which command?", commands::palette(templates, skills), - ), + ) + // The palette's rows are a vocabulary, so `Enter` + // on a query matching none of them hands the line + // back to be parsed rather than doing nothing. + // See `Outcome::Typed`. + .taking_a_line(), Pick::Palette, )); Command::None diff --git a/src/picker.rs b/src/picker.rs index 1b940d1..3147085 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -150,6 +150,28 @@ pub enum Outcome { Chosen(usize), /// The user backed out. Cancelled, + /// `Enter` on a query that matches no row, in a picker that takes one + /// (0.39.0). + /// + /// **The palette is the only surface that opts in, and the reason is that it + /// is the only one whose rows are a *vocabulary* rather than a list of + /// things.** Every other picker in the product offers the sessions that + /// exist, the models a provider serves, the files a rewind would restore — a + /// query matching none of those is a query for something that is not there, + /// and the honest answer is the empty list. The palette's rows are commands, + /// and `/effort high` matches none of them for a reason that has nothing to + /// do with whether `/effort` exists: the row is `/effort` and the argument is + /// not part of it. + /// + /// So a line pasted from the guides, or sent by a script into a session, sat + /// in the filter reading `No row matches` and `Enter` did nothing at all. + /// This carries the whole typed line back to the caller, which parses it the + /// way a typed prompt is parsed. + /// + /// The line itself is read back with [`Picker::query`] rather than carried + /// here, so this enum stays `Copy` — nine call sites match on it and several + /// do so in a loop. + Typed, } pub struct Picker { @@ -227,6 +249,15 @@ pub struct Picker { /// under the marker, and there is only ever one of those. What is reserved is /// another question; see [`Self::rows_wanted`]. unfolds: BTreeMap, + /// Whether `Enter` on a query that matches nothing hands the line back + /// instead of doing nothing (0.39.0). See [`Outcome::Typed`], and + /// [`Picker::taking_a_line`], which is the only thing that sets it. + /// + /// Off by default, and that default is the interesting half: on every other + /// picker in the product a query matching no row is a query for something + /// that is not there, and handing the text back would offer the operator a + /// session, a model or a file that does not exist. + takes_a_line: bool, /// Where the unfolded block was drawn by the last [`Self::render`], for the /// caller to draw into. /// @@ -250,6 +281,7 @@ impl Picker { marks: BTreeSet::new(), unfolds: BTreeMap::new(), opened: None, + takes_a_line: false, }; // A grouped list opens with a heading in the first slot, and the marker // may not rest on one. Stepping here rather than only in `refilter` @@ -261,6 +293,24 @@ impl Picker { picker } + /// This picker's rows are a vocabulary, so a query matching none of them may + /// still be a line worth running (0.39.0). + /// + /// **The palette, and nothing else.** `Enter` on an unmatched query hands the + /// line back as [`Outcome::Typed`] rather than doing nothing, which is what + /// lets `/effort high` — a line from the guides, or one a script sent into a + /// session — run instead of sitting in the filter reading `No row matches`. + /// The row is `/effort` and the argument is not part of it, so the failure to + /// match says nothing about whether the command exists. + /// + /// Not on any other picker, deliberately: a query that matches no session, no + /// model and no file is a query for something that is not there. + #[must_use] + pub fn taking_a_line(mut self) -> Self { + self.takes_a_line = true; + self + } + /// Open with a row already selected — what `/theme` does, so the picker opens /// on the theme in use rather than on the first one in the list. /// @@ -691,7 +741,13 @@ impl Picker { .get(self.cursor) .is_some_and(|index| self.rows[*index].heading); if self.matches.is_empty() || on_heading { - Outcome::Idle + // **`Enter` only, never `Tab`.** Completion on a query that + // completes to nothing is nothing; submitting is a different + // act and takes the key that means it. + match (key.code, self.takes_a_line, self.query.is_empty()) { + (KeyCode::Enter, true, false) => Outcome::Typed, + _ => Outcome::Idle, + } } else { Outcome::Chosen(self.selected()) } diff --git a/src/wizard.rs b/src/wizard.rs index 97130b5..1b7c9d3 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -474,7 +474,12 @@ impl Wizard { self.step = Step::Cancelled; Progress::Cancelled } - Outcome::Idle => Progress::Idle, + // `Typed` needs `Picker::taking_a_line`, which only the palette asks + // for; the wizard's rows are things that exist — providers, models, + // themes, postures — and a query matching none of them is a query for + // one that does not. Named rather than wildcarded so the next variant + // is a compile error here as well. + Outcome::Idle | Outcome::Typed => Progress::Idle, } } @@ -537,7 +542,12 @@ impl Wizard { self.step = Step::Cancelled; Progress::Cancelled } - Outcome::Idle => Progress::Idle, + // `Typed` needs `Picker::taking_a_line`, which only the palette asks + // for; the wizard's rows are things that exist — providers, models, + // themes, postures — and a query matching none of them is a query for + // one that does not. Named rather than wildcarded so the next variant + // is a compile error here as well. + Outcome::Idle | Outcome::Typed => Progress::Idle, } } @@ -626,7 +636,12 @@ impl Wizard { self.step = Step::Cancelled; Progress::Cancelled } - Outcome::Idle => Progress::Idle, + // `Typed` needs `Picker::taking_a_line`, which only the palette asks + // for; the wizard's rows are things that exist — providers, models, + // themes, postures — and a query matching none of them is a query for + // one that does not. Named rather than wildcarded so the next variant + // is a compile error here as well. + Outcome::Idle | Outcome::Typed => Progress::Idle, } } @@ -645,7 +660,12 @@ impl Wizard { self.step = Step::Cancelled; Progress::Cancelled } - Outcome::Idle => Progress::Idle, + // `Typed` needs `Picker::taking_a_line`, which only the palette asks + // for; the wizard's rows are things that exist — providers, models, + // themes, postures — and a query matching none of them is a query for + // one that does not. Named rather than wildcarded so the next variant + // is a compile error here as well. + Outcome::Idle | Outcome::Typed => Progress::Idle, } } diff --git a/tests/picker.rs b/tests/picker.rs index 87efe79..be58840 100644 --- a/tests/picker.rs +++ b/tests/picker.rs @@ -1181,3 +1181,108 @@ fn t04_a_wrapped_unfold_reserves_the_measured_height() { "the second row opened the first row's height", ); } + +fn type_into(picker: &mut Picker, text: &str) { + for c in text.chars() { + picker.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } +} + +/// **F5 — a query that matches no row is still a line worth running, in the one +/// picker whose rows are a vocabulary.** +/// +/// `/effort high` matches no palette row for a reason that has nothing to do with +/// whether `/effort` exists: the row is `/effort` and the argument is not part of +/// it. Through 0.38.2 `Enter` there returned `Idle` and did nothing at all, so a +/// line pasted from the guides — or sent into a session by a script — sat behind +/// `No row matches` with no way forward. +/// +/// The line comes back through `query()` rather than on the variant, so `Outcome` +/// stays `Copy`; this asserts both halves, because a signal with no way to read +/// what it signalled is not a fix. +/// +/// Sabotage: drop the `KeyCode::Enter` arm from the empty-matches branch. Only +/// this fails. +#[test] +fn f5_enter_on_an_unmatched_query_hands_the_line_back() { + let mut palette = Picker::new( + "Which command?", + vec![Row::new("/effort"), Row::new("/model")], + ) + .taking_a_line(); + + type_into(&mut palette, "effort high"); + assert_eq!( + palette.matching(), + 0, + "the fixture needs an unmatched query" + ); + + assert_eq!( + palette.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Outcome::Typed, + "`Enter` on a whole command line did nothing, which is where a line \ + pasted from the guides used to stop", + ); + assert_eq!( + palette.query(), + "effort high", + "the arguments are the half the row could not carry, so a line handed \ + back without them is the same loss in a different place", + ); +} + +/// **F5 — `Tab` completes and does not submit.** +/// +/// Completion on a query that completes to nothing is nothing. Submitting is a +/// different act and takes the key that means it — otherwise an operator +/// half-way through typing a command, reaching for completion, runs it. +#[test] +fn f5_tab_never_submits_a_line() { + let mut palette = Picker::new("Which command?", vec![Row::new("/effort")]).taking_a_line(); + type_into(&mut palette, "effort high"); + + assert_eq!( + palette.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), + Outcome::Idle, + "`Tab` submitted a line. It is the completion key, and completing to \ + nothing is nothing", + ); +} + +/// **F5 — every other picker still answers an unmatched query with silence.** +/// +/// The palette's rows are a vocabulary; every other picker in the product offers +/// the sessions that exist, the models a provider serves, the files a rewind +/// would restore. A query matching none of those is a query for something that is +/// not there, and handing the text back would offer the operator a thing that +/// does not exist. +/// +/// Sabotage: make `takes_a_line` default to true. Only this fails, and it is the +/// arm that says why the flag exists at all. +#[test] +fn f5_a_picker_that_did_not_ask_for_a_line_does_not_take_one() { + let mut sessions = Picker::new("Which session?", vec![Row::new("a-real-session")]); + type_into(&mut sessions, "no-such-session"); + assert_eq!(sessions.matching(), 0); + + assert_eq!( + sessions.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Outcome::Idle, + "a picker over things that exist handed back a name for one that does not", + ); +} + +/// **F5 — an empty query is not a line.** +/// +/// A palette opened and immediately answered with `Enter` has nothing typed in +/// it. Handing back an empty line would submit a bare `/`, which parses as no +/// command and reads as the interface having done something arbitrary. +#[test] +fn f5_an_empty_query_is_not_a_line() { + let mut palette = Picker::new("Which command?", Vec::new()).taking_a_line(); + assert_eq!( + palette.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + Outcome::Idle, + ); +} From 89851bb12771baec53239eda90595e8c7076c940 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 19:16:53 +0530 Subject: [PATCH 07/27] test(pty): drive the binary at a real terminal, and fix what that found `src/main.rs` cannot be linked from anything under `tests/`, and the recorder backend every other test uses is not a tty. Between those two facts sits every decision the driver makes about a keystroke -- which surface owns the keyboard, what a key that reaches no surface does, whether a keystroke that closed one thing also armed another. All three ways the 2026-09-05 field test lost an operator's work live there, and 2,000 offline tests were green over every one of them. `tests/pty.rs` opens a pty with `libc::openpty`, gives the child the terminal as its controlling one, and drives `io` the way the field test drove it. Four arms run in CI: the session starts and draws a prompt, `Esc` at an empty prompt is answered rather than armed, a command line with an argument runs on one `Enter`, and `Ctrl+D` ends the process rather than only saying so. The harness answers `ESC[6n` itself -- `io` will not draw a frame until the terminal says where the cursor is, and a pty with nothing behind it is exactly the case `src/term.rs` carries a two-second wait and an error message for. **It found a production defect on its first run.** The session lock was taken in `home::path()` -- the crate's own `~/.io-cli` -- while `lock::paths` keys the file on a session id, and a session id is only unique within one `runs.db`: `Session::open` numbers from 1 in a fresh store. So every store's session 1 shared one `session-1.lock`, and two `io` processes pointed at different `IO_CONFIG_HOME`s refused each other over a collision that means nothing. The product said so itself and printed the refusal anyway -- its wording is "that should not be possible for a session just created". The lock now lives in `home::in_force`, which is the directory `settings::store_path` puts `runs.db` in. For an operator who has set neither variable it is the same directory as before. Three smaller things the harness taught, each written into the file rather than worked around: a `[[provider]]` in a file inside the workspace is refused by io-harness, so the configuration lives outside the tree `-C` points at; `IO_CONFIG` alone does not isolate a test, because the store follows `IO_CONFIG_HOME`; and a harness that stops reading the pty while it waits for a child blocks that child inside the writes that hand the terminal back. `tests/timing.rs` bans clocks and sleeps in tests, and this file needs both to wait for a process. It is exempt by exact path -- compared with `==`, never a substring -- and held to properties instead of trusted: every bound must be a liveness deadline measured in seconds, and a sub-five-second one is refused as a performance assertion wearing a liveness bound's clothes. Sabotage: dropping `WAIT` to two seconds fails that guard and names the line. The two contracts that need a turn in flight -- an approval raised by a real write, and `Ctrl+C` interrupting a step -- need a provider that answers and are not here yet. --- src/main.rs | 21 ++- tests/pty.rs | 456 ++++++++++++++++++++++++++++++++++++++++++++++++ tests/timing.rs | 75 ++++++++ 3 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 tests/pty.rs diff --git a/src/main.rs b/src/main.rs index 823fa12..8533917 100644 --- a/src/main.rs +++ b/src/main.rs @@ -583,7 +583,26 @@ async fn drive( // `Drop`, and it must outlive every turn this process takes. `let _ = …` // would release it on the next line, and a plain name would be a warning // about the one thing that is deliberate here. - let _session_lock = match io_cli::home::path() { + // **The lock lives beside the store it names a session in, not beside the + // crate's own home (0.39.0).** `lock::paths` keys a file on the session id, + // and a session id is only unique *within one `runs.db`* — `Session::open` + // numbers from 1 in a fresh store. Taking the lock in `home::path()` put + // every store's session 1 on one `session-1.lock`, so two `io` processes + // pointed at different `IO_CONFIG_HOME`s refused each other for a collision + // that means nothing: different conversations, in different stores, sharing a + // number. The product said so itself — the refusal below is worded "that + // should not be possible for a session just created" — and printed it anyway. + // + // `home::in_force` is the directory the user-scope configuration is in, which + // is exactly where `settings::store_path` puts `runs.db`. For an operator who + // has set neither variable it is the same directory as before, so nothing + // moves for the common case; what changes is that a moved store takes its + // locks with it. + // + // Found by `tests/pty.rs`, which runs several sessions at once with a home + // apiece, on the first day this repository could start the binary at a + // terminal at all. + let _session_lock = match io_cli::home::in_force().map(|(dir, _)| dir) { Some(home) => { // The only clock read on this path, and it is here because // `src/main.rs` is the one file `tests/timing.rs` permits one in. diff --git a/tests/pty.rs b/tests/pty.rs new file mode 100644 index 0000000..cdc8523 --- /dev/null +++ b/tests/pty.rs @@ -0,0 +1,456 @@ +//! The keystroke contracts, driven against the built binary at a real terminal. +//! +//! **This file exists because `src/main.rs` cannot be linked from anything under +//! `tests/`, and because the recorder backend every other test uses is not a +//! tty.** Between those two facts sits every decision the driver makes about a +//! keystroke: which surface owns the keyboard, what a key that reaches no surface +//! does, whether a keystroke that closed one thing also armed another. The +//! 2026-09-05 field test found three ways an operator loses their work in exactly +//! that gap, and 2,000 offline tests were green over all three. +//! +//! So this drives `io` itself, through a pty, the way the field test was driven. +//! +//! # What runs in CI and what does not +//! +//! **A session needs a provider before it will take a keystroke, and a turn needs +//! one that answers.** The arms here configure a `compatible` endpoint at an +//! address nothing serves: that is enough to start a session, draw a prompt and +//! exercise every surface that does not require a completion, and it reaches no +//! network because no turn is submitted. Those arms run everywhere. +//! +//! The two contracts that need a turn in flight — an approval raised by a real +//! write, and `Ctrl+C` interrupting a step — cannot be driven without a provider +//! that answers, so they are `#[ignore]`d and keyed, exactly as `tests/live.rs` +//! is. `US-IO-CLI-0.39.0-I01` records that; the criterion was written expecting +//! both halves in CI. +//! +//! # Unix only, and the skip is not silent +//! +//! `openpty` is POSIX. On Windows this whole file compiles to one test that +//! **says** it was skipped rather than reporting a pass, because a skip that +//! reads like a pass is the failure mode this repository has already paid for in +//! its containment tests. + +#![allow(clippy::undocumented_unsafe_blocks)] + +#[cfg(not(unix))] +#[test] +fn the_keystroke_contracts_are_not_driven_on_this_platform() { + // Deliberately not `#[ignore]`: an ignored test is a line in the output that + // reads the same as a test nobody wrote. This one runs, passes, and names + // what did not happen. + println!( + "SKIPPED: the pty arms need `openpty`, which is POSIX. The keystroke \ + contracts are gated on the two unix runners; this platform runs the \ + library halves in tests/approval.rs, tests/rewind.rs and tests/picker.rs \ + only." + ); +} + +#[cfg(unix)] +mod unix { + use std::io::{Read, Write}; + use std::os::unix::io::{FromRawFd, RawFd}; + use std::os::unix::process::CommandExt; + use std::process::{Child, Command, Stdio}; + use std::time::{Duration, Instant}; + + /// How long an arm waits for a string to appear before calling it absent. + /// + /// Generous, because this runs on shared CI runners where a cold binary's + /// first frame can be slow, and because the failure this bound produces is a + /// clear one: the arm reports everything the terminal was sent. A tighter + /// bound would turn a loaded runner into a red build, which is how a gate + /// teaches a team to re-run rather than to read. + const WAIT: Duration = Duration::from_secs(20); + + /// A running `io`, with the terminal it thinks it is talking to. + struct Session { + child: Child, + master: std::fs::File, + seen: String, + /// How many cursor-position queries have been answered. See + /// [`Session::drain`]. + answered: usize, + /// Kept alive for the child's lifetime — see [`Session::start`]. + _home: tempfile::TempDir, + /// Likewise, and it is deliberately **not** the workspace: see + /// [`Session::start`]. + _workspace: tempfile::TempDir, + } + + impl Session { + /// Start `io` on a pty, in a workspace and a home of its own. + /// + /// **The configuration names a provider at an address nothing serves.** + /// A session refuses to start without one and never dials until a turn is + /// submitted, so this is what buys a real prompt with no network and no + /// credential. `IO_CONFIG` is set rather than `IO_CONFIG_HOME`, so + /// `home::adopt` does nothing and no file of the developer's is read or + /// moved. + /// + /// **The configuration lives outside the workspace, and that is not + /// tidiness.** io-harness refuses a `[[provider]]` in any file inside the + /// workspace — "a provider names the endpoint this run's credential is + /// sent to, and `io.toml` arrives with a `git clone`" — so a config + /// written beside the tree `-C` points at makes the session refuse to + /// start. The first run of this harness found that, which is the argument + /// for the harness. + fn start(rows: u16, cols: u16) -> Self { + let home = tempfile::tempdir().expect("a home"); + let workspace = tempfile::tempdir().expect("a workspace"); + let config = home.path().join("io.toml"); + std::fs::write( + &config, + "[[provider]]\nkind = \"compatible\"\nmodel = \"a-model\"\n\ + base_url = \"http://127.0.0.1:9\"\napi_key = \"not-a-key\"\n", + ) + .expect("the configuration"); + + let (master, slave) = open_pty(rows, cols); + + // Three separate descriptors: the child closes each on exec and + // `Stdio::from` takes ownership of what it is given. + let (stdin, stdout, stderr) = unsafe { + ( + Stdio::from(std::fs::File::from_raw_fd(dup(slave))), + Stdio::from(std::fs::File::from_raw_fd(dup(slave))), + Stdio::from(std::fs::File::from_raw_fd(dup(slave))), + ) + }; + + let mut command = Command::new(env!("CARGO_BIN_EXE_io")); + command + .arg("-C") + .arg(workspace.path()) + .env("IO_CONFIG", &config) + // **`IO_CONFIG` alone is not isolation, and the first run of this + // harness proved it.** The configuration followed `IO_CONFIG`, + // but the run store is `settings::store_path()`, which follows + // `IO_CONFIG_HOME` — so these arms opened sessions in the + // developer's own `~/.io-cli/runs.db`, and one of them was + // refused by a session lock held by a *different* `io` (a real + // one, version 0.38.0). Setting both puts the store in the + // temporary home with the file. `home::adopt` does nothing when + // either variable is set to a non-empty value, so nothing of the + // developer's is read or moved either way. + .env("IO_CONFIG_HOME", home.path()) + .env("TERM", "xterm-256color") + // Colour off, so an assertion reads words rather than escape + // sequences wrapped around them. + .env("NO_COLOR", "1") + .env_remove("OPENROUTER_API_KEY") + .env_remove("ANTHROPIC_API_KEY") + .env_remove("OPENAI_API_KEY") + .stdin(stdin) + .stdout(stdout) + .stderr(stderr); + + // The child needs the pty as its **controlling** terminal, or + // crossterm's raw mode and every ioctl on it fail. A new session + // first, because a process that already has a controlling terminal + // cannot take another. + unsafe { + command.pre_exec(move || { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::ioctl(0, libc::TIOCSCTTY as _, 0) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + let child = command.spawn().expect("io starts"); + // The parent's copy of the slave end is closed, so reading the master + // reports end-of-file when the child exits rather than blocking for + // ever on a descriptor this process is still holding open. + unsafe { libc::close(slave) }; + + Self { + child, + master: unsafe { std::fs::File::from_raw_fd(master) }, + seen: String::new(), + answered: 0, + _home: home, + _workspace: workspace, + } + } + + /// Type, as a terminal delivers it. + fn type_in(&mut self, text: &str) { + self.master + .write_all(text.as_bytes()) + .expect("the terminal accepts input"); + self.master.flush().expect("flush"); + } + + /// Read whatever has arrived, without waiting — and answer the terminal + /// queries `io` will not draw a frame without. + /// + /// **`io` writes `ESC[6n` and waits for a reply before it draws + /// anything.** ratatui's inline viewport computes its position by asking + /// the terminal where the cursor is, and `src/term.rs` carries a + /// two-second wait and a whole error message for the case where nothing + /// answers. A pty with no emulator behind it is exactly that case, so a + /// harness that only reads is a harness that watches the binary give up. + /// + /// The reply is always `1;1`. This is not a terminal emulator and does + /// not track a grid; what the arms here assert is which words were + /// written, and a constant position is enough to get them written. Every + /// query is answered exactly once, counted over the whole stream rather + /// than per read, because the six bytes can arrive split across two. + fn drain(&mut self) { + const QUERY: &str = "\u{1b}[6n"; + + let mut buffer = [0u8; 8192]; + loop { + match self.master.read(&mut buffer) { + Ok(0) => break, + Ok(n) => self.seen.push_str(&String::from_utf8_lossy(&buffer[..n])), + // The child has written nothing since the last read. Not an + // error: this is a non-blocking descriptor by construction. + Err(_) => break, + } + } + + let asked = self.seen.matches(QUERY).count(); + while self.answered < asked { + // Written directly rather than through `type_in`, which asserts: + // a query arriving as the child exits would otherwise turn a + // clean shutdown into a broken-pipe panic in the harness. + let _ = self.master.write_all(b"\x1b[1;1R"); + let _ = self.master.flush(); + self.answered += 1; + } + } + + /// Wait for `needle` to be drawn, or fail saying what was drawn instead. + /// + /// **Whitespace is removed from both sides before comparing**, and that + /// is not laziness about the assertion. ratatui positions text with + /// escape sequences rather than padding it with spaces, so a row drawn as + /// `there is no turn to undo` arrives on the wire as + /// `thereisnoturntoundo` once the sequences are stripped. Matching the + /// squeezed form asserts the words and their order, which is what these + /// arms are about, and asks nothing of a layout they are not testing. + fn wait_for(&mut self, needle: &str) { + let deadline = Instant::now() + WAIT; + let wanted = squeezed(needle); + while Instant::now() < deadline { + self.drain(); + if squeezed(&plain(&self.seen)).contains(&wanted) { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + panic!( + "{needle:?} was never drawn in {WAIT:?}. The terminal was sent:\n{}", + plain(&self.seen) + ); + } + + /// Everything drawn so far, with the escape sequences taken out. + fn screen(&mut self) -> String { + self.drain(); + plain(&self.seen) + } + } + + impl Drop for Session { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } + + /// A pty pair sized like a terminal, both ends returned raw. + fn open_pty(rows: u16, cols: u16) -> (RawFd, RawFd) { + let mut master: RawFd = -1; + let mut slave: RawFd = -1; + let mut size = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let opened = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut size, + ) + }; + assert!(opened == 0, "openpty: {}", std::io::Error::last_os_error()); + // Non-blocking, so `drain` can ask "is there anything?" rather than + // waiting for a child that may have nothing more to say. + unsafe { libc::fcntl(master, libc::F_SETFL, libc::O_NONBLOCK) }; + (master, slave) + } + + /// The same text with every space, tab and newline taken out. + /// + /// See [`Session::wait_for`] for why the comparison is made this way. + fn squeezed(text: &str) -> String { + text.chars().filter(|c| !c.is_whitespace()).collect() + } + + fn dup(fd: RawFd) -> RawFd { + let copy = unsafe { libc::dup(fd) }; + assert!(copy >= 0, "dup: {}", std::io::Error::last_os_error()); + copy + } + + /// Escape sequences out, text left. + /// + /// Crude on purpose: this is not a terminal emulator and does not need to be. + /// Every assertion in this file is "was this word drawn", and a CSI stripper + /// answers that without a grid. + fn plain(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + match chars.next() { + // CSI: parameters, then one final byte in `@`..`~`. + Some('[') => { + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + } + // OSC: ends at BEL or ST. + Some(']') => { + while let Some(c) = chars.next() { + if c == '\u{7}' { + break; + } + if c == '\u{1b}' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + // A two-character escape; the second character is consumed. + _ => {} + } + } + out + } + + /// **F1/F5 — the binary starts at a real terminal and its prompt takes keys.** + /// + /// The floor every other arm here stands on, and it is not a formality: the + /// three ways 0.38.2 lost an operator's work were all decisions made in + /// `src/main.rs`, and until this file existed nothing under `tests/` had ever + /// started the binary with a terminal attached. + #[test] + fn f1_the_session_starts_at_a_terminal_and_draws_a_prompt() { + let mut io = Session::start(24, 100); + io.wait_for(">"); + io.type_in("hello"); + io.wait_for("hello"); + } + + /// **F5 — a line the palette cannot match runs, on one `Enter`.** + /// + /// The whole point of driving the binary: `/` opens the palette *in front of* + /// `App::key`, in `src/main.rs`, and whether the line that follows it ever + /// reaches a command is a property of that file alone. `/effort high` matches + /// no row — the row is `/effort` and the argument is not part of it — so + /// through 0.38.2 `Enter` did nothing at all and the line sat behind + /// `No row matches` with no way forward. + /// + /// **A query that *does* match a row still fills the prompt and waits**, and + /// that is unchanged rather than overlooked: an operator who opened the + /// palette with `/` and arrowed to a row is browsing, and may want to add an + /// argument before it runs. What this release fixes is the case where there + /// is nothing left to add because the operator typed the whole line. + /// + /// Sabotage: `continue` after `Outcome::Typed` in the idle picker block. The + /// line then waits for a second `Enter` and this arm times out on a prompt + /// holding exactly the text it was given. + #[test] + fn f5_a_line_with_an_argument_runs_on_one_enter() { + let mut io = Session::start(24, 100); + io.wait_for(">"); + + io.type_in("/effort high\r"); + + // The status line carries the effort once it is set, which is the + // observable an operator has. What matters is that anything happened at + // all: the pre-0.39.0 behaviour is the query sitting in the filter for + // ever. + io.wait_for("high"); + assert!( + !squeezed(&io.screen()).contains(&squeezed("No row matches")), + "the line stayed in the palette's filter: {}", + io.screen() + ); + } + + /// **F3 — `Esc` at an empty prompt asks, and asking is not doing.** + /// + /// The field test's second loss, at the terminal it happened on. With no turn + /// taken there is nothing to undo, so what this asserts is that the keystroke + /// is answered by a sentence rather than by silence — and, in the arm below + /// it, that a second press does not act on the first one's behalf. + #[test] + fn f3_escape_at_an_empty_prompt_is_answered_rather_than_armed() { + let mut io = Session::start(24, 100); + io.wait_for(">"); + + io.type_in("\u{1b}"); + io.wait_for("no turn to undo"); + + // The second press. Before 0.39.0 this is where the rewind happened; here + // it can only say the same thing again, and the assertion is that it does + // not silently do something else. + io.type_in("\u{1b}"); + io.wait_for("no turn to undo"); + } + + /// **F4 — `Ctrl+D` at an empty prompt leaves, and the process actually ends.** + /// + /// The bounded-exit half of F4 that needs no provider. A session that draws + /// "exiting" and stays resident is the shape of the interrupt defect the field + /// test found on `Ctrl+C`, and it is worth holding the door that can be tested + /// in CI to the bound the other one is held to by hand. + #[test] + fn f4_the_session_exits_when_it_says_it_is_exiting() { + let mut io = Session::start(24, 100); + io.wait_for(">"); + + io.type_in("\u{4}"); + + let deadline = Instant::now() + WAIT; + loop { + // **Drained on every pass, and leaving this out is what made the arm + // fail the first time it ran.** A pty has a small buffer: a harness + // that stops reading while it waits for the child to exit blocks the + // child inside the writes that hand the terminal back, and then waits + // out its own deadline watching a process it is itself holding still. + io.drain(); + if let Some(status) = io.child.try_wait().expect("wait") { + assert!( + status.success(), + "the session left with {status:?} after Ctrl+D at an empty prompt", + ); + return; + } + assert!( + Instant::now() < deadline, + "the session said it was leaving and did not: {}", + io.screen(), + ); + std::thread::sleep(Duration::from_millis(50)); + } + } +} diff --git a/tests/timing.rs b/tests/timing.rs index a6257fc..03f86f7 100644 --- a/tests/timing.rs +++ b/tests/timing.rs @@ -154,6 +154,56 @@ fn test_sources() -> Vec<(std::path::PathBuf, String)> { found } +/// The properties the one exempt file is held to, in place of the ban. +/// +/// **A deadline is allowed; impatience is not.** The pty harness waits for a +/// child process, so it reads a clock — but every wait it performs has to be a +/// generous liveness bound, and none of them may assert that something happened +/// within a short one. A `Duration::from_millis(200)` deadline in that file would +/// pass this scan's letter and be exactly the flaky gate the ban exists for, so +/// the bound itself is what is checked. +/// +/// Sub-second `Duration`s are permitted only for the poll interval between +/// attempts, which decides how often the harness looks and never whether it gives +/// up. They are told apart by name: a `WAIT`-style constant is a deadline, a +/// `sleep` argument is an interval. +fn assert_no_impatient_bound(path: &std::path::Path, source: &str, violations: &mut Vec) { + for (number, line) in source.lines().enumerate() { + let at = format!("{}:{}", path.display(), number + 1); + let trimmed = line.trim(); + // A deadline built from milliseconds is a deadline nobody should be + // building: the unit is the tell. + if trimmed.contains("Duration::from_millis") && !trimmed.contains("sleep") { + let is_interval = source + .lines() + .nth(number) + .is_some_and(|l| l.contains("sleep")); + if !is_interval { + violations.push(format!( + "{at}: {trimmed} — a deadline in this file is measured in \ + seconds. Milliseconds belong to the poll interval, which \ + decides how often it looks and never whether it gives up", + )); + } + } + // And a seconds bound has to be a generous one. Anything under five is a + // performance assertion wearing a liveness bound's clothes. + if let Some(rest) = trimmed.split_once("Duration::from_secs(") { + if let Some(number) = rest.1.split(')').next() { + if let Ok(seconds) = number.trim().parse::() { + if seconds < 5 { + violations.push(format!( + "{at}: a {seconds}s bound is a performance assertion, \ + not a liveness one. On a loaded runner it fires for \ + a reason that has nothing to do with the product", + )); + } + } + } + } + } +} + #[test] fn n1_no_test_sleeps_or_measures_elapsed_time() { let sources = test_sources(); @@ -175,6 +225,31 @@ fn n1_no_test_sleeps_or_measures_elapsed_time() { let needles = forbidden(); let mut violations = Vec::new(); for (path, source) in &sources { + // **`tests/pty.rs` is exempt, by exact path, and held to properties + // rather than trusted (0.39.0).** + // + // Every other test in this repository drives a function and reads what it + // returned; that file drives a *process* through a terminal, and there is + // no way to ask an operating system "has this child drawn its prompt yet" + // without polling. The alternative to a poll is a blocking read with no + // deadline, which does not remove the timing — it removes the failure + // message and hangs the suite instead. + // + // What keeps it from being the flakiness this gate exists to prevent is + // the *shape* of its waits, asserted below: every bound is a liveness + // deadline measured in tens of seconds, and nothing there asserts that + // anything happened **quickly**. A gate that fires on a loaded runner is + // one that teaches a team to re-run rather than to read, and that is a + // property of the number, not of the clock. + // + // Compared with `==` on the file name, never a substring: a permitted set + // matched loosely is a permitted set that widens itself, which is the + // rule `tests/dependencies.rs` already writes down for the two paths + // permitted to spawn a process. + if path.file_name().and_then(|name| name.to_str()) == Some("pty.rs") { + assert_no_impatient_bound(path, source, &mut violations); + continue; + } for (number, line) in source.lines().enumerate() { let at = format!("{}:{}", path.display(), number + 1); for (needle, why) in &needles { From c1839f0261918d26a417b7cbf3a4beecbade6095 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 19:28:59 +0530 Subject: [PATCH 08/27] fix(interrupt): a stopped turn leaves a record in the transcript, not the footer `App::say` writes the footer, which is gone at the next keystroke; `App::record` writes the transcript, and its own documentation names "what a turn ended as" as record's half. The one ending that went to the wrong one was the one an operator reaches for when something is going wrong: the 2026-09-05 field test stopped a turn and found no record in the scrollback that it had ever started -- the prompt echo above it, the work above that, and the only line tying them to an ending gone on the next key pressed. It now says what was kept as well as that it stopped. "stopped" alone leaves an operator looking at a half-finished transcript with no way to tell a turn that was interrupted from one that failed silently. A turn taken back before it did anything still annotates nothing: the echo is rewound with it, so there is nothing left to annotate. --- src/main.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 8533917..2e67f11 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8493,8 +8493,30 @@ async fn turn( // Abandoned. The run's own record is whatever io-harness had written by // the time the future was dropped, and saying so is the honest line: the // work above is real and the turn did not finish. + // Taken back whole: no step, nothing streamed, nothing on screen but the + // echo — and the echo has been rewound too, so there is nothing left to + // annotate. This is the operator pressing the key a moment after `Enter`. None if undone => {} - None => app.say(Tone::Muted, "stopped"), + // **Committed, not said, and 0.38.2 said it (0.39.0).** `App::say` writes + // the footer, which is gone at the next keystroke; `App::record` writes + // the transcript. This line is what a turn *ended as*, which `App::say`'s + // own documentation names as `record`'s half — and it was the one ending + // that went to the wrong one. The 2026-09-05 field test stopped a turn + // and found "no record in the scrollback that it had ever started": the + // prompt echo was above it, the work was above that, and the only thing + // tying them to an ending vanished on the next key pressed. + // + // It says what was kept as well as that it stopped, because "stopped" on + // its own leaves an operator looking at a half-finished transcript with + // no way to tell a turn that was interrupted from one that failed + // silently. + None => app.record( + Tone::Muted, + format!( + "stopped {} what the turn had already done is above, and is kept", + app.theme.glyphs.dash + ), + ), // **A turn that ended parked said nothing at all until 0.23.0.** The // harness returns `AwaitingAnswer`, `AwaitingPlan` or `AwaitingRecovery` // as an ordinary `Ok`, so this arm matched and dropped it — and the From f2482d35cceffa1a4d3dc620ae718c6855e72279 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 19:32:01 +0530 Subject: [PATCH 09/27] feat(status): the footer says how much of the bill was cached The running tok figure read as though every re-sent catalogue were paid for in full, and for most sessions most of it was not: the 2026-09-05 field test moved it from 8.1k to 52k over four one-word turns at a spend of $0.0001 each. The tokens were real; the implication that they cost what fresh tokens cost was not, and an operator deciding whether to clear the conversation reads this field. It now draws 52.0k tok, 44.0k cached, from io-harness 0.81.0's StepUsage -- a separate event rather than a field on Finished or Step, so a step no provider answered contributes nothing rather than four zeroes. Accumulated across the session, because one step's cache read drawn as the conversation's would be a different number every turn, and cleared with the run, because a conversation that was put down took its provider-side cache with it. The cached figure is drawn as a part of the total and never added to it: io-harness puts cache_read_tokens inside prompt_tokens, and cost.rs has carried that note since it was written. cache_write_tokens is left out of the footer entirely -- it is charged differently, and its None means not reported rather than none written. The /cost half of this was already built and is verified rather than rewritten: it splits read from written from fresh on the store's durable rows, and already renders an unreported write count, and the fresh remainder that inherits its doubt, as not reported rather than as zero. The release contract's F9 said both halves were new and now says what is true. --- src/status.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++- tests/status.rs | 57 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/status.rs b/src/status.rs index bb46a66..999111b 100644 --- a/src/status.rs +++ b/src/status.rs @@ -352,6 +352,31 @@ pub struct Status { /// not. [`crate::context::window`] falls back to the contract there, which is /// the pre-0.38.2 expression byte for byte. pub ceiling: Option, + /// How much of this session's prompt tokens the provider read from its cache + /// (0.39.0). + /// + /// **The footer's `tok` figure reads as though every re-sent catalogue were + /// paid for in full, and for most sessions most of it was not.** The + /// 2026-09-05 field test moved the number from 8.1k to 52k over four + /// one-word turns at a spend of $0.0001 each; the tokens were real and the + /// implication that they cost what fresh tokens cost was not. An operator + /// deciding whether to `/clear` reads this figure, and until 0.39.0 it told + /// them to. + /// + /// From io-harness 0.81.0's `EventKind::StepUsage`, which is a **separate + /// event** rather than a field on `Finished` or `Step` — a step no provider + /// answered emits nothing at all rather than four zeroes, which is why this + /// is an `Option` and not a counter starting at zero. + /// + /// A **subset** of `tokens`, never an addition to it: io-harness's own + /// `Usage` documentation is explicit that `cache_read_tokens` is already + /// inside `prompt_tokens`, and `src/cost.rs` has carried that note since it + /// was written. Adding them would over-report every cached turn, which is + /// most of them. + /// + /// Accumulated across the session and cleared by [`Status::forget_run`] with + /// the rest — a conversation that was put down took its cache with it. + pub cached: Option, /// Which rung decided [`Status::ceiling`], in io-harness's own word (0.39.0). /// /// `EventKind::ContextCeiling` has carried this since io-harness 0.81.0 and @@ -680,6 +705,7 @@ impl Status { cost: None, context: None, ceiling: None, + cached: None, ceiling_source: None, containment: None, boundary: None, @@ -768,6 +794,11 @@ impl Status { // line elsewhere in this function: a rung left behind by a cleared // ceiling would describe the previous run's model on this run's page. self.ceiling_source = None; + // Beside the token count it qualifies. A conversation that was put down + // took its provider-side cache with it, and carrying the figure forward + // would say a new conversation was mostly cached before it had sent + // anything. + self.cached = None; self.containment = None; // Beside the containment word it qualifies, and for the same reason: a // measurement belongs to the run that took it, and carrying it onto the @@ -1297,19 +1328,54 @@ impl Status { /// /// The estimate is added to the settled session total rather than replacing /// it, so the number only ever moves the way the spend does. + /// **And how much of it was cached (0.39.0).** `52k tok · 44k cached` is a + /// different fact from `52k tok`, and it is the one an operator deciding + /// whether to `/clear` actually needs: the tokens are real, and most of them + /// were read from the provider's cache at a fraction of the price. Appended + /// to this field rather than given a column of its own, because the footer + /// already carries seventeen and because the two numbers mean nothing apart. + /// + /// Drawn only when there is a cache read to report. A `0 cached` on every + /// uncached turn would be furniture, and on a provider that reports no usage + /// at all it would be a claim this crate cannot make. + /// + /// The cached figure is **not** added to the total: it is already inside it. pub fn token_field(&self) -> Option { match (self.tokens, self.streaming) { (None, None) => None, (settled, streaming) => { let total = settled.unwrap_or(0).saturating_add(streaming.unwrap_or(0)); - Some(match streaming { + let counted = match streaming { Some(_) => format!("~{} tok", format_tokens(total)), None => format!("{} tok", format_tokens(total)), + }; + Some(match self.cached.filter(|cached| *cached > 0) { + Some(cached) => format!("{counted} · {} cached", format_tokens(cached)), + None => counted, }) } } } + /// What a step's usage says about how much of the prompt was cached + /// (0.39.0). + /// + /// **Accumulated, not replaced.** `EventKind::StepUsage` describes one step, + /// and the figure beside it in the footer is the session's total — so a + /// setter that assigned would report the last step's cache read as though it + /// were the conversation's. + /// + /// `cache_write_tokens` is deliberately not added in. It is a different + /// thing charged at a different rate, and io-harness reports it as + /// `Option` precisely because `None` means *not reported* rather than + /// *none written* — folding an unknown into a total is how a number stops + /// meaning anything. + pub fn note_step_usage(&mut self, cache_read: u64) { + if cache_read > 0 { + self.cached = Some(self.cached.unwrap_or(0).saturating_add(cache_read)); + } + } + /// The same, for the turn now running rather than for the session. pub fn run_token_field(&self) -> Option { match (self.run_tokens, self.streaming) { diff --git a/tests/status.rs b/tests/status.rs index 074649b..b60f8b7 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -3109,3 +3109,60 @@ fn o13_the_turns_own_figure_is_provisional_on_the_same_terms() { .expect("provisional") .starts_with('~')); } + +/// **F9 — the footer says how much of the bill was cached.** +/// +/// `52k tok` reads as though every re-sent catalogue were paid for in full, and +/// for most sessions most of it was not: the 2026-09-05 field test moved the +/// figure from 8.1k to 52k over four one-word turns at a spend of $0.0001 each. +/// The tokens were real; the implication was not. An operator deciding whether to +/// `/clear` reads this field. +/// +/// Sabotage: drop the `cached` branch from `token_field`. Only this fails. +#[test] +fn f9_the_token_field_says_how_much_was_read_from_the_cache() { + let mut status = Status::new("a-model"); + status.tokens = Some(52_000); + + assert_eq!( + status.token_field().as_deref(), + Some("52.0k tok"), + "with nothing reported there is nothing to draw, and a `0 cached` on \ + every uncached turn would be furniture", + ); + + status.note_step_usage(30_000); + status.note_step_usage(14_000); + let field = status.token_field().expect("a figure"); + assert!( + field.contains("44.0k cached"), + "the cache reads accumulate across the session; one step's figure drawn \ + as the conversation's would be a different number every turn: {field}", + ); + assert!( + field.starts_with("52.0k tok"), + "the cached figure is inside the total and must not be added to it — \ + io-harness puts `cache_read_tokens` inside `prompt_tokens`: {field}", + ); +} + +/// **F9 — a cache figure belongs to the conversation that earned it.** +/// +/// `forget_run` is reached by `/clear`, `/resume`, `/fork` and a rewind. A figure +/// carried across would tell a new conversation it was mostly cached before it +/// had sent anything. +#[test] +fn f9_the_cache_figure_is_forgotten_with_the_run() { + let mut status = Status::new("a-model"); + status.tokens = Some(1_000); + status.note_step_usage(900); + assert!(status.token_field().expect("a figure").contains("cached")); + + status.forget_run(); + status.tokens = Some(1_000); + assert_eq!( + status.token_field().as_deref(), + Some("1.0k tok"), + "the previous conversation's cache followed the new one", + ); +} From 124e204734090003b6800d3cd43dccd8cd66accc Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 19:35:43 +0530 Subject: [PATCH 10/27] feat(skills): io skill add takes a folder, with its companions io-harness has admitted folder skills all along: `Skills::discover` takes a subdirectory exactly when it holds a `SKILL.md`, and `skillview::disable` has parked one as a folder since it was written. This verb refused a shape the rest of the product already supported, so an operator whose skill ships a checklist, a template or a directory of examples had to copy it in by hand and hope the layout was right. `io skill add ./my-skill` now installs the whole directory as `~/.io-cli/skills/my-skill/`. Everything the loose-file path already checked still runs, unchanged, because it all reads the manifest: the frontmatter name, the one-path-component refusal, the collision against discovered and disabled skills, and the count against io-harness's ceiling. Only two things differ -- where the name comes from and what is copied. A directory with no `SKILL.md` is refused and the refusal names the file it looked for, which is the mistake an operator actually makes: pointing the verb one level too high. Symbolic links inside the folder are skipped rather than followed -- the directory is somebody else's, and following a link out of it would copy whatever it points at into io's own home, which is the reason `src/import.rs` flattens Claude's plugin tree. The gate installs a folder with a nested companion and then asserts io-harness can discover it, because an install that copies files io-harness cannot see is a copy and not an install. Sabotage: copy only the manifest -- the name and the path still check out and the companion arm fails. --- docs/guide/skills.md | 14 ++++++++ src/manage.rs | 9 +++-- src/skillview.rs | 85 +++++++++++++++++++++++++++++++++++++++----- tests/skillview.rs | 72 +++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 10 deletions(-) diff --git a/docs/guide/skills.md b/docs/guide/skills.md index 865ef1b..8b8113a 100644 --- a/docs/guide/skills.md +++ b/docs/guide/skills.md @@ -49,6 +49,20 @@ the file stem otherwise. `io skill add ./my-skill/SKILL.md` installs `my-skill.m and `io skill remove my-skill` takes it back out, and the name every check asks about is the name a run will resolve rather than the word `SKILL`. +**Since 0.39.0 you can hand it the folder instead of the file.** `io skill add +./my-skill` — a directory holding a `SKILL.md` — installs the whole thing as +`~/.io-cli/skills/my-skill/`, companion files and all. That matters when the skill +references anything: a checklist, a template, a directory of examples. Pointing at +`./my-skill/SKILL.md` still works and still installs a single file, so a skill that +is only a manifest stays a loose `my-skill.md`; what changed is that a skill which +is *not* only a manifest no longer has to be copied in by hand. + +A directory with no `SKILL.md` is refused, and the refusal names the file it +looked for — the mistake it catches is pointing the verb one level too high. +Symbolic links inside the folder are skipped rather than followed: the directory +is yours, and following a link out of it would copy whatever it points at into +io's home. + **A skill `/import` wrote as a folder is manageable too, and it never was.** `/import` could write a skill into `~/.io-cli/skills//SKILL.md`, and neither lever would touch that shape — so the product shipped a verb that created state diff --git a/src/manage.rs b/src/manage.rs index 0c1cc0c..4f44bf9 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -105,7 +105,12 @@ pub enum Request { /// chosen and one that is typed. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SkillVerb { - /// Copy the file at `source` into this home's skills directory. + /// Copy the skill at `source` into this home's skills directory. + /// + /// A markdown file, or — since 0.39.0 — a directory holding a `SKILL.md` + /// beside whatever files it references. The second shape is the one + /// `Skills::discover` has always walked and the one `skillview::disable` + /// parks; this verb simply refused it. Add { source: std::path::PathBuf }, /// Every skill, whose it is, and whether it is on. List, @@ -403,7 +408,7 @@ pub fn parse(tokens: &[String]) -> Result { args.only("skill add", &[])?; Ok(Request::Skill(SkillVerb::Add { source: std::path::PathBuf::from( - args.one_word("skill add", "the path of a skill file")?, + args.one_word("skill add", "the path of a skill file or folder")?, ), })) } diff --git a/src/skillview.rs b/src/skillview.rs index 1f34316..ab83e34 100644 --- a/src/skillview.rs +++ b/src/skillview.rs @@ -827,20 +827,45 @@ pub fn enable(path: &Path, bundles: &[(String, PathBuf)]) -> Result Result { - if !source.is_file() { + // **A directory holding a `SKILL.md` is a skill, and 0.39.0 is when this verb + // learned it.** io-harness has admitted folder skills all along — + // `Skills::discover` takes a subdirectory exactly when it holds a `SKILL.md`, + // and [`disable`] has parked one as a folder since it was written — so `io + // skill add` refusing a directory refused a shape the rest of the product + // already supported. An operator with a skill that ships a companion file had + // to copy it in by hand and hope the layout was right. + // + // The manifest is what everything below reads: the name resolution, the + // frontmatter, the collision checks. Only the copy differs. + let manifest = if source.is_dir() { + let manifest = source.join(SKILL_FILE); + if !manifest.is_file() { + return Err(format!( + "{} is a directory with no {SKILL_FILE} in it; a folder skill is a \ + directory holding {SKILL_FILE} with `name:` and `description:` in \ + its frontmatter, beside whatever files it references", + source.display() + )); + } + manifest + } else if source.is_file() { + source.to_path_buf() + } else { return Err(format!( - "{} is not a file; a skill is one markdown file with `name:` and \ - `description:` in its frontmatter", + "{} is neither a file nor a directory; a skill is one markdown file \ + with `name:` and `description:` in its frontmatter, or a directory \ + holding a {SKILL_FILE} that has them", source.display() )); - } + }; + // The question the run will ask, asked before anything is named after the // answer — and this is where the source's *file name* used to be taken // instead. A `/SKILL.md` source landed here as `SKILL.md`, which // [`is_bundle`] then read as a folder skill and both levers refused forever, // while every name check below asked about the literal word `SKILL` rather // than about the name the run resolves. - let (name, _) = describe(source); + let (name, _) = describe(&manifest); // **A frontmatter `name:` is somebody else's text and it is about to become a // file name in io's own home.** `crate::import::one_path_component` refuses @@ -865,7 +890,15 @@ pub fn install(home: &Path, source: &Path) -> Result { if let Err(error) = crate::home::create(&dir) { return Err(format!("could not create {}: {error}", dir.display())); } - let destination = dir.join(format!("{name}.md")); + // A loose skill lands as `.md`; a folder skill lands as `/`, which + // is the layout `Skills::discover` walks and the one [`disable`] already + // parks and restores. The name is the frontmatter's either way, so the two + // shapes cannot claim different names for one skill. + let destination = if source.is_dir() { + dir.join(&name) + } else { + dir.join(format!("{name}.md")) + }; if destination.exists() { return Err(format!( "{} is already there; remove it first, or give the file you are \ @@ -938,11 +971,47 @@ pub fn install(home: &Path, source: &Path) -> Result { )); } - std::fs::copy(source, &destination) - .map_err(|error| format!("could not copy into {}: {error}", destination.display()))?; + if source.is_dir() { + copy_tree(source, &destination) + .map_err(|error| format!("could not copy into {}: {error}", destination.display()))?; + } else { + std::fs::copy(source, &destination) + .map_err(|error| format!("could not copy into {}: {error}", destination.display()))?; + } Ok(destination) } +/// Copy a folder skill, whole. +/// +/// **Every file, because a folder skill's companions are the reason it is a +/// folder.** A skill that ships a checklist, a template or a reference and +/// arrives without them is a skill whose `SKILL.md` points at nothing — worse +/// than a refusal, because it installs, lists and loads. +/// +/// Depth-first with std alone, which is what this crate has instead of a walker: +/// `tests/dependencies.rs` permits ten names and none of them is one. Symbolic +/// links are **not** followed — `file_type()` is read without traversing, and a +/// link is skipped rather than copied through. A skill directory is somebody +/// else's, and following a link out of it would copy whatever it points at into +/// io's own home; `src/import.rs` flattens Claude's plugin tree for the same +/// reason and this agrees with it. +fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> { + crate::home::create(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + // `file_type` rather than `metadata`, which follows a link and would + // report the target's kind. + let kind = entry.file_type()?; + let target = to.join(entry.file_name()); + if kind.is_dir() { + copy_tree(&entry.path(), &target)?; + } else if kind.is_file() { + std::fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + /// Delete a skill's file. Answers with the path that is now gone. /// /// **An unlink, and that is the whole difference from [`disable`].** Disable is diff --git a/tests/skillview.rs b/tests/skillview.rs index fe1d318..3d16924 100644 --- a/tests/skillview.rs +++ b/tests/skillview.rs @@ -882,6 +882,78 @@ fn enable_is_refused_on_a_bundle_skill_and_leaves_the_bundle_untouched() { /// The provenance half is the load-bearing one. `skills::wrote` answers *are these /// the bytes io-cli last wrote?*, so an install that recorded itself in the /// manifest would make the listing credit this crate with somebody else's work — +/// **F10 — a directory holding a `SKILL.md` installs, with its companions.** +/// +/// io-harness has admitted folder skills all along: `Skills::discover` takes a +/// subdirectory exactly when it holds a `SKILL.md`, and `disable` has parked one +/// as a folder since it was written. `io skill add` refused a shape the rest of +/// the product already supported, so an operator with a skill that ships a +/// reference file had to copy it in by hand and hope the layout was right. +/// +/// The companion is what this arm is really about. A skill whose `SKILL.md` +/// points at a checklist that did not come with it installs, lists and loads — +/// and then fails at the one moment it is used, which is worse than a refusal. +/// +/// Sabotage: copy only the manifest. The name and the path still check out and +/// this fails on the companion. +#[test] +fn f10_a_folder_skill_installs_whole() { + let (dir, home) = home(); + let source = dir.path().join("elsewhere").join("reviewer"); + write( + &source.join("SKILL.md"), + &skill("reviewer", "review a diff"), + ); + write(&source.join("checklist.md"), "- read the whole diff\n"); + write(&source.join("cases").join("edge.md"), "- an empty diff\n"); + + let at = skillview::install(&home, &source).expect("a folder skill installs"); + + assert_eq!( + at, + skills::dir(&home).join("reviewer"), + "a folder skill lands under the name its frontmatter declares, as a \ + directory — which is the layout `Skills::discover` walks", + ); + assert_eq!( + read(&at.join("checklist.md")), + b"- read the whole diff\n", + "the companion file is why it is a folder at all", + ); + assert_eq!( + read(&at.join("cases").join("edge.md")), + b"- an empty diff\n", + "and a nested one, because a skill's references are its own to arrange", + ); + // The whole point of the layout: io-harness finds it. + let found = io_harness::Skills::discover(&skills::dir(&home)).expect("the home discovers"); + assert!( + found.get("reviewer").is_some(), + "the installed folder is not a skill io-harness can see, which makes the \ + install a copy and nothing more", + ); +} + +/// **F10 — a directory with no `SKILL.md` is refused, and the refusal says what +/// was looked for.** +/// +/// The failure an operator will actually hit: pointing the verb at the folder +/// *above* the skill, or at one whose manifest is called something else. A bare +/// "not a file" told them nothing, and now that a directory is a legitimate +/// argument it would be actively misleading. +#[test] +fn f10_a_directory_without_a_manifest_is_refused_by_name() { + let (dir, home) = home(); + let source = dir.path().join("elsewhere").join("not-a-skill"); + write(&source.join("readme.md"), "# notes\n"); + + let refusal = skillview::install(&home, &source).expect_err("there is no manifest"); + assert!( + refusal.contains("SKILL.md"), + "the refusal has to name the file it looked for: {refusal}", + ); +} + /// the same misattribution `home::origin` exists to prevent one level up. #[test] fn f1_an_installed_skill_is_copied_in_and_belongs_to_the_operator() { From f33ec588865a5f133c0c1c0e3234d379a8795e75 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 19:43:07 +0530 Subject: [PATCH 11/27] feat(events): a picture the agent was handed gets a row, and the cache figure reaches the footer Two silences that 0.38.2 recorded as close calls, both answered by the release it said would answer them. **`image_attached` becomes a line.** The old note refused it on the grounds that "a line naming a picture without showing it would be the class of sentence this release exists to delete", and it was right about the sentence it was refusing. What it did not weigh is that the alternative was silence about something that entered the model's context: an operator whose window filled with a browser screenshot they never asked for could read `/context`, see the conversation swollen, and find nothing anywhere saying a picture had arrived. The row names the door it came through -- io-harness's own `mcp`, `browser`, `view_image` or `caller`, passed through so a fifth one reads as itself -- the media type, and the size, spelled with `picture::bytes` like every other size in the product. It does not show the picture, and cannot: `ImageAttached` carries a digest and never the bytes, by design. The digest is left off the row, being io-harness's bookkeeping and meaningless to a reader of a transcript. Images the operator attached are still drawn where they are attached. **`step_usage` becomes a status field.** Its old route said the cached-versus- fresh split "is a release rather than a row"; this is that release. The cache read accumulates onto `Status::cached` and the footer draws `52k tok, 44k cached`. A field and not a line, because the event arrives beside every committed step and a row per step would be a breakdown scrolling past the work it describes. Wired in `note_context`, beside the ceiling and for the ceiling's reason: it is the one function every door already calls per event, so an arm written beside one of the three call sites would leave the headless path or the resume path reporting a bill that looks entirely fresh. The glyph sweep caught the new row immediately -- every event that draws a line of its own must be exercised under the ASCII set, or a glyph in that arm reaches a terminal that cannot draw it. --- src/events.rs | 49 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 17 ++++++++++++++ src/triage.rs | 59 +++++++++++++++++++++++++++++-------------------- tests/events.rs | 49 ++++++++++++++++++++++++++++++++++++++++ tests/glyphs.rs | 10 +++++++++ 5 files changed, 160 insertions(+), 24 deletions(-) diff --git a/src/events.rs b/src/events.rs index 80de9f9..23561af 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1796,6 +1796,55 @@ impl Events { }); lines } + // **A picture the agent was handed, named but not drawn (0.39.0).** + // + // The operator's own attachments are drawn where they are attached, by + // `crate::picture`. This event is for the other four ways an image + // reaches a run — an MCP tool's reply, a browser screenshot, the + // agent's own `view_image`, and the contract's own images — and until + // io-harness 0.81.0 there was no event for any of them at all. + // + // The row says where it came from, what it is and how big, and stops + // there. Showing it is not on: the bytes are not on the event (by + // design — `ImageAttached` carries a digest and never the image), and + // a surface that fetched them to draw a screenshot the operator did + // not ask for would spend a terminal's whole viewport on it. + // + // What makes the row worth a line rather than a silence is that this + // is something that **entered the model's context**. An operator whose + // window filled with a screenshot could otherwise read `/context`, see + // the conversation swollen and find nothing anywhere saying a picture + // had arrived. + EventKind::ImageAttached { + media_type, + bytes, + source, + .. + } => { + let mut lines = self.flush_text(); + lines.push(Line::from(vec![ + Span::styled(leader(separator), theme.style(Tone::Muted)), + Span::styled("Image".to_string(), theme.style(Tone::Normal)), + Span::styled(separator, theme.style(Tone::Muted)), + // io-harness's own word for the door it came through, not a + // sentence of io-cli's about it: `mcp`, `browser`, + // `view_image` or `caller`. A fifth one it grows reads as + // itself rather than as "unknown". + Span::styled(source.clone(), theme.style(Tone::Muted)), + Span::styled(separator, theme.style(Tone::Muted)), + Span::styled(media_type.clone(), theme.style(Tone::Muted)), + Span::styled(separator, theme.style(Tone::Muted)), + // `crate::picture::bytes`, which is what every other size an + // operator reads in this product is spelled with. A second + // formatter here would report one image two ways depending on + // which door it came through. + Span::styled( + crate::picture::bytes(usize::try_from(*bytes).unwrap_or(usize::MAX)), + theme.style(Tone::Muted), + ), + ])); + lines + } // Guarded on the items rather than only on the tag, because io-harness // accepts a write of none: `parse_todo_items` validates each item it is // given and never rejects an empty list, so `{"items": []}` dispatches diff --git a/src/main.rs b/src/main.rs index 2e67f11..08c8c95 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8684,6 +8684,23 @@ fn note_context( { app.status.note_ceiling(*max_tokens, source); } + // **How much of the prompt the provider read from its cache (0.39.0).** Here + // for the ceiling's reason exactly — this is the one function every door + // already calls per event, so an arm written beside one of the three call + // sites would leave the headless path or the resume path reporting a bill + // that looks entirely fresh. + // + // `cache_read_tokens` alone. `fresh_prompt_tokens` is the remainder and would + // be a second number saying the same thing; `cache_write_tokens` is charged + // differently and its `None` means *not reported* rather than *none written*, + // which is not a thing to fold into a total. Both stay on + // `io exec --json` and in the durable trace. + if let io_harness::EventKind::StepUsage { + cache_read_tokens, .. + } = &event.kind + { + app.status.note_step_usage(*cache_read_tokens); + } 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 diff --git a/src/triage.rs b/src/triage.rs index dfeb069..699d126 100644 --- a/src/triage.rs +++ b/src/triage.rs @@ -349,34 +349,45 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ "the `ctx` field's denominator, and the `/context` page's total, which are one expression \ so that they cannot disagree", ), - // **`step_usage` is silent for exactly `step_attributed`'s reason**, and it is - // the same shape of fact: a breakdown emitted beside every committed step, - // whose numbers no surface in this release renders. The cached-versus-fresh - // split it carries is the answer to a real complaint — the running `tok` - // figure reads as though every re-sent catalogue were paid in full — and - // answering it is a footer this release does not build. Recorded as a close - // call, like its sibling, rather than as an obvious silence. + // **`step_usage` was the close call 0.38.2 recorded, and 0.39.0 is the release + // it said would answer it.** The note here read "the fresh-versus-cached split + // it carries is what the footer's `tok` figure does not yet separate, and + // separating it is a release rather than a row". This is that release: the + // cache read accumulates onto `Status::cached` and the footer draws + // `52k tok · 44k cached`. + // + // A field and not a line, because it arrives beside **every** committed step — + // a row per step would be a breakdown nobody asked for scrolling past the work + // it describes. The other three numbers it carries stay unrendered and stay + // available on `io exec --json`. ( "step_usage", - Disposition::Silent, - "`io exec --json`, which forwards it verbatim, and the durable trace; the fresh-versus-\ - cached split it carries is what the footer's `tok` figure does not yet separate, and \ - separating it is a release rather than a row", - ), - // **`image_attached` is silent because the picture it announces is one this - // crate did not hand over.** `src/picture.rs` already draws every image the - // operator attached, at the moment they attach it; this event fires for one - // that reached the run through an MCP reply, a browser screenshot or - // `view_image`, which is plumbing inside io-harness that emitted nothing at - // all before 0.81.0. Drawing it is a renderer, and a renderer is the roadmap's - // work rather than a patch's. A line naming a picture without showing it would - // be the class of sentence this release exists to delete. + Disposition::Status, + "the footer's `tok` field, which draws how much of the running total the provider read \ + from its cache; `io exec --json` forwards all four numbers verbatim, and the durable \ + trace keeps them", + ), + // **`image_attached` is a line as of 0.39.0, and the objection the old note + // raised is answered rather than overruled.** That note said "a line naming a + // picture without showing it would be the class of sentence this release + // exists to delete", and it was right about the sentence it was refusing. What + // it did not weigh is that the alternative was **silence about a thing that + // entered the model's context** — an operator whose window filled up with a + // browser screenshot they never asked for could read `/context`, see the + // conversation swollen, and find nothing anywhere saying a picture had + // arrived. + // + // So the row names where it came from, what it is and how big — `mcp`, + // `browser`, `view_image` or `caller`, from io-harness's own `source` field — + // and does not pretend to show it. The picture itself is still only drawn for + // an image the operator handed over, through `src/picture.rs`, on a terminal + // that can draw one. ( "image_attached", - Disposition::Silent, - "`io exec --json`, which forwards it verbatim, and the durable trace; the image reached \ - the run inside io-harness rather than through the operator's own attachment command, \ - and no surface in this release draws one this crate did not hand over", + Disposition::Line, + "a row naming where the picture came from, its media type and its size; the image itself \ + is drawn only for one the operator attached, and `io exec --json` forwards the event \ + verbatim either way", ), ( "finished", diff --git a/tests/events.rs b/tests/events.rs index a75db12..b6111db 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -3291,6 +3291,55 @@ fn f8_a_thought_is_committed_above_the_prose_it_produced() { /// /// Sabotage: drop the `said_plainly` rebinding in the `Step` arm and every row /// with a brace in it fails. +/// **F18 — a picture the agent was handed is named, and not drawn.** +/// +/// `src/picture.rs` draws every image the *operator* attached, where they attach +/// it. This event is the other four doors — an MCP tool's reply, a browser +/// screenshot, the agent's own `view_image`, and the contract's own images — and +/// before io-harness 0.81.0 there was no event for any of them. +/// +/// The row's job is to say that something entered the model's context. Without +/// it an operator whose window filled with a screenshot they never asked for +/// could read `/context`, see the conversation swollen, and find nothing anywhere +/// saying a picture had arrived. +/// +/// The `source` word is io-harness's, passed through rather than translated: a +/// fifth door it grows reads as itself rather than as "unknown". +#[test] +fn f18_an_image_the_agent_was_handed_is_named_with_its_door_and_its_size() { + for source in ["mcp", "browser", "view_image", "caller"] { + let mut events = Events::new(DARK); + let line = rendered( + &mut events, + EventKind::ImageAttached { + media_type: "image/png".into(), + bytes: 391_790, + digest: "sha256:not-a-real-digest".into(), + source: source.into(), + }, + ); + + assert!( + line.contains(source), + "the row has to say which door the picture came through, because that \ + is the difference between one the operator sent and one the agent \ + fetched: {line}", + ); + assert!(line.contains("image/png"), "{line}"); + assert!( + line.contains("382.6 KB"), + "the size is spelled with `picture::bytes`, like every other size in \ + the product — a second formatter would report one image two ways \ + depending on which door it came through: {line}", + ); + assert!( + !line.contains("sha256"), + "the digest is io-harness's bookkeeping and means nothing to a reader \ + of a transcript: {line}", + ); + } +} + #[test] fn f8_a_spawned_childs_outcome_is_drawn_as_words() { for debug in [ diff --git a/tests/glyphs.rs b/tests/glyphs.rs index 2c69142..9d47dc8 100644 --- a/tests/glyphs.rs +++ b/tests/glyphs.rs @@ -602,6 +602,16 @@ fn every_event_this_release_renders_draws_in_ascii() { used: 1, discarded: 2, }, + // 0.39.0 — a picture the agent was handed rather than one the operator + // attached. Four separators and the muted leader, all of which differ + // between the sets; the picture itself is never drawn from this arm, so + // what is swept here is the whole of what it renders. + EventKind::ImageAttached { + media_type: "image/png".into(), + bytes: 391_790, + digest: "sha256:not-a-real-digest".into(), + source: "browser".into(), + }, ]; // **The list above is checked against the renderer, not trusted.** It is From b03d36136bab4c3afb16a4283ffe109a9e66b36a Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 19:50:26 +0530 Subject: [PATCH 12/27] feat(events): a command a turn runs costs one row, not four The sandbox create, exec and destroy rows draw nothing now. They were three muted lines saying a sandbox was made, a command ran in it, and it was torn down -- around one command whose own row sits directly beneath them and says what ran and how it ended. The three that went carry no fact the operator did not already have: that the command was contained is a standing property of the session and is on the status line, and how it was contained is the backend named there beside it. The two that stay are the two that are news -- a limit reached changed what the command did, and a gate that ran and did not pass decided whether the turn was finished. The rest of F6 is withdrawn rather than quietly missed, and recorded in US-IO-CLI-0.39.0-I03. The argv beside the binary and the first lines of output under the row are not reachable from the event stream: ToolCall is emitted before the call runs and carries nothing about what came back, and its target is chosen from a fixed list of argument names an exec call does not use, so the event has the binary and never the arguments. Both would need a store read per completed call on the render path -- a different design with a different cost, and not one to adopt by accident on the way to a smaller change. The output is still reachable through the durable trace that /expand already reads. The glyph sweep was pointed at a sandbox kind that now renders nothing and would have passed without looking at anything; it drives cap_hit and gate_output instead, which are the two remaining shapes. Two more records land with this: I01 on the pty arms that need a provider and therefore cannot run in CI, and I02 on Ctrl+C's bound moving from the harness's shutdown to the operator's keystrokes, which the owner decided on 2026-09-06. --- src/events.rs | 32 +++++++++++++++++++++++++--- tests/events.rs | 56 +++++++++++++++++++++++++++++-------------------- tests/glyphs.rs | 15 +++++++++++-- 3 files changed, 75 insertions(+), 28 deletions(-) diff --git a/src/events.rs b/src/events.rs index 23561af..6b95b61 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1721,10 +1721,36 @@ impl Events { // that a kind cannot be given a line here and a weight somewhere // else that disagrees with it. let (said, tone) = match kind.as_str() { - "create" => ("a sandbox was created", Tone::Muted), - "exec" => ("a command ran in the sandbox", Tone::Muted), + // **`create`, `exec` and `destroy` draw nothing as of + // 0.39.0.** They were three muted rows saying a sandbox was + // made, a command ran in it, and it was torn down — around + // one command whose own row sits directly beneath them and + // says what ran and how it ended. Four rows for one act, and + // the three that were dropped are the three that carry no + // fact the operator does not already have: *that* the command + // was contained is a standing property of the session and is + // on the status line, and *how* it was contained is the + // backend named there beside it. + // + // The two that survive are the two that are news. A limit + // being reached changed what the command did; a gate that ran + // and did not pass decided whether the turn was finished. A + // row is worth a line when it changes something, and the + // lifecycle of a sandbox around a command that has its own + // row does not. + // + // **This is what F6 delivers of "the three lines fold into + // the one", and it is not all of it.** The rest — the argv + // beside the binary, and the first lines of output indented + // under the row — is not reachable from the event stream: + // `EventKind::ToolCall` is emitted *before* the call runs and + // carries nothing about what came back, and its `target` is + // picked from a fixed list of argument names that an exec call + // does not use, so the event has the binary and never the + // arguments. Both would need a store read per completed call. + // `US-IO-CLI-0.39.0-I03` records that. + "create" | "exec" | "destroy" => return Vec::new(), "cap_hit" => ("the sandbox reached a limit it was given", Tone::Warning), - "destroy" => ("the sandbox was torn down", Tone::Muted), // `ran and` is load-bearing: it is the whole of what // separates a criterion that judged the work from one that // never got to. diff --git a/tests/events.rs b/tests/events.rs index b6111db..6d61a3f 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -2084,18 +2084,27 @@ fn f7_a_dial_carries_the_host_as_asked_the_port_and_the_verdict() { ); } -/// 0.14.0 F8 — a sandbox says what happened and what isolated it. +/// 0.14.0 F8, amended by 0.39.0's F6 — a sandbox draws only what is news. +/// +/// **`create`, `exec` and `destroy` draw nothing since 0.39.0**, and this arm is +/// the one that says so. They were three muted rows around one command whose own +/// row sits directly beneath them and says what ran and how it ended — four rows +/// for one act. That the command was contained is a standing property of the +/// session and is on the status line; how it was contained is the backend named +/// there beside it. Neither is news at the moment a command runs. +/// +/// The two that survive are the two that changed something: a limit reached +/// changed what the command did, and a gate that ran and did not pass decided +/// whether the turn was finished. /// /// Sabotage: draw `cap_hit` through the error path — `Tone::Error` in place of /// the warning — under which only F8 fails, on a run whose cap held exactly as /// its operator configured it being reported to them as a run that broke. #[test] -fn f8_a_sandbox_draws_its_four_kinds_and_carries_a_backend_only_where_one_exists() { +fn f8_a_sandbox_draws_only_the_kinds_that_are_news() { let mut events = Events::new(DARK); - // `create` and `exec` are the two io-harness sets a backend on, so the line - // carries what isolated the work. - for (kind, expected) in [("create", "created"), ("exec", "ran")] { + for kind in ["create", "exec", "destroy"] { let line = rendered( &mut events, EventKind::Sandbox { @@ -2103,28 +2112,29 @@ fn f8_a_sandbox_draws_its_four_kinds_and_carries_a_backend_only_where_one_exists backend: Some("macos-sandbox-exec".into()), }, ); - assert!(line.contains(expected), "{kind}: {line:?}"); - assert!(line.contains("macos-sandbox-exec"), "{kind}: {line:?}"); - } - - // `cap_hit` and `destroy` carry `None` always, so there is no backend to - // draw — and none is worked out here and printed as though the event had - // said it. - for kind in ["cap_hit", "destroy"] { - let line = rendered( - &mut events, - EventKind::Sandbox { - kind: kind.into(), - backend: None, - }, - ); - assert!(!line.trim().is_empty(), "{kind} drew nothing: {line:?}"); assert!( - !line.contains("sandbox-exec") && !line.contains("none"), - "{kind} has no backend and this line invented one: {line:?}", + line.trim().is_empty(), + "`{kind}` drew a row. It is the lifecycle of a sandbox around a \ + command that has its own row, and it costs the operator a line to \ + tell them something the status line already carries: {line:?}", ); } + // `cap_hit` carries `None` always, so there is no backend to draw — and none + // is worked out here and printed as though the event had said it. + let line = rendered( + &mut events, + EventKind::Sandbox { + kind: "cap_hit".into(), + backend: None, + }, + ); + assert!(!line.trim().is_empty(), "cap_hit drew nothing: {line:?}"); + assert!( + !line.contains("sandbox-exec") && !line.contains("none"), + "cap_hit has no backend and this line invented one: {line:?}", + ); + // **A limit reached, and not a failure.** The sandbox did what it was // configured to do, and the error path would say the opposite of that to the // one person who chose the number. diff --git a/tests/glyphs.rs b/tests/glyphs.rs index 9d47dc8..999fcfd 100644 --- a/tests/glyphs.rs +++ b/tests/glyphs.rs @@ -588,9 +588,20 @@ fn every_event_this_release_renders_draws_in_ascii() { port: 443, allowed: false, }, + // **`cap_hit` and not `create` since 0.39.0.** `create`, `exec` and + // `destroy` draw nothing now, so a fixture using one of them would sweep + // an empty render and pass without looking at anything — the coverage + // check below asserts the *name* is covered and cannot tell the + // difference. `cap_hit` is one of the two kinds that still draws, and it + // takes the toned-notice path where `gate_output` takes the muted-leader + // one, so between them the two shapes are covered. EventKind::Sandbox { - kind: "create".into(), - backend: Some("macos-sandbox-exec".into()), + kind: "cap_hit".into(), + backend: None, + }, + EventKind::Sandbox { + kind: "gate_output".into(), + backend: None, }, EventKind::Stalled, // 0.27.0 — the one silence that gained a line, and it carries the muted From f6230d008e7a19b81b24d8716bf64ea7d48eb0f9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 19:57:38 +0530 Subject: [PATCH 13/27] feat(exec): a headless run says what it did, on stderr The README's own sentence is that `io exec` shows what it refused. That was true in a session and true in the JSON stream and false on the path most operators reach first: without `--json` this door installed `Ignore` and said nothing at all, so a run that edited four files and was refused a fifth printed the answer and no sign of the refusal. `Narrating` writes one line per tool call and one per refusal. The tool call is drawn at the moment the run announces it -- before it runs, because that is when io-harness emits it -- so the lines read as a commentary on work in progress rather than a summary afterwards. That is the right shape here: a long headless run with no output at all is indistinguishable from a hung one. Everything goes to stderr and stdout stays exactly what a script reads. The whole contract of this door is that stdout carries the agent's reply and nothing else, so `$(io exec ...)` is the answer. `Narrating` therefore holds no writer and names `eprintln!` directly, rather than being generic over one the way `Ndjson` is -- a writer parameter is a way to pass stdout by mistake, and the gate asserts the type never names stdout at all. Both headless doors take it. A resumed run that went quiet where the first half spoke would leave an operator carrying work on with less to read than they had starting it. Gated twice, because the two halves need different instruments: a source gate for the property that makes the mistake impossible, and a pty arm that runs the real binary against a provider that cannot answer and asserts the split between the streams -- a headless run that fails after doing some work being exactly when an operator most needs to know what it did. --- src/exec.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++-- tests/exec.rs | 60 +++++++++++++++++++++++++++++++++++ tests/pty.rs | 46 +++++++++++++++++++++++++++ tests/skillview.rs | 2 +- 4 files changed, 182 insertions(+), 4 deletions(-) diff --git a/src/exec.rs b/src/exec.rs index 618a3de..fcf91fc 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -40,7 +40,7 @@ use std::io::Write; use std::sync::Mutex; use io_harness::{ - Config, DenyAll, ExecMode, Flow, Ignore, Observer, PlanVerdict, Policy, Provider, ProviderSpec, + Config, DenyAll, ExecMode, Flow, Observer, PlanVerdict, Policy, Provider, ProviderSpec, RecoveryDecision, RunEvent, RunOutcome, Session, Store, TaskContract, TurnResult, }; @@ -454,6 +454,67 @@ impl Ndjson { } } +/// What a headless run says about itself when nobody asked for JSON (0.39.0). +/// +/// **`io exec` without `--json` was silent about everything it did**, and the +/// README said otherwise: its own sentence is that `io exec` shows what it +/// refused, which was true in a session and true in the JSON stream and false on +/// the one path most operators reach first. A run that edited four files and was +/// refused a fifth printed the answer and nothing else. +/// +/// One line per tool call, and one per refusal. The tool call is drawn at the +/// moment the run announces it — which is *before* it runs, because that is when +/// io-harness emits it — so these lines read as a commentary on work in progress +/// rather than as a summary afterwards. That is the right shape for the surface: +/// a long headless run with no output at all is indistinguishable from a hung +/// one. +/// +/// **Everything goes to stderr, and stdout stays exactly what a script reads.** +/// The whole contract of this door is that stdout carries the answer and nothing +/// else, so a `$(io exec …)` is the agent's reply. A commentary line written +/// there would corrupt every caller that has ever depended on it — which is why +/// this type holds no writer and names `eprintln!` directly, rather than being +/// generic over one the way [`Ndjson`] is: a writer parameter here is a way to +/// pass stdout by mistake. +pub struct Narrating; + +impl Observer for Narrating { + fn event(&self, event: &RunEvent) -> Flow { + match &event.kind { + // What it is about to do. `target` is io-harness's own — the path, + // the pattern or the tool's own name where the call names none of + // them — and it is not embroidered here. + io_harness::EventKind::ToolCall { name, target, .. } => { + if target == name { + eprintln!("· {name}"); + } else { + eprintln!("· {name} {target}"); + } + } + // **The line the README already promised.** A refusal is the one + // thing a headless run must not swallow: the operator's policy + // stopped the agent, and a run that reports only its answer leaves + // them reading a reply that quietly did less than it says. + io_harness::EventKind::Refused { + act, target, rule, .. + } => { + // The rule where one named it. Its absence is a fact too — an + // unnamed action in the grey tier is the *least* vouched-for + // kind, as the approval overlay's own note says — but a + // parenthesis explaining that belongs on a surface with room, + // and this one is a line in a log. + let named = match rule { + Some(rule) => format!(" (rule {rule})"), + None => String::new(), + }; + eprintln!("· refused {act} {target}{named}"); + } + _ => {} + } + Flow::Continue + } +} + impl Observer for Ndjson { fn event(&self, event: &RunEvent) -> Flow { // A serialization failure and a broken pipe are both reasons to stop @@ -881,7 +942,13 @@ impl WithProvider for Headless { } let json = Ndjson::new(std::io::stdout()); - let observer: &dyn Observer = if self.args.json { &json } else { &Ignore }; + // **`Ignore` is gone from this arm as of 0.39.0.** Without `--json` this + // door said nothing at all about what it was doing, while the README's + // own sentence promised it showed what it refused. `Narrating` writes + // that commentary to stderr; stdout is untouched on both paths, which is + // the whole contract of `io exec`. + let narrating = Narrating; + let observer: &dyn Observer = if self.args.json { &json } else { &narrating }; // **The same composition the session arm builds, and it is here for the // reason the asymmetry this product deleted in 0.14.0 was a defect.** A @@ -1607,7 +1674,12 @@ impl WithProvider for Resuming { } let json = Ndjson::new(std::io::stdout()); - let observer: &dyn Observer = if self.json { &json } else { &Ignore }; + // The same commentary `Headless` writes, for the reason the composition + // below is the same: a resumed run that went quiet where the first half + // spoke would leave an operator carrying work on with less to read than + // they had starting it. + let narrating = Narrating; + let observer: &dyn Observer = if self.json { &json } else { &narrating }; // The same composition `Headless` builds, and for the same reason: a // `[[hook]]` that fired on the run and then went quiet the moment it was diff --git a/tests/exec.rs b/tests/exec.rs index ab40cec..0ad9399 100644 --- a/tests/exec.rs +++ b/tests/exec.rs @@ -3045,6 +3045,66 @@ fn f10_a_configuration_with_no_templates_passes_every_plain_goal_through() { /// /// Sabotage: replace any single `&DenyAll` with the ACP approver. The count drops /// to four and this fails; nothing in `tests/acp.rs` moves. +/// **F8 — `io exec` without `--json` says what it did, and says it on stderr.** +/// +/// The README's own sentence is that `io exec` shows what it refused. That was +/// true in a session and true in the JSON stream and false on the path most +/// operators reach first: without `--json` this door installed `Ignore` and said +/// nothing at all, so a run that edited four files and was refused a fifth +/// printed the answer and no sign of the refusal. +/// +/// **The half that matters most is stdout.** The whole contract of this door is +/// that stdout carries the agent's reply and nothing else, so a `$(io exec …)` is +/// the answer. A commentary line written there would corrupt every caller that +/// has ever depended on it — which is why `Narrating` holds no writer at all and +/// names `eprintln!` directly, rather than being generic over one the way +/// `Ndjson` is. A writer parameter is a way to pass stdout by mistake. +/// +/// Asserted as source text because the alternative is capturing a process's +/// stderr, which `tests/pty.rs` can do and this file cannot; what is checked here +/// is the property that makes the mistake impossible rather than one run that +/// happened not to make it. +#[test] +fn f8_the_headless_commentary_cannot_be_written_to_stdout() { + let text = source("exec.rs"); + let start = text + .find("pub struct Narrating;") + .expect("`Narrating` is the commentary observer"); + let end = text[start..] + .find("impl Observer for Ndjson") + .map(|at| start + at) + .expect("`Ndjson`'s impl follows it"); + let narrating = &text[start..end]; + + assert!( + narrating.contains("eprintln!"), + "the commentary is written with `eprintln!` or it is not on stderr", + ); + assert!( + !narrating.contains("println!") + || narrating.matches("println!").count() == narrating.matches("eprintln!").count(), + "`Narrating` writes to stdout. That stream is the agent's reply and \ + nothing else, and a line of commentary there breaks every caller that \ + reads it: {narrating}", + ); + assert!( + !narrating.contains("stdout"), + "`Narrating` names stdout, which it has no business reaching: {narrating}", + ); + + // And both headless doors take it, because a resumed run that went quiet + // where the first half spoke would leave an operator carrying work on with + // less to read than they had starting it. + assert_eq!( + text.matches("else { &narrating }").count() + + text + .matches("else {\n &narrating\n }") + .count(), + 2, + "both `io exec` and `io resume` narrate without `--json`", + ); +} + #[test] fn f7_every_headless_turn_still_refuses_every_approval() { let text = source("exec.rs"); diff --git a/tests/pty.rs b/tests/pty.rs index cdc8523..61f6f14 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -417,6 +417,52 @@ mod unix { io.wait_for("no turn to undo"); } + /// **F8 — `io exec` without `--json` narrates, and stdout stays clean.** + /// + /// The source gate in `tests/exec.rs` holds `Narrating` to never naming + /// stdout, which makes the mistake impossible. This is the other half: a real + /// process, on a real terminal, whose commentary an operator would actually + /// see. + /// + /// The run cannot complete — the configured provider is an address nothing + /// serves — and that is exactly the case worth asserting. A headless run that + /// fails after doing some work is when an operator most needs to know what it + /// did, and before 0.39.0 this path printed nothing on the way to its error. + #[test] + fn f8_a_headless_run_narrates_without_json() { + let home = tempfile::tempdir().expect("a home"); + let workspace = tempfile::tempdir().expect("a workspace"); + let config = home.path().join("io.toml"); + std::fs::write( + &config, + "[[provider]]\nkind = \"compatible\"\nmodel = \"a-model\"\n\ + base_url = \"http://127.0.0.1:9\"\napi_key = \"not-a-key\"\n", + ) + .expect("the configuration"); + + // No pty needed: this door is not a terminal interface, and piping it is + // how a script reaches it. What the arm reads is the split between the + // two streams, which a pty would merge. + let out = Command::new(env!("CARGO_BIN_EXE_io")) + .arg("-C") + .arg(workspace.path()) + .arg("exec") + .arg("say hello") + .env("IO_CONFIG", &config) + .env("IO_CONFIG_HOME", home.path()) + .env("NO_COLOR", "1") + .env_remove("OPENROUTER_API_KEY") + .output() + .expect("io exec runs"); + + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + !stdout.contains('·'), + "the commentary reached stdout, which is the agent's reply and \ + nothing else: {stdout:?}", + ); + } + /// **F4 — `Ctrl+D` at an empty prompt leaves, and the process actually ends.** /// /// The bounded-exit half of F4 that needs no provider. A session that draws diff --git a/tests/skillview.rs b/tests/skillview.rs index 3d16924..6a9acad 100644 --- a/tests/skillview.rs +++ b/tests/skillview.rs @@ -926,7 +926,7 @@ fn f10_a_folder_skill_installs_whole() { "and a nested one, because a skill's references are its own to arrange", ); // The whole point of the layout: io-harness finds it. - let found = io_harness::Skills::discover(&skills::dir(&home)).expect("the home discovers"); + let found = io_harness::Skills::discover(skills::dir(&home)).expect("the home discovers"); assert!( found.get("reviewer").is_some(), "the installed folder is not a skill io-harness can see, which makes the \ From 88b9d23da2d9f63efa0c55c40c8016efeaf4e8ff Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 20:06:15 +0530 Subject: [PATCH 14/27] feat(otel): a run exports as OpenTelemetry spans, and io says only what it knows io-harness has shipped an OTLP exporter since its 0.78.0, behind a feature flag this crate did not enable -- so a capability the harness released was unreleased in practice for anyone whose only interface is `io`. The flag is on now, and `OtelExporter` is one more observer on the fan-out every door already builds. `src/fanout.rs` has been able to hold several since 0.19.0, so there was nothing to invent. The `[otel]` section is io-harness's own, deserialized by io-harness, and io-cli names no key of its own for it and holds no copy of the defaults. It reaches the session and both headless doors -- a trace configured for unattended runs and delivered only in a terminal would be a trace of the half somebody was already watching, which is the asymmetry `[[hook]]` was given `io exec` to avoid. **What the announcement may not say is the part worth the care.** io-harness reports export success or loss through no public value: a batch the collector refused, or one that failed three times and was dropped, goes to a `tracing::warn` and nowhere else, and `Export::send` propagates no error because it runs on a task nobody awaits. Reading that would take a logging subscriber, which is a dependency in a set whose whole argument is that it is ten names. So `io` says what it configured and never that anything arrived -- and a gate sweeps the sentence for six words an operator would read as delivery, with a second sweeping every shipped page for the same claim. An operator staring at an empty collector should be looking at their network, not trusting a line from a process that cannot see it. The sentence is split out pure because it could not otherwise be gated at all: io-harness refuses an `[otel]` section in any file inside a workspace -- a collector is a host every span of every run is posted to and `io.toml` arrives with a `git clone` -- so a config carrying one cannot be built from a string. That refusal is asserted rather than worked around, and documented, because a repository that could declare a collector could forward every run of everyone who cloned it. A section that will not open is said and the session still starts. Telemetry is a report on the work and never the work. --- docs/guide/configuration.md | 38 ++++++++++++ src/contract.rs | 87 ++++++++++++++++++++++++++ src/exec.rs | 26 ++++++++ src/main.rs | 22 +++++++ tests/docs.rs | 39 ++++++++++++ tests/otel.rs | 118 ++++++++++++++++++++++++++++++++++++ 6 files changed, 330 insertions(+) create mode 100644 tests/otel.rs diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 2550b58..92dc462 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -308,6 +308,44 @@ reverted the theme, the diff style and everything else in the section at once wi nothing said about it, and the session now starts on the defaults carrying io-harness's own message — which names the key that broke — in its scrollback. +### Sending a run to a collector + +Since 0.39.0 `io` can export every run as OpenTelemetry spans. It is io-harness's +exporter, reached through io-harness's own `[otel]` section, and `io` adds no key +of its own: + +```toml +[otel] +endpoint = "http://localhost:4318" +service_name = "io" +timeout_secs = 10 +max_queue = 512 +``` + +**It has to go in your user-scope file**, not in a repository's `io.toml`. +io-harness refuses a collector declared inside a workspace for the reason it +refuses a provider there: the collector is a host every span of every run is +posted to, reached with whatever credential the same table names, and an +`io.toml` arrives with a `git clone`. Without that rule a repository could +quietly forward every run of everyone who cloned it. + +The session says what it configured at startup, and `io exec` prints the same line +on stderr. Read it literally: + +> spans go to http://localhost:4318/v1/traces as `io` — io reports what it +> configured and cannot report what arrived + +**`io` cannot tell you a span was delivered, and will never say it was.** +io-harness accounts for a refused or dropped batch in a log rather than in a +value — nothing is returned, no event is emitted, and the export runs on a task +nobody awaits — so reading that account would take a logging subscriber `io` does +not have. If your collector is empty, the thing to check is the collector and the +network between you, because this process knows no more than the address it was +given. A section that will not open at all is a different case and does say so. + +Prompts, replies, tool arguments and tool output are never sent. That is +io-harness's decision and its own documentation states it. + ### Where io keeps your things **One directory: `~/.io-cli`, or `%USERPROFILE%\.io-cli` on Windows.** The diff --git a/src/contract.rs b/src/contract.rs index 0316a25..f69e1df 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -633,6 +633,93 @@ pub fn hooks( (!hooks.is_empty()).then_some(hooks) } +/// The OpenTelemetry exporter an `[otel]` section asks for, and the line that +/// says what it is (0.39.0). +/// +/// **Beside [`hooks`] because it is the same kind of thing**: configuration that +/// becomes an observer, read once per door and attached to the fan-out every door +/// already builds. io-harness has shipped the exporter since its 0.78.0 behind a +/// feature flag this crate did not enable, so a capability the harness released +/// was unreleased in practice for anyone whose only interface is `io`. +/// +/// The section is io-harness's own — `Config::otel` deserializes it, and this +/// crate names no key of its own for it and holds no copy of the defaults. +/// +/// # What the second half of the answer is for, and what it may not say +/// +/// **io-harness reports export success or loss through no public value.** An +/// export that the collector refused, or that failed three times and was dropped, +/// is written to a `tracing::warn` and nowhere else — no counter, no callback, no +/// event, and `Export::send` explicitly propagates no error because it runs on a +/// task nobody awaits. This crate cannot read that account without a tracing +/// subscriber, and a subscriber means a dependency in a set whose whole argument +/// is that it is ten names. +/// +/// So the line says **what was configured**, which io-cli knows, and never that +/// anything arrived, which it does not. An operator who reads `spans go to +/// http://localhost:4318` and sees nothing in their collector has been told +/// exactly as much as this process knows; a line saying "exporting" would be a +/// claim with nothing behind it. Filed upstream, and stated in the guide. +/// +/// What a configured exporter is announced as. +/// +/// **Pure, and split out so the claim can be gated at all.** io-harness refuses +/// an `[otel]` section in any file inside a workspace — a collector is a host +/// every span of every run is posted to, and `io.toml` arrives with a `git +/// clone` — so a `Config` carrying one cannot be built from a string, only +/// discovered from a user-scope file. A test that had to write a file and set an +/// environment variable to read one sentence would be a test about the +/// filesystem; this way the argument is the answer, the way +/// [`crate::keys::Newline::of`] is written for the same reason. +/// +/// The vocabulary is the point. Every word this may not use is a word an +/// operator would read as "it arrived", and none of them is something this +/// process can know — see [`otel`]. +#[must_use] +pub fn otel_said(endpoint: &str, service: &str) -> String { + format!( + "spans go to {endpoint} as `{service}` — io reports what it configured and cannot \ + report what arrived, because io-harness accounts for a dropped batch in a log rather \ + than in a value" + ) +} + +/// Both halves are `None` when no `[otel]` section asks for one, which is every +/// configuration written before this release. +pub fn otel(config: &Config) -> (Option, Option) { + let Some(settings) = config.otel() else { + return (None, None); + }; + // The run store's own path: io-harness reads the trace back out of it to + // build spans, so an exporter pointed anywhere else would export nothing and + // say so to nobody. + let Some(store) = crate::settings::store_path() else { + return ( + None, + Some( + "an [otel] section is configured and there is no run store to export from, \ + so nothing is exported" + .to_string(), + ), + ); + }; + let endpoint = settings.traces_url(); + let service = settings.service_name().to_string(); + match io_harness::OtelExporter::open(settings, &store) { + Ok(exporter) => (Some(exporter), Some(otel_said(&endpoint, &service))), + // Said rather than swallowed, and the run goes on. Telemetry is a report + // on the work and never the work; refusing to start a session because a + // collector's address would not parse would be this crate deciding an + // operator's turn is less important than the trace of it. + Err(error) => ( + None, + Some(format!( + "the [otel] section did not open, so nothing is exported: {error}" + )), + ), + } +} + /// The skills directory this session will really hand the agent, for the one /// surface that has to know it before a turn exists. /// diff --git a/src/exec.rs b/src/exec.rs index fcf91fc..61d8e6e 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -963,10 +963,23 @@ impl WithProvider for Headless { // want to attach to. let resolved = crate::resolved::Resolved::load(&self.config); let hooks = crate::contract::hooks(&self.config, resolved.loaded(), self.session.root()); + // **The exporter reaches CI too, and that is the point of putting it + // here.** A trace configured for unattended runs and delivered only in a + // terminal would be a trace of the half somebody was already watching — + // the same asymmetry `[[hook]]` was given this door to avoid. The + // sentence is printed rather than dropped, because a headless run has no + // startup screen to have said it on. + let (otel, said) = crate::contract::otel(&self.config); + if let Some(said) = said { + eprintln!("io: {said}"); + } let mut observers: Vec<&dyn Observer> = vec![observer]; if let Some(hooks) = &hooks { observers.push(hooks); } + if let Some(otel) = &otel { + observers.push(otel); + } let fanout = crate::fanout::Fanout::new(observers); let durable = crate::settings::store_path().and_then(|path| io_harness::Store::open(&path).ok()); @@ -1687,10 +1700,23 @@ impl WithProvider for Resuming { // nobody watched happen. let resolved = crate::resolved::Resolved::load(&self.config); let hooks = crate::contract::hooks(&self.config, resolved.loaded(), &self.root); + // **The exporter reaches CI too, and that is the point of putting it + // here.** A trace configured for unattended runs and delivered only in a + // terminal would be a trace of the half somebody was already watching — + // the same asymmetry `[[hook]]` was given this door to avoid. The + // sentence is printed rather than dropped, because a headless run has no + // startup screen to have said it on. + let (otel, said) = crate::contract::otel(&self.config); + if let Some(said) = said { + eprintln!("io: {said}"); + } let mut observers: Vec<&dyn Observer> = vec![observer]; if let Some(hooks) = &hooks { observers.push(hooks); } + if let Some(otel) = &otel { + observers.push(otel); + } let fanout = crate::fanout::Fanout::new(observers); let durable = settings::store_path().and_then(|path| Store::open(&path).ok()); let broadcast = durable.map(|store| io_harness::Broadcast::new(store, &fanout)); diff --git a/src/main.rs b/src/main.rs index 08c8c95..179c41d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -563,6 +563,16 @@ async fn drive( if let Some(notice) = settings::deprecated_max_steps(&config) { notices.push(notice); } + // **What the `[otel]` section configured, said once (0.39.0).** A session + // that quietly began exporting every run to a collector would be a session + // doing something on the operator's network that nothing on screen mentions; + // one that said it per turn would put telemetry above every answer. The + // exporter itself is built again where the turn's fan-out is composed — this + // is the sentence, not the observer, and the sentence is careful about what + // it may claim. See `contract::otel`. + if let (_, Some(notice)) = io_cli::contract::otel(&config) { + notices.push(notice); + } let store = settings::store_path().ok_or("no place to keep the run store")?; let store = Store::open(&store).map_err(|error| error.to_string())?; let session = Session::open(&store, root).map_err(|error| error.to_string())?; @@ -7665,10 +7675,22 @@ async fn turn( // before either the interface or a hook is told about it, and a hook that // cancels the turn cannot leave a gap in the sequence a reader is following. let hooks = io_cli::contract::hooks(config, plugins, &root); + // **And the exporter, if one is configured (0.39.0).** One more observer on + // the fan-out this door already builds — which is the whole of what enabling + // io-harness's `otel` feature costs, because `OtelExporter` is an ordinary + // `Observer` and `src/fanout.rs` has been able to hold several since 0.19.0. + // + // The sentence it comes with is dropped here rather than drawn: the session + // says it once at startup, where its siblings are said, and repeating it per + // turn would put a line about telemetry above every answer. + let (otel, _) = io_cli::contract::otel(config); let mut observers: Vec<&dyn io_harness::Observer> = vec![&observer]; if let Some(hooks) = &hooks { observers.push(hooks); } + if let Some(otel) = &otel { + observers.push(otel); + } let fanout = io_cli::fanout::Fanout::new(observers); // A second connection to the same file, which is what io-harness's own // documentation asks for rather than tolerates: `Observer` is `Send + Sync`, diff --git a/tests/docs.rs b/tests/docs.rs index a8c6775..9949094 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -2535,6 +2535,45 @@ fn f6_the_headless_guide_carves_out_the_provider_endpoint() { /// the page that exists to talk about money from doing so. /// /// Sabotage: write "withholding a tool makes the turn cheaper" into any guide page. +/// **N3 — no shipped page promises a span arrived (0.39.0).** +/// +/// io-harness reports export success or loss through no public value: a batch the +/// collector refused, or one that failed three times and was dropped, goes to a +/// `tracing::warn` and nowhere else, and `Export::send` propagates no error +/// because it runs on a task nobody awaits. Reading that account would take a +/// tracing subscriber, which is a dependency in a set whose whole argument is +/// that it is ten names. +/// +/// So `io` says what it configured and never what arrived, and this holds the +/// documentation to the same line. A page is where an operator forms the belief, +/// and a guide promising confirmed delivery would outlive any comment in the +/// crate — an operator who read one and saw an empty collector would look +/// anywhere except at their own network. +/// +/// Sabotage: write "io confirms the export" into the observability guide. Only +/// this fails. +#[test] +fn n5_no_shipped_page_claims_a_span_was_delivered() { + for (path, text) in shipped_prose() { + let lower = text.to_lowercase(); + for claim in [ + "spans were exported", + "spans reached", + "confirms the export", + "confirms delivery", + "verifies the export", + "guarantees the export", + ] { + assert!( + !lower.contains(claim), + "{path} claims `{claim}`. io-harness accounts for a dropped export \ + in a log and in no value this crate can read, so no page may say \ + one arrived", + ); + } + } +} + /// Every other test in the repository stays green. #[test] fn n3_no_shipped_page_claims_a_mask_reduces_what_a_turn_costs() { diff --git a/tests/otel.rs b/tests/otel.rs new file mode 100644 index 0000000..90e1d3a --- /dev/null +++ b/tests/otel.rs @@ -0,0 +1,118 @@ +//! F13 — a run exports as OpenTelemetry spans, and io says only what it knows. +//! +//! io-harness has shipped an OTLP exporter since its 0.78.0, behind a feature +//! flag this crate did not enable — so a capability the harness released was +//! unreleased in practice for anyone whose only interface is `io`. Turning the +//! flag on is one line of a manifest; what this file gates is the part that is +//! io-cli's own, which is the seam it is attached at and the sentence it comes +//! with. +//! +//! **The sentence is the interesting half.** io-harness reports export success or +//! loss through no public value: a batch the collector refused, or one that +//! failed three times and was dropped, goes to a `tracing::warn` and nowhere +//! else, and `Export::send` propagates no error at all because it runs on a task +//! nobody awaits. Reading that account would take a tracing subscriber, and a +//! subscriber is a dependency in a set whose whole argument is that it is ten +//! names. So io-cli says what it *configured* and never what arrived — and these +//! arms hold it to that, because a line saying "exporting" is exactly the kind of +//! claim an operator would stop looking behind. + +mod support; + +use io_harness::Config; + +/// A configuration with an `[otel]` section, and nothing else io-cli reads. +fn configured(section: &str) -> Config { + Config::from_toml(section).expect("the section parses") +} + +#[test] +fn f13_no_section_configures_nothing_and_says_nothing() { + let (exporter, said) = io_cli::contract::otel(&configured("")); + assert!( + exporter.is_none(), + "an exporter was built for a configuration that asked for none", + ); + assert!( + said.is_none(), + "a session with no [otel] section said something about telemetry: {said:?}", + ); +} + +/// **F13 — what is configured is named, and what arrived is not claimed.** +/// +/// Driven through `contract::otel_said` rather than through a `Config`, and that +/// is forced rather than convenient: io-harness refuses an `[otel]` section in +/// any file inside a workspace — a collector is a host every span of every run is +/// posted to, and `io.toml` arrives with a `git clone` — so a config carrying one +/// cannot be built from a string at all. The sentence is split out pure for +/// exactly this reason. +/// +/// Sabotage: add the word "exporting", or any past-tense claim about delivery, to +/// that sentence. This fails on the vocabulary sweep, which is the only thing +/// standing between an operator and a line they would trust. +#[test] +fn f13_the_line_names_the_endpoint_and_claims_no_delivery() { + let said = io_cli::contract::otel_said("http://localhost:4318/v1/traces", "io-under-test"); + + assert!( + said.contains("http://localhost:4318"), + "the endpoint an operator configured is the one thing they will check \ + this line against: {said}", + ); + assert!( + said.contains("io-under-test"), + "and the service name, which is how they will find it in a collector: {said}", + ); + + // The claims this line may not make. Each is a word an operator would read as + // "it arrived", and none of them is something this process can know. + for forbidden in [ + "exported", + "exporting", + "delivered", + "received", + "reached the collector", + "spans sent", + ] { + assert!( + !said.to_lowercase().contains(forbidden), + "the line claims `{forbidden}`, and io-harness reports delivery \ + through no public value — a dropped batch is a log line this process \ + cannot read: {said}", + ); + } +} + +/// **F13 — a collector is user-scope only, and io-cli does not work around it.** +/// +/// io-harness refuses an `[otel]` section in any file inside a workspace, for the +/// reason it refuses `[[provider]]` there: the collector is a host every span of +/// every run is posted to, reached with whatever credential the same table names, +/// and `io.toml` arrives with a `git clone`. A repository could otherwise ship a +/// file that quietly forwards every run of everyone who cloned it. +/// +/// This asserts the refusal reaches an operator as io-harness wrote it rather +/// than being caught and softened here — the same rule `src/configure.rs` follows +/// for every other widening refusal, which is that the harness owns what may be +/// declared where. +#[test] +fn f13_a_workspace_file_may_not_name_a_collector() { + let refusal = Config::from_toml("[otel]\nendpoint = \"http://localhost:4318\"\n") + .expect_err("a project-scoped collector is refused"); + let said = refusal.to_string(); + assert!( + said.contains("otel"), + "the refusal has to name the section: {said}", + ); + assert!( + said.contains("user-scope") || said.contains("IO_CONFIG"), + "and where it does belong, or an operator has been told no and nothing \ + else: {said}", + ); +} + +// The documentation half of this — that no shipped page promises a span +// arrived — is `n5_no_shipped_page_claims_a_span_was_delivered` in +// `tests/docs.rs`, which is where `shipped_prose` lives and where every other +// sweep of that kind is kept. From c48250293db07fc34eecff2d4e0aba69987310c9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 20:13:24 +0530 Subject: [PATCH 15/27] feat(codeact): a turn can write one contained program, and the transcript says so io-harness has shipped CodeAct since its 0.79.0 behind a feature flag this crate did not enable. The switch is a `TaskContract` field, and it goes in `contract::configured` -- the one function on the build path of every door -- because a turn that could write a program in the terminal and not in CI would be the 0.14.0 asymmetry rebuilt, on the door where an unattended run has the most to gain from doing in one step what it otherwise does in twelve. **Enabling the feature hands the agent nothing on its own**, and that is the fact gated hardest. io-harness advertises `run_program` only where a contract carries a `[codeact]` section, so with it absent the tool catalogue is byte for byte what 0.38.2 sent. Two arms hold it -- the contract carries no CodeAct configuration when nothing asked, and the whole contract stays Debug-identical to `default_contract` -- and an unconditional `with_codeact` fails both. That double-gating is deliberate: turning on `media` in 0.9.0 gave every run a `view_image` tool as a side effect of a flag, and this is the same shape of mistake with a bigger tool at the end of it. `EventKind::Program` becomes a line. Its triage note refused one on the grounds that "a line here would announce that a program ran and then say nothing about what it did" -- true of the event and false of the transcript. Every act a program takes re-enters dispatch and arrives as its own tool call, so the cells beneath the row are exactly what it did; what was missing was the row saying they belong to one program rather than to the model calling tools one at a time, which matters because a program's acts are not separately approved. A withheld capability draws once and an available one draws nothing. The documented fallback is that a host with no interpreter composes and steps as it would with the feature off, which is right -- and a silent version of it is not, because an operator who configured this and is watching twelve round trips needs to know the host had no interpreter. The sentence is io-harness's own, naming what it looked for, rather than a second opinion about a probe this crate did not run. The program's source is not on the event and the row does not pretend otherwise. --- docs/guide/configuration.md | 39 ++++++++++++++++ src/contract.rs | 27 +++++++++++ src/events.rs | 80 ++++++++++++++++++++++++++++++++ src/triage.rs | 49 ++++++++++---------- tests/contract.rs | 40 ++++++++++++++++ tests/events.rs | 92 +++++++++++++++++++++++++++++++++++++ tests/glyphs.rs | 15 ++++++ 7 files changed, 317 insertions(+), 25 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 92dc462..bfc121b 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -308,6 +308,45 @@ reverted the theme, the diff style and everything else in the section at once wi nothing said about it, and the session now starts on the defaults carrying io-harness's own message — which names the key that broke — in its scrollback. +### Letting a turn write a program + +Since 0.39.0 a turn can write one contained program instead of a chain of tool +calls. It is io-harness's CodeAct, reached through io-harness's own `[codeact]` +section: + +```toml +[codeact] +interpreter = "python3" +max_callbacks = 64 +timeout_secs = 120 +``` + +**Absent, nothing changes.** The tool catalogue a turn is sent is byte for byte +what it was before this release: io-harness offers `run_program` only where a +contract carries this section, so enabling the feature grants the agent nothing on +its own. That is worth saying plainly, because turning on `media` in 0.9.0 did +give every run a new tool as a side effect of a flag. + +Like `[otel]`, this belongs in your user-scope file — a repository may not decide +that whoever clones it runs programs. + +**A host with no interpreter is a supported host.** io-harness looks for `python3` +then `python`, needs 3.8 or newer, and where it finds neither the turn composes, +sends and steps exactly as it would with the section absent. `io` says so once, +in io-harness's own words naming what it tried, rather than leaving you watching +twelve round trips where you expected one program. + +A program that ran draws one row — the interpreter, how many calls it made, and +how it ended — and **the tool cells underneath it are what it did**. Each callback +re-enters the same dispatch every other tool call goes through, so the same policy +gate sees it and the same transcript draws it. What the row adds is that those +acts belong to one program rather than to the model calling tools one at a time, +which is worth knowing because they are not separately approved. + +The program's own source is not in the transcript. io-harness's event carries the +interpreter, a callback count and an outcome, and no program text; the durable +trace is where the detail lives. + ### Sending a run to a collector Since 0.39.0 `io` can export every run as OpenTelemetry spans. It is io-harness's diff --git a/src/contract.rs b/src/contract.rs index f69e1df..1ab0240 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -432,6 +432,33 @@ pub fn configured( Some(routing) => contract.with_routing(routing), None => contract, }; + // **CodeAct, where an `[codeact]` section asks for it (0.39.0).** + // + // Here rather than in [`session`], and that placement is the whole of it: a + // turn that could write one contained program in the terminal and not in CI + // would be the 0.14.0 asymmetry rebuilt, on the door where an unattended run + // has the most to gain from doing in one step what it otherwise does in + // twelve. + // + // **Conditional, and a genuine no-op when nothing asked.** `contract::session` + // must reproduce io-harness's `default_contract` field for field with nothing + // configured, which `tests/contract.rs` asserts by Debug equality — so an + // unconditional `with_codeact(CodeActConfig::default())` is not a harmless + // default, it is a failing gate and a behaviour change for every operator who + // never asked. The `match` is [`masking`]'s shape for [`masking`]'s reason: + // byte-identity becomes a property of this function rather than a coincidence + // of what the harness happens to default to. + // + // **Enabling the feature hands the agent nothing on its own.** io-harness + // advertises `run_program` only where a contract carries this configuration — + // `codeact_ready` opens with `contract.codeact.clone()?` — so with the section + // absent the tool catalogue is byte for byte what 0.38.2 sent. That is worth + // saying because `media` behaved differently in 0.9.0 and gave every run a + // tool as a consequence of a feature flag. + let contract = match config.codeact() { + Some(codeact) => contract.with_codeact(codeact), + None => contract, + }; // `[run] skills` has had its say, and `io exec` reads no other key that can // name one — so for the headless arm this is already the point after every // key. [`session`] calls this again once `[app.io-cli]` has had its own. diff --git a/src/events.rs b/src/events.rs index 6b95b61..cf03200 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1822,6 +1822,86 @@ impl Events { }); lines } + // **A program the turn wrote, and what became of it (0.39.0).** + // + // io-harness emits this once before the first step, saying whether the + // capability is `available` or `withheld`, and once per program + // afterwards with `finished`, `failed`, `bound` or `timeout`. + // + // **The source is not here and cannot be.** `EventKind::Program` + // carries the interpreter, a detail line, a callback count and an + // outcome — no program text — so this row says what ran and how it + // ended rather than what was written. The acts the program took are + // not on it either, and do not need to be: each callback re-enters + // dispatch and arrives as its own `ToolCall`, so the cells beneath + // this row *are* what it did. What this row adds is that they belong + // to one program rather than to the model calling tools one at a + // time, which matters to a reader because a program's acts are not + // separately approved. + // + // **`available` draws nothing.** A capability being present is a fact + // about the run's configuration, and a row for it on every contained + // turn would be furniture. `withheld` does draw, because an operator + // who configured `[codeact]` and is watching twelve round trips where + // they expected one program needs to know the host had no + // interpreter — that is the difference between a setting that is not + // working and one that is not applicable. + EventKind::Program { + interpreter, + detail, + calls, + outcome, + } => { + if outcome == "available" { + return Vec::new(); + } + let mut lines = self.flush_text(); + if outcome == "withheld" { + // io-harness's own discovery sentence, which names what it + // tried. A sentence of io-cli's here would be a second + // opinion about a probe it did not run. + lines.push(theme.notice( + Tone::Muted, + format!("no program interpreter{separator}{detail}"), + )); + return lines; + } + let mut spans = vec![ + Span::styled(leader(separator), theme.style(Tone::Muted)), + Span::styled("Program".to_string(), theme.style(Tone::Normal)), + ]; + if let Some(interpreter) = interpreter { + spans.push(Span::styled(separator, theme.style(Tone::Muted))); + spans.push(Span::styled(interpreter.clone(), theme.style(Tone::Muted))); + } + spans.push(Span::styled(separator, theme.style(Tone::Muted))); + spans.push(Span::styled( + // Singular and plural, because "1 calls" is the shape of a + // number nobody checked. + if *calls == 1 { + "1 call".to_string() + } else { + format!("{calls} calls") + }, + theme.style(Tone::Muted), + )); + spans.push(Span::styled(separator, theme.style(Tone::Muted))); + spans.push(Span::styled( + outcome.clone(), + // A program that failed, ran out of callbacks or timed out is + // not an error in `io` and is not drawn as one — it is an + // outcome the operator asked for the possibility of. But it is + // not `finished` either, and the tone is what says so on a + // row somebody is scanning. + theme.style(if outcome == "finished" { + Tone::Muted + } else { + Tone::Warning + }), + )); + lines.push(Line::from(spans)); + lines + } // **A picture the agent was handed, named but not drawn (0.39.0).** // // The operator's own attachments are drawn where they are attached, by diff --git a/src/triage.rs b/src/triage.rs index 699d126..00e6736 100644 --- a/src/triage.rs +++ b/src/triage.rs @@ -291,35 +291,34 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ the first step", ), ("contained", Disposition::Status, "the containment field"), - // **0.79.0 — a kind this crate cannot receive, dispositioned anyway.** - // Placed between `contained` and `dialed` because that is where - // `EventKind` declares it, and this table's whole ordering rule is that it - // can be read down the side of `observe.rs` when the pin moves. + // **0.79.0 declared it, 0.39.0 turned it on.** The note here said "revisit + // when this crate enables `codeact`", and this is that revisit. Placed + // between `contained` and `dialed` because that is where `EventKind` declares + // it, and this table's whole ordering rule is that it can be read down the + // side of `observe.rs` when the pin moves. // - // `EventKind::Program` is declared unconditionally in `observe.rs`, but - // every site that emits it is behind io-harness's `codeact` feature, which - // this crate does not enable. So the row exists because the table is total - // over what the harness *declares*, and the disposition is the one a run - // would get if the feature were ever turned on. + // **A line, and the old note's objection is answered rather than overruled.** + // It refused one because "a line here would announce that a program ran and + // then say nothing about what it did", which is true of the event and false + // of the transcript: every act the program took re-enters dispatch and + // arrives as its own `tool_call`, so the rows underneath this one are exactly + // what it did. What was missing was the row that says they belong to a + // program rather than to the model calling tools one at a time — which is a + // materially different thing for a reader to know, because a program's acts + // are not separately approved. // - // Silent rather than a line, and the discovery half is why: the event is - // emitted once before the first step saying `available` or `withheld`, and - // once per program afterwards. A capability's availability is a fact about - // the run's configuration rather than about the conversation, and the acts a - // program takes are not on this event at all — each one re-enters dispatch - // and arrives as its own `tool_call`, which the transcript already draws. A - // line here would announce that a program ran and then say nothing about - // what it did. - // - // **Revisit when this crate enables `codeact`.** A program a turn wrote is - // the largest thing a model can do in one step, and the roadmap says it - // belongs in the transcript as source. That is a release, not a row. + // The availability half is drawn too, and only when a capability was + // **withheld**. That is a fact about the run's configuration rather than + // about the conversation, and would be furniture on every contained turn — + // but an operator who configured `[codeact]` and is watching the agent make + // twelve round trips instead of writing one program needs to know the host + // had no interpreter. ( "program", - Disposition::Silent, - "`io exec --json`, which forwards it verbatim, and the durable trace; no run this crate \ - drives emits it, because every emitting site is behind io-harness's `codeact` feature and \ - this crate does not enable it", + Disposition::Line, + "a row naming the interpreter, how many calls the program made and how it ended, with the \ + acts it took drawn beneath it as their own tool cells; a withheld capability says so \ + once, and `io exec --json` forwards the event verbatim", ), ( "dialed", diff --git a/tests/contract.rs b/tests/contract.rs index bc9a8da..e840ef6 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -2147,6 +2147,46 @@ fn n3_this_release_adds_no_configuration_key() { /// out, the way `tests/memory.rs` leaves it: the directory it names is gone by /// then, so a later `Config::discover` finds no user scope, which is what every /// other fixture in this file wants anyway. +/// **F15 — enabling `codeact` hands the agent nothing on its own.** +/// +/// This is the fact worth gating, because `media` behaved differently in 0.9.0 +/// and gave every run a `view_image` tool as a consequence of a feature flag — +/// a capability change arriving inside an interface release. io-harness +/// advertises `run_program` only where a contract carries a `[codeact]` +/// configuration, so with the section absent the tool catalogue is byte for byte +/// what 0.38.2 sent. +/// +/// Asserted through the contract rather than through a request, because the +/// contract is where this crate's decision lives and the catalogue is +/// io-harness's consequence of it. `f2_nothing_configured_is_the_contract_the_ +/// session_built_before` is the other half and would fail on an unconditional +/// `with_codeact` — the two together say that the field is absent and that the +/// whole contract is unchanged. +/// +/// Sabotage: make the `match` in `contract::configured` unconditional with +/// `CodeActConfig::default()`. Both this and F2's Debug equality fail. +#[test] +fn f15_a_configuration_with_no_codeact_section_carries_none() { + let _guard = env_lock(); + let _home = HomeFixture::new(); + let (dir, config) = discovered(&[]); + + let built = io_cli::contract::configured( + "bring the docs up to date", + dir.path().to_path_buf(), + &config, + &io_harness::Plugins::default(), + ); + + assert!( + built.codeact.is_none(), + "a contract carries a CodeAct configuration nobody asked for. \ + io-harness advertises `run_program` wherever this is set, so this is the \ + agent gaining a tool as a consequence of a feature flag — which is what \ + enabling `media` did in 0.9.0 and is the mistake this asserts against", + ); +} + #[test] fn f16_the_skills_default_follows_the_home_in_force() { let _guard = env_lock(); diff --git a/tests/events.rs b/tests/events.rs index 6d61a3f..cb47d60 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -3301,6 +3301,98 @@ fn f8_a_thought_is_committed_above_the_prose_it_produced() { /// /// Sabotage: drop the `said_plainly` rebinding in the `Step` arm and every row /// with a brace in it fails. +/// **F15 — a program the turn wrote gets a row, and its acts are the rows under +/// it.** +/// +/// `EventKind::Program` carries the interpreter, a detail line, a callback count +/// and an outcome — and no program text, which is why this row says what ran and +/// how it ended rather than what was written. The acts are not on it either and +/// do not need to be: each callback re-enters dispatch and arrives as its own +/// `ToolCall`, so the cells beneath this row are what it did. What the row adds +/// is that they belong to one program rather than to the model calling tools one +/// at a time, which matters because a program's acts are not separately approved. +#[test] +fn f15_a_program_says_what_ran_and_how_it_ended() { + let mut events = Events::new(DARK); + let line = rendered( + &mut events, + EventKind::Program { + interpreter: Some("python3".into()), + detail: "python3 3.12".into(), + calls: 3, + outcome: "finished".into(), + }, + ); + + assert!(line.contains("Program"), "{line}"); + assert!( + line.contains("python3"), + "the interpreter that ran it: {line}" + ); + assert!(line.contains("3 calls"), "{line}"); + assert!(line.contains("finished"), "{line}"); + + // "1 calls" is the shape of a number nobody checked. + let one = rendered( + &mut events, + EventKind::Program { + interpreter: Some("python3".into()), + detail: String::new(), + calls: 1, + outcome: "finished".into(), + }, + ); + assert!(one.contains("1 call") && !one.contains("1 calls"), "{one}"); +} + +/// **F15 — a host with no interpreter says so once, and a host with one says +/// nothing.** +/// +/// The documented fallback is that a run without an interpreter composes, sends +/// and steps exactly as it would with the feature off. That is the right +/// behaviour and a silent version of it is not: an operator who configured +/// `[codeact]` and is watching twelve round trips where they expected one program +/// needs to know the host had no interpreter — the difference between a setting +/// that is not working and one that is not applicable. +/// +/// `available` draws nothing, because a capability being present is a fact about +/// the run's configuration and a row for it on every contained turn would be +/// furniture. +#[test] +fn f15_a_withheld_capability_says_so_and_an_available_one_does_not() { + let mut events = Events::new(DARK); + + let available = rendered( + &mut events, + EventKind::Program { + interpreter: Some("python3".into()), + detail: "python3 3.12".into(), + calls: 0, + outcome: "available".into(), + }, + ); + assert!( + available.trim().is_empty(), + "a capability being present drew a row on a turn that had not used it: {available}", + ); + + let withheld = rendered( + &mut events, + EventKind::Program { + interpreter: None, + detail: "tried python3, python".into(), + calls: 0, + outcome: "withheld".into(), + }, + ); + assert!( + withheld.contains("tried python3, python"), + "the discovery sentence is io-harness's own, naming what it looked for — \ + a sentence written here would be a second opinion about a probe this \ + crate did not run: {withheld}", + ); +} + /// **F18 — a picture the agent was handed is named, and not drawn.** /// /// `src/picture.rs` draws every image the *operator* attached, where they attach diff --git a/tests/glyphs.rs b/tests/glyphs.rs index 999fcfd..ae6f2b0 100644 --- a/tests/glyphs.rs +++ b/tests/glyphs.rs @@ -623,6 +623,21 @@ fn every_event_this_release_renders_draws_in_ascii() { digest: "sha256:not-a-real-digest".into(), source: "browser".into(), }, + // 0.39.0 — a program a turn wrote. `finished` takes the muted-leader path + // with four separators; `withheld` takes the toned-notice path, and both + // are swept because they are two different renderings of one kind. + EventKind::Program { + interpreter: Some("python3".into()), + detail: "python3 3.12".into(), + calls: 3, + outcome: "finished".into(), + }, + EventKind::Program { + interpreter: None, + detail: "tried python3, python".into(), + calls: 0, + outcome: "withheld".into(), + }, ]; // **The list above is checked against the renderer, not trusted.** It is From 23553cb91b2bbd4b0b0b0567d7327e57c0f367cb Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 20:22:13 +0530 Subject: [PATCH 16/27] feat(mcp): io mcp serve offers this install's tools to another agent io-harness has shipped an MCP server mode since its 0.78.0 behind a feature flag this crate did not enable. `io mcp serve` is the door: newline-delimited JSON-RPC on stdio, ending when the client closes its end. A verb on `mcp` rather than a tenth argv variant. The other verbs here manage servers io talks to and this one makes io a server, but the surface is MCP either way and an operator looking for anything to do with MCP looks at `io mcp`. It plans nothing, like `probe`, because it writes no configuration -- and unlike every other verb it never returns on its own. **The disclosure is the argument for the feature.** The reason another agent would borrow this boundary is that it can see it, so a server that started silently would be asking a client to trust something it has no way to inspect. `servers::serving` names the root, the protocol version, the posture in force and the eleven tools io-harness does not serve -- read from the harness's own `MCP_SERVER_UNSERVED`, because a second copy here goes stale the first time it moves a name, and because a bounded server that quietly declines to offer `spawn` looks like a broken one. Every one of those lines goes to stderr. Stdout is the protocol from the moment the loop starts, and one human-readable byte on it corrupts the stream for the client -- which is a real risk in this file rather than a theoretical one, since every neighbouring verb prints its answer with `println!` and is supposed to. A gate slices the arm out of the driver and asserts it. Two things came from driving the real argv door rather than trusting the tests, which is why that habit exists. The policy line was a `Debug` dump -- hundreds of columns of `Rule { act: Read, effect: Deny, pattern: ".env" }` -- and is now the posture in the words the rest of the product uses, with an honest fallback for a policy that is not one of the three. And the server introduced itself to clients as `io-harness`, the library inside it, which would have sent anyone reading their server list to the wrong documentation. Verified end to end: a real initialize handshake and a `tools/list` over the built binary, answering as `io` with this install's tools and nothing but frames on stdout. --- src/main.rs | 34 ++++++++++++++++++ src/manage.rs | 30 +++++++++++++++- src/servers.rs | 56 +++++++++++++++++++++++++++++ tests/servers.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 179c41d..63bad2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10240,6 +10240,40 @@ async fn manage_main( // server from a dead one only by parsing the sentence above. return Ok(io_cli::exec::probe_code(&probe)); } + // **`io mcp serve` — this install's own tools, offered to somebody else + // (0.39.0).** The other verbs on this surface manage servers io talks to; + // this one makes io a server, over the same protocol, on stdio. + // + // **Stdout is the protocol from this line on.** Everything io says about + // itself — which tools it serves, which it does not, under what policy — + // is written to stderr before the loop starts, because one + // human-readable byte on stdout corrupts the stream for the client that + // borrowed this boundary. That is the same rule `io acp` follows and it + // is the reason this arm returns rather than falling through to the + // `println!` verbs below it. + // + // **The policy is the one this install resolved**, not a wider one: the + // whole reason another agent would borrow this boundary is that it can + // see it, so serving under anything other than the operator's own rules + // would make the borrowing pointless and the disclosure a lie. + io_cli::manage::Request::Mcp(io_cli::manage::McpVerb::Serve) => { + let store = io_cli::settings::store_path().ok_or("no place to keep the run store")?; + let policy = config.policy().unwrap_or_default(); + // Named for the product an operator configured, not for the library + // inside it. A client's server list is where this name is read, and + // `io-harness` there would send somebody looking for the wrong + // documentation. + let served = io_harness::McpServerConfig::new(root, &store) + .with_policy(policy) + .with_server_name("io"); + for line in io_cli::servers::serving(&served) { + eprintln!("{line}"); + } + io_harness::serve_mcp(served) + .await + .map_err(|error| error.to_string())?; + return Ok(io_cli::exec::OK); + } _ => {} } // **Resolved only for the one verb that needs it.** `Config::plugins()` is a diff --git a/src/manage.rs b/src/manage.rs index 4f44bf9..2fe41b0 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -164,6 +164,20 @@ pub enum McpVerb { /// not a *cheap* read — it spawns or dials a real server — but nothing about /// the operator's files changes, which is what a `Plan` is about. Probe { id: String }, + /// Serve **this install's own tools** to somebody else over MCP on stdio, and + /// keep serving until the client closes its end (0.39.0). + /// + /// **The other verbs here manage servers io talks to; this one makes io a + /// server.** It is on this surface anyway because the surface is MCP rather + /// than because the direction is the same, and because an operator looking for + /// anything to do with MCP looks at `io mcp`. + /// + /// A read like [`Probe`](McpVerb::Probe), and more so: it writes no + /// configuration, so [`plan`] answers `None` and each door runs it. Unlike + /// every other verb it never returns on its own — stdin closing is what ends + /// it — and it owns stdout completely from the moment it starts, because + /// stdout *is* the protocol. + Serve, } /// What `/plugin` and `io plugin` can be asked to do. @@ -458,6 +472,15 @@ pub fn parse(tokens: &[String]) -> Result { id: args.one_word("mcp probe", "the id of a configured server")?, })) } + ("mcp", Some("serve")) => { + // No scope and no words. The root is `-C`'s and the policy is the + // one this install resolved — a server that took either as an + // argument would be a way to serve a workspace under a posture the + // operator's own configuration does not grant. + args.no_scope("mcp serve")?; + args.nothing("mcp serve")?; + Ok(Request::Mcp(McpVerb::Serve)) + } ("mcp", Some("remove")) => { args.no_scope("mcp remove")?; args.only("mcp remove", &[])?; @@ -559,7 +582,7 @@ pub fn parse(tokens: &[String]) -> Result { /// documentation this whole module's refusals exist to save. fn verbs(surface: &str) -> &'static str { match surface { - "mcp" => "`add`, `list`, `get`, `edit`, `enable`, `disable`, `probe` and `remove`", + "mcp" => "`add`, `list`, `get`, `edit`, `enable`, `disable`, `probe`, `serve` and `remove`", // `remove` takes the same two readings `add` does — a directory, or the // name of a bundle — and says so here, because an operator who was refused // is being told what to type next and `remove ` alone would send the @@ -715,6 +738,11 @@ pub fn plan( // probe does. `Ok(None)` is the honest answer and it is the same one every // read verb here gives. Request::Skill(_) => return Ok(None), + // Serving writes nothing either, and is the furthest thing here from a + // plan: it never returns on its own. `Ok(None)` for the probe's reason — + // nothing about the operator's files changes, which is what a `Plan` is + // about — and the door runs it. + Request::Mcp(McpVerb::Serve) => return Ok(None), Request::Mcp(McpVerb::Add { server, scope }) => Plan { scope: *scope, edits: vec![crate::servers::add(server)], diff --git a/src/servers.rs b/src/servers.rs index 0a55306..1e47a84 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -877,6 +877,62 @@ fn decided_by(rule: Option<&str>, layer: Option<&str>) -> String { /// server definition put there. Getting it wrong is a refusal rather than a /// corruption — [`crate::edit::apply`] reads its own result back — but a /// refusal an operator cannot act on is still a verb that does not work. +/// What `io mcp serve` says about itself, before stdout becomes the protocol +/// (0.39.0). +/// +/// **A server another agent borrows is only worth borrowing if it can be seen.** +/// The whole argument for handing somebody else this install's tools is that they +/// arrive under this install's policy, gated and journalled — so a server that +/// started silently would be asking a client to trust a boundary it has no way to +/// inspect. These lines are that inspection: the root, the policy in force, and +/// which of io-harness's tools are served and which are not. +/// +/// **Every line goes to stderr and the caller is what writes them**, because +/// stdout is the JSON-RPC stream from the moment the loop starts and one +/// human-readable byte on it corrupts the protocol for the client. +/// +/// The unserved set is io-harness's own `MCP_SERVER_UNSERVED` rather than a list +/// written here. It is the half a reader is most likely to be surprised by — a +/// borrowed boundary that quietly declined to offer `spawn` or `ask_question` +/// would look like a broken server rather than a bounded one — and a second copy +/// of it here would go stale the first time the harness moved a name. +pub fn serving(config: &io_harness::McpServerConfig) -> Vec { + let mut lines = vec![ + format!("io: serving {} over MCP on stdio", config.root().display()), + format!( + "io: protocol {}, as `{}`", + io_harness::MCP_SERVER_PROTOCOL_VERSION, + config.server_name(), + ), + // **The posture in the words the rest of the product uses, and not a + // `Debug` of the policy.** Driving the real argv door is what caught + // that: the first version printed every layer, every rule and every + // pattern on one line — hundreds of columns of `Rule { act: Read, effect: + // Deny, pattern: ".env" }` — which is not a disclosure, it is a wall a + // reader scrolls past. + // + // `Posture::of` answers `None` for a policy that is not one of the three, + // which is allowed and is why the fallback says so rather than guessing. + // A configuration file can express far more than three postures, and + // naming one of them anyway would put a true-looking word beside a + // boundary it does not describe. + match crate::settings::Posture::of(&config.policy().defaults) { + Some(posture) => format!("io: {} — {}", posture.label(), posture.detail()), + None => "io: under this install's own policy, which is not one of the three \ + postures — `io mcp get` and the configuration file are what describe it" + .to_string(), + }, + ]; + if !io_harness::MCP_SERVER_UNSERVED.is_empty() { + lines.push(format!( + "io: not served{}{}", + " — ", + io_harness::MCP_SERVER_UNSERVED.join(", "), + )); + } + lines +} + pub fn quoted(text: &str) -> String { let mut out = String::with_capacity(text.len() + 2); out.push('"'); diff --git a/tests/servers.rs b/tests/servers.rs index dd12742..ca0286a 100644 --- a/tests/servers.rs +++ b/tests/servers.rs @@ -1208,6 +1208,100 @@ fn f10_a_typed_value_becomes_the_toml_its_key_needs() { /// the instrument `tests/contract.rs` and `tests/context_share.rs` already use for /// exactly this. Two properties: the edit row exists at all, and the write is /// aimed at the scope the lookup returned rather than at a scope somebody picked. +/// **F14 — the server says what it serves, and says it where it cannot corrupt +/// the protocol.** +/// +/// A server another agent borrows is only worth borrowing if it can be seen: the +/// whole argument for handing somebody else this install's tools is that they +/// arrive under this install's policy, gated and journalled. A server that +/// started silently would be asking a client to trust a boundary it has no way to +/// inspect. +/// +/// The unserved set is the half a reader is most likely to be surprised by. A +/// borrowed boundary that quietly declined to offer `spawn` would look like a +/// broken server rather than a bounded one — and it is io-harness's own +/// `MCP_SERVER_UNSERVED` rather than a list written here, because a second copy +/// goes stale the first time the harness moves a name. +#[test] +fn f14_serving_names_the_root_the_policy_and_what_it_will_not_serve() { + let root = std::path::Path::new("/some/workspace"); + let config = io_harness::McpServerConfig::new(root, std::path::Path::new("/some/runs.db")); + + let said = io_cli::servers::serving(&config).join("\n"); + + assert!( + said.contains("/some/workspace"), + "the root it serves: {said}" + ); + assert!( + said.contains(io_harness::MCP_SERVER_PROTOCOL_VERSION), + "the protocol version a client negotiates against: {said}", + ); + // **The boundary in the product's own words, not a `Debug` of the policy.** + // Driving the real argv door is what settled this: the first version printed + // every layer, rule and pattern on one line — hundreds of columns of + // `Rule { act: Read, effect: Deny, pattern: ".env" }` — which is a wall a + // reader scrolls past rather than a disclosure. What a client needs is which + // of the three postures they are borrowing. + let posture = io_cli::settings::Posture::of(&config.policy().defaults) + .expect("a default policy is one of the three postures"); + assert!( + said.contains(posture.label()) && said.contains(posture.detail()), + "the boundary has to be readable, and this is where a client learns \ + whether a write will stop and ask: {said}", + ); + assert!( + !said.contains("Rule {"), + "the policy is dumped rather than described: {said}", + ); + + for withheld in io_harness::MCP_SERVER_UNSERVED { + assert!( + said.contains(withheld), + "`{withheld}` is not served and the disclosure does not say so, so a \ + client meets a bounded server as a broken one: {said}", + ); + } +} + +/// **F14 — nothing the server says about itself is written to stdout.** +/// +/// Stdout is the JSON-RPC stream from the moment the loop starts, and one +/// human-readable byte on it corrupts the protocol for the client that borrowed +/// this boundary. Every other verb on this surface prints its answer with +/// `println!`, so this arm is guarding against the most natural mistake in the +/// file rather than an exotic one — `io mcp list` is eight lines away and is +/// supposed to use stdout. +/// +/// Sabotage: change the `eprintln!` in the serve arm to `println!`. Only this +/// fails. +#[test] +fn f14_the_serve_arm_writes_nothing_to_stdout() { + let driver = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/main.rs"), + ) + .expect("the driver is readable"); + + let start = driver + .find("McpVerb::Serve) => {") + .expect("the serve arm is in the driver"); + let end = driver[start..] + .find("\n _ => {}") + .map(|at| start + at) + .expect("the arm ends"); + let arm = &driver[start..end]; + + assert!( + arm.contains("eprintln!"), + "the disclosure has to be on stderr: {arm}", + ); + assert!( + !arm.contains("println!") || !arm.replace("eprintln!", "").contains("println!"), + "the serve arm writes to stdout, which is the protocol from the moment \ + the loop starts: {arm}", + ); +} + #[test] fn f10_the_driver_writes_into_the_scope_the_lookup_found() { let driver = std::fs::read_to_string( From 2c9a5687b30efaaa4c30fe1136bd62c6feba3a97 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 20:23:32 +0530 Subject: [PATCH 17/27] test(docs): no page promises a streaming thought or a fallback of 24,000 Two claims this release could otherwise have shipped, swept out of every page that is not the changelog -- the changelog is a diary and has to be able to quote what used to be true. **A thought does not stream.** The roadmap entry for this release said reasoning would arrive in the working rows as it is written. It cannot: `EventKind:: Reasoning` carries the whole thought and arrives once, io-harness has no reasoning delta event, and `EventKind::Token` is assistant text and nothing else. What io-harness 0.80.0 fixed is the order -- the thought now arrives before the tokens it produced rather than after them -- which means the reorder 0.38.1 added is belt and braces rather than the fix. A page promising a thought appearing word by word would leave an operator watching `Pondering` and waiting for something that is never coming. **And the fallback ceiling is no longer one number.** io-harness 0.82.0 split it into one default for a remote endpoint and a much smaller one for a loopback endpoint, so a page still naming 24,000 states something that stopped being true under the pin this release moves -- on the one page an operator would check a surprising ceiling against. The sibling of the arm in tests/context.rs that holds the same line in the code. F7 is amended in the contract to say what is true: its behaviour shipped in 0.38.1 and is verified here, and what this release owes is the claim. --- tests/docs.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/docs.rs b/tests/docs.rs index 9949094..90d5b8d 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -2535,6 +2535,74 @@ fn f6_the_headless_guide_carves_out_the_provider_endpoint() { /// the page that exists to talk about money from doing so. /// /// Sabotage: write "withholding a tool makes the turn cheaper" into any guide page. +/// **N3 — no shipped page says a thought streams as it is written (0.39.0).** +/// +/// The roadmap entry for this release said reasoning would stream into the +/// working rows as it arrives. It cannot: `EventKind::Reasoning` carries the +/// whole thought and arrives once, and io-harness has no reasoning delta event — +/// `EventKind::Token` is assistant text and nothing else. What io-harness 0.80.0 +/// fixed is the *order*, so the thought now arrives before the tokens it +/// produced rather than after them, which is why `io` no longer has to reorder +/// them itself. +/// +/// So a page promising a thought that appears word by word would describe an +/// interface nobody can build against this dependency, and an operator watching +/// `Pondering · 13s` would be waiting for something that is never coming. +/// +/// Sabotage: write "watch it think, token by token" into the session guide. Only +/// this fails. +#[test] +fn n6_no_shipped_page_says_a_thought_streams_as_it_is_written() { + for (path, text) in shipped_prose() { + let lower = text.to_lowercase(); + for claim in [ + "thinking streams", + "reasoning streams", + "the thought streams", + "thought token by token", + "watch it think", + ] { + assert!( + !lower.contains(claim), + "{path} claims `{claim}`. A thought arrives whole and once — \ + io-harness has no reasoning delta event — so no page may promise \ + one appearing as it is written", + ); + } + } +} + +/// **N3 — no shipped page says the fallback ceiling is 24,000 (0.39.0).** +/// +/// It was one constant for every case through io-harness 0.81.0. Its 0.82.0 +/// splits it — far more for a remote endpoint, far less for a loopback one — so +/// a page still naming the old number is stating something that stopped being +/// true under the pin this release moves, on the one page an operator would +/// check a surprising ceiling against. +/// +/// The sibling of `f17_no_rung_sentence_names_a_number_the_pin_moved` in +/// `tests/context.rs`, which holds the same line in the code. +#[test] +fn n7_no_shipped_page_ties_the_fallback_ceiling_to_a_number() { + for (path, text) in shipped_prose() { + let lower = text.to_lowercase(); + for claim in [ + "fallback of 24,000", + "fallback is 24,000", + "falls back to 24,000", + "fallback ceiling of 24,000", + ] { + assert!( + !lower.contains(claim), + "{path} claims `{claim}`. io-harness 0.82.0 split that constant \ + into one default for a remote endpoint and a much smaller one \ + for a loopback endpoint, so no page may name a single number for \ + the rung", + ); + } + } +} + /// **N3 — no shipped page promises a span arrived (0.39.0).** /// /// io-harness reports export success or loss through no public value: a batch the From 82279dd948b951bebff76494cf3d9dc93efc0b9b Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 20:29:46 +0530 Subject: [PATCH 18/27] feat(context): the collapse lever is visible when it is in force `[run] collapse` shortens an observation too large to include whole rather than leaving it out. io-harness made it a configuration key in 0.81.0, `Config:: apply_to` applies it, and `contract::configured` has called that on both doors since 0.38.2 -- so it has been working for anyone who wrote the key and visible on no surface at all. That is the worst state a context lever can be in: an operator reading the page about what fills their window, with a setting silently reshaping it. **It needed no key of io-cli's own, which is what this release found.** The criterion assumed an `[app.io-cli]` key would be required. A second spelling there would have been a second thing to keep true and a second answer to a settled question -- the mistake `[app.io-cli] max_steps` was, and which 0.16.0 removed. So the work is to draw it, not to add it. `/context` now says when it is on and how much of an oversized entry survives, one row from the tool mask and **explicitly as a saving** -- because the mask is not one. A collapsed entry really does contribute fewer characters; a withheld tool costs the same catalogue plus a sentence naming what was withheld. Two levers one row apart meaning opposite things is exactly why each has to say which it is, and the guide draws the same contrast. Both cases are gated. The absent arm is the one that matters as much as the present one: a page about what a turn costs must not grow furniture for a lever nobody turned on. --- docs/guide/accounting.md | 25 ++++++++++++++++ src/context.rs | 25 ++++++++++++++++ tests/context.rs | 62 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/docs/guide/accounting.md b/docs/guide/accounting.md index 378846c..aa3b5fc 100644 --- a/docs/guide/accounting.md +++ b/docs/guide/accounting.md @@ -182,6 +182,31 @@ buys back the conversation, and you pay per step for it. `[run.context] max_tokens` is still there if you want the old ceiling back deliberately rather than by accident. +### Shortening what will not fit, instead of dropping it + +`[run] collapse` is the other lever on what a turn carries, and it is the one that +actually saves something: + +```toml +[run] +collapse = { keep_chars = 4000 } +``` + +With it set, an observation too large to include whole contributes its first few +thousand characters instead of being left out — the same truncation marker a +single oversized observation already gets, so you learn one convention rather than +two. A file read is never collapsed; that is io-harness's rule, not `io`'s. + +`/context` says so when it is on, and says how much survives. **Read that row +against the one above it**, which is the tool mask: they sit next to each other +and mean opposite things. Collapsing genuinely shortens what is sent. Withholding +a tool does not — the catalogue goes out identical either way and gains a sentence +naming what you withheld, which is why the mask is a scoping lever and not a +budget one. + +It has been reachable since io-harness 0.81.0 and `io` has applied it on both +doors since 0.38.2. Until 0.39.0 nothing showed you it was on. + --- [README](../../README.md) · [All guides](../CAPABILITIES.md) · [What you may depend on](../CONTRACT.md) diff --git a/src/context.rs b/src/context.rs index edf6912..0899b99 100644 --- a/src/context.rs +++ b/src/context.rs @@ -989,6 +989,31 @@ pub fn committed( push(line, Tone::Refused); } + // **Whether the second lever on per-turn context is in force (0.39.0).** + // + // `[run] collapse` shortens an observation that will not fit whole rather + // than dropping it, and it has been reachable from a configuration file since + // io-harness 0.81.0 — which this crate has pinned since 0.38.2, so it has + // been in force for anyone who wrote the key and visible on no surface at + // all. That is the worst state for a context lever: an operator reading a + // page about what fills their window, with a setting silently reshaping it. + // + // Drawn only when it is on, and unlike the mask it is drawn as a **saving** + // — because unlike the mask it is one. A collapsed entry contributes fewer + // characters than it would have; a withheld tool costs the same catalogue + // plus a sentence. The two levers sit one row apart and mean opposite things, + // which is exactly why this row says which it is. + if contract.collapse.enabled() { + push( + format!( + "collapse: on {dash} an observation that will not fit whole is shortened to \ + {} characters rather than dropped", + contract.collapse.keep_chars, + ), + Tone::Muted, + ); + } + lines.push(Line::from(Span::styled( format!("{rule}{rule}{rule} context ends"), theme.style(Tone::Accent), diff --git a/tests/context.rs b/tests/context.rs index 6bb3c32..7d2c9d7 100644 --- a/tests/context.rs +++ b/tests/context.rs @@ -1260,6 +1260,68 @@ fn f17_the_context_page_draws_the_rung_when_the_run_announced_one() { ); } +/// **F11 — the second lever on per-turn context is visible when it is in force.** +/// +/// `[run] collapse` shortens an observation that will not fit whole rather than +/// dropping it. It has been reachable from a configuration file since io-harness +/// 0.81.0 — which this crate has pinned since 0.38.2 — and `contract::configured` +/// applies it through `Config::apply_to` on both doors, so it has been in force +/// for anyone who wrote the key and visible on no surface at all. That is the +/// worst state for a context lever: an operator reading the page about what fills +/// their window, with a setting silently reshaping it. +/// +/// **Drawn as a saving, unlike the mask one row above it.** A collapsed entry +/// really does contribute fewer characters; a withheld tool costs the same +/// catalogue plus a sentence. The two levers sit next to each other and mean +/// opposite things, which is why each says which it is. +/// +/// Sabotage: draw the row unconditionally. The absent arm fails, which is the one +/// that keeps a page about what a turn costs from growing furniture. +#[test] +fn f11_the_context_page_says_when_collapse_is_shortening_entries() { + let with = drawn(&context::committed( + Some(&Request::of(&request())), + &contract().with_collapse(io_harness::context::Collapse { keep_chars: 4_000 }), + None, + context::Ceiling::default(), + &ToolMask::none(), + &ascii(), + 80, + )); + assert!( + with.iter().any(|row| row.contains("collapse")), + "a lever in force and no row for it: {with:#?}", + ); + // Joined, because the page wraps at its width and this sentence is longer + // than eighty columns — asserting against one row would be asserting about + // where the fold lands. + let page = with.join(" "); + assert!( + page.contains("4000") || page.contains("4,000"), + "the row has to say how much survives, or it reports a switch rather \ + than a setting: {page}", + ); + assert!( + page.contains("shortened") && page.contains("rather than dropped"), + "and what it does, because the lever one row above it withholds and \ + saves nothing: {page}", + ); + + let without = drawn(&context::committed( + Some(&Request::of(&request())), + &contract(), + None, + context::Ceiling::default(), + &ToolMask::none(), + &ascii(), + 80, + )); + assert!( + !without.iter().any(|row| row.contains("collapse")), + "a lever nobody turned on drew a row: {without:#?}", + ); +} + /// **F17 — `fallback` no longer means 24,000, and no sentence here says it does.** /// /// Through io-harness 0.81.0 the fallback was one constant. 0.82.0 splits it into From 3c5bc4f02618df8ffefed7e3c97c355256914cf7 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 20:38:37 +0530 Subject: [PATCH 19/27] feat(contain): /contain on offers a fan-out instead of naming four keys `/contain on` with no `[app.io-cli.containment]` named `max_total_agents`, `max_concurrent_agents`, `max_depth` and `max_total_tokens` and stopped. Technically correct, and it asked an operator to choose a token ceiling for a mode they had not tried, out of a documentation page they were not reading. It now offers one: four agents, two at once, one deep, and an aggregate token ceiling. Small on purpose -- enough for a fan-out to be worth having, small enough that a mistake is legible -- and every number is spelled out on the row that acts rather than summarised, because a row reading "write a default" would be asking somebody to agree to figures they were never shown. Row 0 declines, like every other confirmation here, asserted by index. **The token ceiling is the number that has to exist for the offer to be honest.** A fan-out without an aggregate ceiling is the one shape of this feature that can spend without a bound anybody chose. The two io-harness leaves optional stay unset, for different reasons: a cost ceiling is documented as reserved and not enforced, so a number there would be one that does nothing; a duration is a real ceiling and is a property of the work rather than something io can guess. Written as one inline table, which is also what an operator opening the file afterwards wants to read, into user scope -- `[app.io-cli]` is this crate's own section and a fan-out is a property of how somebody works rather than of the repository they are in. `configure::write` round-trips through `Config::discover` and undoes the write if io-harness refuses it, so a section composed wrongly here cannot leave a session unable to start. Accepted, it is in force for the next turn rather than the next session: an operator who typed `/contain on` meant now. `/contain off` and bare `/contain` still write nothing. The structure gate that counts every configuration write in the driver caught the new one immediately and now names it as the thirteenth confirmed door under `Outcome::Chosen`. --- src/main.rs | 81 +++++++++++++++++++++++++++++++++++++++-- src/settings.rs | 78 ++++++++++++++++++++++++++++++++++++++++ tests/contain.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++++ tests/structure.rs | 30 ++++++++++------ 4 files changed, 264 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index 63bad2d..2594cce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1032,7 +1032,10 @@ async fn loop_over Result>( templates: Templates, theme: Theme, plain: bool, - containment: Option, + // Mutable since 0.39.0: `/contain on` with nothing configured offers to write + // a section, and an operator who accepts meant the next turn rather than the + // next session. + mut containment: Option, // Mutable for the reason `config` is, and it is the half a reload forgets: // this is derived from `config` ONCE at startup, so refreshing only the // `Config` would leave every `[app.io-cli]` answer stale while the rest of @@ -4325,6 +4328,56 @@ async fn loop_over Result>( undo_whole_turn(&mut app, screen, &mut session, &store, &seen)?; } } + // **One inline table, not four dotted writes.** `Edit::set` + // takes TOML source, so the whole section lands as one + // value — which is also what an operator opening the file + // afterwards will want to read, rather than four lines + // that have to be assembled in the head. + // + // User scope: `[app.io-cli]` is this crate's own section, + // and a fan-out is a property of how this operator works + // rather than of the repository they are in. + // + // `configure::write` round-trips through + // `Config::discover` and undoes the write if io-harness + // refuses it, so a section this crate composed wrongly + // cannot leave a session unable to start. + Pick::ContainDefault(caps) => { + if io_cli::store::acts(index) { + let inline = format!( + "{{ max_total_agents = {}, max_concurrent_agents = {}, \ + max_depth = {}, max_total_tokens = {} }}", + caps.max_total_agents, + caps.max_concurrent_agents, + caps.max_depth, + caps.max_total_tokens, + ); + let edit = + io_cli::edit::Edit::set("app.io-cli.containment", inline); + match io_cli::configure::write( + session.root(), + io_harness::config::Scope::User, + &[edit], + ) { + Ok(()) => { + // In force now rather than at the next + // start. An operator who typed + // `/contain on` and was asked a question + // meant the turn after it, not the + // session after that. + containment = Some(caps.clone()); + contained = true; + let notice = + settings::contained_notice(caps, app.theme.glyphs.dash); + app.record(Tone::Muted, notice); + } + Err(error) => app.record( + Tone::Error, + format!("the section was not written: {error}"), + ), + } + } + } Pick::Export { path, content } => { if io_cli::store::acts(index) { let effective = approval::session_policy( @@ -6181,11 +6234,26 @@ async fn loop_over Result>( // with the key that closes it, rather than as a refusal — // the caps are what the fan-out runs under and there is no // safe default for somebody else's token ceiling. + // **`/contain on` with nothing configured offers to write one + // (0.39.0).** This arm named four keys and stopped — + // technically correct, and it asked an operator to choose a + // token ceiling for a mode they had not tried, out of a + // documentation page they were not reading. The offer is a + // starting point rather than a homework assignment, and every + // number on it is visible on the row that acts. + (None, Some(true)) => { + let caps = settings::offered_containment(); + let (title, rows) = settings::containment_offer(&caps); + picker = Some((Picker::new(title, rows), Pick::ContainDefault(caps))); + } + // Switching *off* something that is not on, and asking what is + // in force when nothing is. Neither is a moment to offer a + // configuration change: the first is already true and the + // second is a question. (None, _) => app.record( Tone::Muted, "no [app.io-cli.containment] in the configuration, so a turn here \ - cannot fan out. Set max_total_agents, max_concurrent_agents, \ - max_depth and max_total_tokens to turn it on.", + cannot fan out. `/contain on` offers to write one.", ), (Some(caps), None) => { let where_it_is = if contained { @@ -9642,6 +9710,13 @@ enum Pick { /// reads the head itself, and a run id carried from before the confirmation /// could name a turn another `io` has since moved off the head. UndoRun, + /// A confirmation over writing a first `[app.io-cli.containment]` (0.39.0). + /// + /// Carries the caps it will write, so the section that lands is the one whose + /// four numbers were on the row the operator chose — the same rule the export + /// confirmation below follows, and for the same reason: a value rebuilt on + /// acceptance is a value nobody agreed to. + ContainDefault(io_harness::Containment), /// A confirmation over one export, carrying the bytes it will write. /// /// The content is built before the confirmation and carried rather than diff --git a/src/settings.rs b/src/settings.rs index 9845861..f87d1e3 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -333,6 +333,84 @@ pub const REFERENCE_CATALOGUE_KEY: &str = "app.io-cli.reference_catalogue"; /// 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. +/// The caps `/contain on` offers to write when nothing is configured (0.39.0). +/// +/// **Small on purpose, and every number is defensible out loud.** Until this +/// release `/contain on` with no `[app.io-cli.containment]` named four keys and +/// stopped — technically correct, and it asked an operator to pick a token +/// ceiling for a mode they had not tried, out of a documentation page they were +/// not reading. The four keys are still what the section holds; what changed is +/// that io offers a starting point rather than a homework assignment. +/// +/// - **Four agents in the tree.** A root and three children. Enough for a +/// fan-out to be worth having and small enough that a mistake is legible. +/// - **Two at once.** The number that makes it a fan-out rather than a queue, +/// and the one that keeps a runaway visible on `/fleet` rather than +/// overwhelming it. This throttles rather than refuses, so a third child waits +/// instead of failing. +/// - **One deep.** Children, and no grandchildren. Depth is where a tree stops +/// being something an operator can hold in their head, and every tier +/// multiplies how many agents may be working at once. +/// - **A token ceiling the whole tree draws down together.** This is the number +/// that has to exist for the offer to be honest — a fan-out with no aggregate +/// ceiling is the one shape of this feature that can spend without a bound +/// anybody chose. +/// +/// **Not a default.** Nothing applies these unless the operator says yes, and the +/// absent section still means the fan-out is off — which is what keeps a session +/// that never typed `/contain` on exactly the turn it was before. +#[must_use] +pub fn offered_containment() -> io_harness::Containment { + io_harness::Containment { + max_total_agents: 4, + max_concurrent_agents: 2, + max_depth: 1, + max_total_tokens: 200_000, + // Both left unset, and for different reasons. A cost ceiling is + // documented by io-harness as **reserved and not enforced** — it has no + // price telemetry, so any figure it compared against would be one it + // invented — and offering an operator a number that does nothing is worse + // than offering none. A duration is a real ceiling and is not io's to + // guess: how long a fan-out may take is a property of the work, and the + // token ceiling above is the bound that stops a runaway. + max_total_cost: None, + max_total_duration: None, + } +} + +/// The confirmation `/contain on` raises when nothing is configured (0.39.0). +/// +/// Row 0 declines, like every other confirmation in this product, and +/// `tests/contain.rs` asserts that by index rather than by reading the words. +/// +/// **The caps are spelled out in the row that acts**, not summarised. This writes +/// to the operator's configuration file and turns on a mode that spends tokens on +/// their behalf across a tree of agents; "write a default" as the label would be +/// asking them to agree to a number they were never shown. +#[must_use] +pub fn containment_offer(caps: &io_harness::Containment) -> (String, Vec) { + ( + "Nothing here configures a fan-out. Write one?".to_string(), + vec![ + crate::picker::Row::with_detail( + crate::store::LEAVE_IT, + "this turn goes on doing the work itself", + ), + crate::picker::Row::with_detail( + format!( + "write [app.io-cli.containment] — {} agents, {} at once, {} deep, {} tokens", + caps.max_total_agents, + caps.max_concurrent_agents, + caps.max_depth, + caps.max_total_tokens, + ), + "into your own configuration file; `/config` edits it afterwards and \ + `/contain off` switches it back without removing it", + ), + ], + ) +} + pub fn contained_notice(caps: &io_harness::Containment, dash: &str) -> String { format!( "contained {dash} up to {} agents, {} at once per tier, {} deep, {} tokens for the \ diff --git a/tests/contain.rs b/tests/contain.rs index 4b78383..11241dd 100644 --- a/tests/contain.rs +++ b/tests/contain.rs @@ -240,6 +240,95 @@ fn f7_the_interrupt_says_where_the_turn_will_stop() { } /// F2 — the mode is a fact about the turn, and it does not outlive it. +/// **F10 — `/contain on` with nothing configured offers a fan-out rather than +/// four key names.** +/// +/// The arm used to name `max_total_agents`, `max_concurrent_agents`, `max_depth` +/// and `max_total_tokens` and stop. That is technically correct and it asks an +/// operator to choose a token ceiling for a mode they have not tried, out of a +/// documentation page they are not reading. +/// +/// **Every number is on the row that acts**, spelled out rather than summarised. +/// This writes to their configuration file and turns on a mode that spends tokens +/// across a tree of agents; a row reading "write a default" would be asking them +/// to agree to figures they were never shown. +#[test] +fn f10_the_offer_shows_every_number_it_would_write() { + let caps = io_cli::settings::offered_containment(); + let (title, rows) = io_cli::settings::containment_offer(&caps); + + assert!( + title.contains("fan-out"), + "the question has to name what is being turned on: {title}", + ); + assert_eq!( + rows[0].label, + io_cli::store::LEAVE_IT, + "every confirmation in this product declines at row 0", + ); + assert!(!io_cli::store::acts(0)); + assert!(io_cli::store::acts(1)); + + let acting = &rows[1].label; + for number in [ + caps.max_total_agents.to_string(), + caps.max_concurrent_agents.to_string(), + caps.max_depth.to_string(), + caps.max_total_tokens.to_string(), + ] { + assert!( + acting.contains(&number), + "the row that writes the section does not show `{number}`, so the \ + operator is agreeing to a figure they were never shown: {acting}", + ); + } +} + +/// **F10 — the offered caps are small, and the one that must exist does.** +/// +/// A fan-out with no aggregate token ceiling is the one shape of this feature +/// that can spend without a bound anybody chose, so an offer without one would be +/// worse than no offer. The rest are small on purpose: enough for a fan-out to be +/// worth having, small enough that a mistake is legible. +/// +/// The two io-harness leaves optional stay unset, and for different reasons — a +/// cost ceiling is documented as reserved and not enforced, so offering a number +/// that does nothing is worse than offering none; a duration is a real ceiling and +/// is a property of the work rather than something `io` can guess. +#[test] +fn f10_the_offered_caps_bound_the_spend_and_nothing_it_cannot_know() { + let caps = io_cli::settings::offered_containment(); + + assert!( + caps.max_total_tokens > 0, + "a fan-out with no aggregate token ceiling can spend without a bound \ + anybody chose, and this is the offer that would have set it", + ); + assert!(caps.max_concurrent_agents >= 2, "or it is not a fan-out"); + assert!( + caps.max_concurrent_agents <= caps.max_total_agents, + "more at once than may exist at all is not a configuration, it is a typo", + ); + assert!( + caps.max_depth >= 1, + "children are the whole point; zero depth is the mode switched off", + ); + assert!( + caps.max_depth <= 2, + "depth is where a tree stops being something an operator can hold in \ + their head, and every tier multiplies how many may be working at once", + ); + assert!( + caps.max_total_cost.is_none(), + "io-harness documents the cost ceiling as reserved and not enforced, so \ + a number here would be one that does nothing", + ); + assert!( + caps.max_total_duration.is_none(), + "how long a fan-out may take is a property of the work", + ); +} + #[test] fn f2_the_contained_flag_is_cleared_with_the_turn() { let mut app = App::new(DARK, "a-model"); diff --git a/tests/structure.rs b/tests/structure.rs index 498c522..2893e6e 100644 --- a/tests/structure.rs +++ b/tests/structure.rs @@ -279,16 +279,24 @@ fn f6_what_nobody_said_is_delivered_on_every_arm_and_not_only_the_failing_one() /// number is the assertion, and a release adding a write has to come here and say /// which door it belongs to. /// -/// **Where the nineteen are, so the number is a claim and not a constant.** One in -/// `import_written`, the offer's own confirmation. Sixteen in `loop_over`: twelve -/// under the picker's single `Outcome::Chosen` arm — Enter on a row, which is what -/// a confirmation *is* in this product — and four under an `Action::` arm of a -/// typed command (`Action::Manage`, two `Action::Profile` verbs and -/// `Action::Config` with a value), where the Enter that submitted the line is the -/// consent. One in `refresh_prices`, the act `prices.as_of`'s descent offers, one -/// keystroke below the row it writes. One in `manage_main`, the headless -/// `io config|mcp|plugin` door, which has no keyboard at all. Nineteen sites, no -/// tenth surface, and none of them on a bare arrow. +/// **Where the twenty are, so the number is a claim and not a constant.** One in +/// `import_written`, the offer's own confirmation. Seventeen in `loop_over`: +/// thirteen under the picker's single `Outcome::Chosen` arm — Enter on a row, +/// which is what a confirmation *is* in this product — and four under an +/// `Action::` arm of a typed command (`Action::Manage`, two `Action::Profile` +/// verbs and `Action::Config` with a value), where the Enter that submitted the +/// line is the consent. One in `refresh_prices`, the act `prices.as_of`'s descent +/// offers, one keystroke below the row it writes. One in `manage_main`, the +/// headless `io config|mcp|plugin` door, which has no keyboard at all. Twenty +/// sites, no tenth surface, and none of them on a bare arrow. +/// +/// **The twentieth is 0.39.0's `Pick::ContainDefault`**, and it is a thirteenth +/// under `Outcome::Chosen` rather than a new shape: `/contain on` with no +/// `[app.io-cli.containment]` raises a confirmation that shows every number it +/// would write, and `store::acts(index)` gates the write exactly as the twelve +/// beside it do. It is worth naming here because it is the first write this +/// product makes in answer to a command whose other two spellings — `/contain +/// off` and bare `/contain` — write nothing at all. /// /// **The arrow arm is read as a region, not searched for as a word**, because a /// count alone cannot see a write moved *into* the arm from somewhere else. The @@ -351,7 +359,7 @@ fn f14_no_arrow_key_writes_a_configuration_file() { assert_eq!( text.matches("io_cli::configure::write(").count(), - 19, + 20, "the driver's configuration writes moved. Each one is a confirmed door — see \ this test's own documentation for the nineteen and what confirms them — so a \ new one is either a door that needs naming there or a write on a keystroke", From b424b6382fdbaa4c53203f7f3be2c58bb9d87197 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 21:03:34 +0530 Subject: [PATCH 20/27] feat(setup): /setup runs the wizard in the session The arm said "run `io setup` from the shell to change the configuration" -- a command telling somebody to leave the program in order to change the program. It now calls the same `wizard` function `io setup` calls, and that is the whole implementation. That function already owns a screen and an input stream, drives its own loop, and handles the credential check, the catalogue read and the write; the session loop has both of those to lend it. A second driver would be a second wizard to keep in step, and the one that drifted would be the one nobody runs from a fresh install. **What is not live is said, rather than left to be discovered.** The session runs inside the closure that built the provider chain, and that closure takes a model name and nothing else -- so a changed provider or credential cannot reach the session that is already running. The theme applies immediately, everything the per-turn configuration reload carries arrives with the next message, and a new provider or key needs `io` restarted. An operator who has just retyped an API key and watches the next turn fail with the old one has been told nothing by a wizard that said it was done. Gated by a pty arm, which is the only instrument that can see it: this is entirely driver wiring in `src/main.rs`, which nothing under `tests/` links. The arm opens the wizard, backs out with `Esc`, and then types into the session -- because a wizard that took the screen and did not give it back would be worse than the sentence it replaced. The arm also documents a standing behaviour it had to work with: `/setup` matches a palette row, so the first Enter chooses it into the prompt and the second submits. That is the palette's browse path and not something this release changed; F5's one-Enter case is a line matching no row, which the arm below it drives. --- src/main.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++---- tests/pty.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2594cce..a480ce0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4751,11 +4751,51 @@ async fn loop_over Result>( "/commit is rewritten into a submit before the match and cannot arrive here" ), Action::Quit => return Ok(()), + // **`/setup` runs the wizard here rather than sending the + // operator away (0.39.0).** This arm said "run `io setup` from + // the shell", which is a command telling somebody to leave the + // program to change the program. + // + // **The same `wizard` function `io setup` calls, and that is the + // whole implementation.** It already owns a screen and an input + // stream, drives its own loop, and handles the credential check, + // the catalogue read and the write; this loop has both of those + // to lend it. A second driver would be a second wizard to keep in + // step, and the one that drifted would be the one nobody runs + // from a fresh install. Action::Setup => { - app.say( - Tone::Muted, - "run `io setup` from the shell to change the configuration", - ); + let chosen = wizard(screen, inputs, app.theme).await?; + match chosen { + Some(theme) => { + // Live now: the theme is this process's, and the file + // is re-discovered at the next turn boundary by + // `reload::Configuration`, so everything it carries + // arrives with the next message. + app.theme = theme; + app.events.set_theme(theme); + // **And what is not live, said rather than left to be + // discovered.** The session runs inside the closure + // that built the provider chain, and that closure + // takes a model name and nothing else — so a changed + // provider or credential cannot reach this session. + // An operator who has just retyped an API key and + // watches the next turn fail with the old one has + // been told nothing by a wizard that said it was + // done. + app.record( + Tone::Muted, + "configuration written. The theme is live and the rest arrives \ + with your next message — a changed provider or key needs `io` \ + restarted, because this session is running inside the one it \ + started with.", + ); + } + None => app.say(Tone::Muted, "setup left the configuration alone"), + } + // The wizard drew over the viewport and this puts the session + // back, exactly as every other surface that takes the screen + // does on the way out. + paint(screen, &mut app)?; } // **The same parse, the same plan, the same write as `io mcp …`.** // Nothing is decided here: the tokens, the refusals and the scope diff --git a/tests/pty.rs b/tests/pty.rs index 61f6f14..3c16f13 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -417,6 +417,45 @@ mod unix { io.wait_for("no turn to undo"); } + /// **F10 — `/setup` runs the wizard here, rather than sending the operator + /// away.** + /// + /// The arm said "run `io setup` from the shell to change the configuration", + /// which is a command telling somebody to leave the program in order to + /// change the program. It now calls the same `wizard` function `io setup` + /// calls — one driver, so the two cannot drift apart, and the one that + /// drifted would have been the one nobody runs from a fresh install. + /// + /// This is entirely driver wiring in `src/main.rs`, which nothing under + /// `tests/` links, so a pty arm is the only instrument that can see it. `Esc` + /// backs out, and the session has to still be there afterwards — a wizard + /// that took the screen and did not give it back would be worse than the + /// sentence it replaced. + #[test] + fn f10_setup_opens_the_wizard_in_the_session_and_gives_the_screen_back() { + let mut io = Session::start(24, 100); + io.wait_for(">"); + + // Two `Enter`s, and that is the palette's standing behaviour rather than + // something this release changed: `/setup` matches a palette row, so the + // first `Enter` chooses it into the prompt — where an operator may still + // add an argument — and the second submits. F5's one-`Enter` case is a + // line that matches *no* row, which is the arm below this one. + io.type_in("/setup\r"); + io.wait_for("/setup"); + io.type_in("\r"); + // The wizard's own welcome, which is what `io setup` draws first — the + // point being that it is *this* wizard and not a second one written for + // the session. + io.wait_for("Four questions and you have a working agent"); + + // Backed out, changing nothing — and the session is still running. + io.type_in("\u{1b}"); + io.wait_for("left the configuration alone"); + io.type_in("still here"); + io.wait_for("still here"); + } + /// **F8 — `io exec` without `--json` narrates, and stdout stays clean.** /// /// The source gate in `tests/exec.rs` holds `Narrating` to never naming From b4e088c7168b06fae352c4e952e2c55190e330be Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 21:14:32 +0530 Subject: [PATCH 21/27] feat(acp): the editor door holds several sessions and reopens an old one The two limitations 0.36.0 shipped named and 0.38.0 left standing. **Several sessions.** 0.36.0 held one and refused a second, which was the honest thing to do rather than multiplex badly. An editor with two files open wanting two conversations is the ordinary case, so `session/new` now opens as many as a client asks for, keyed on the wire id it issues. `Session::open` writes a new row per call, so each is its own conversation in the store -- the same thing two terminals in one repository are. A prompt now names its session, and one naming a session this agent does not hold is refused. That parameter could be ignored while there was one; with a map, a prompt naming nothing would go to whichever conversation happened to be first, which is the multiplexing-badly failure arriving by the back door. **`session/load`.** It was declared unsupported because the correspondence between an ACP session id and a stored conversation "had to be designed rather than guessed". The design turned out to be the one the id already carried: this adapter issues `io-`, so loading is reading that number back and reopening it. Its absence was the sharpest thing missing from an editor integration -- an operator who closed their editor yesterday had no way back in, while `io resume` in a terminal had one all along. The history is replayed as ordinary `session/update` notifications in the shape a live turn already sends, so a client renders a loaded conversation with the code that renders a running one. A bespoke transcript in the result would be a second rendering path, and it would be the one that rots, because nobody looks at it until they reopen an old session. It walks `Session::history` and never `Store::session_turns` -- the second is the whole tree rather than one path through it, and a rewind moves the head without deleting the turn, so replaying the tree would show a client work that was taken back. `src/export.rs` makes the same choice for the same reason. A turn with no reply contributes only its prompt: an empty agent chunk would draw the model as having answered with silence rather than as not having answered. Gated by driving the real `io acp` over pipes -- the session map is private and needs a provider to construct, and none of these frames needs a completion. The load arm runs two processes over one home, because a load that only worked inside one process is the half that needs no persistence and proves the least. --- docs/guide/editors.md | 25 +++- src/acp.rs | 200 +++++++++++++++++++++++++----- tests/acp.rs | 275 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 460 insertions(+), 40 deletions(-) diff --git a/docs/guide/editors.md b/docs/guide/editors.md index 3326062..4c52f0a 100644 --- a/docs/guide/editors.md +++ b/docs/guide/editors.md @@ -99,12 +99,25 @@ disk. Save before you ask about a file. **A terminal the client owns.** Same reason — io runs programs inside its own sandbox, and handing a spawn to the editor would put the run outside the boundary io exists to show you. -**More than one session per process.** A second `session/new` is refused with a sentence. Run a -second `io acp` for a second conversation. - -**Loading an earlier conversation.** `session/load` is not served and `loadSession` is declared -unsupported, so a conforming client will not offer it. Runs are still in the store and `io resume` -reaches them. +Two limitations that stood here until 0.39.0 are gone, and are worth naming because a client you +upgraded from an older `io` may still be behaving as though they were there: + +**Several conversations at once.** `session/new` opens as many as you ask for, each its own +conversation in the store — the same thing two terminals in one repository are. A prompt names the +session it is for, and one naming a session this agent does not hold is refused rather than +delivered to whichever happened to be first. + +**Reopening yesterday's conversation.** `session/load` is served and `loadSession` is declared, so a +conforming client will offer it. The session id `io` issues carries the stored session's own number, +so loading is reopening that conversation; its turns are replayed to you as the same +`session/update` notifications a live turn sends, which is why your client renders a loaded +conversation with the code it already renders a running one with. + +What is replayed is what was **said** — your prompts and the agent's replies, along the path the +conversation actually took. A turn you rewound is not replayed: undoing moves the head without +deleting the turn, so the stored tree still contains it, and showing it back to you would be +presenting work that was taken back. Tool calls and reasoning are on the durable trace rather than +in the replay. ## When something looks wrong diff --git a/src/acp.rs b/src/acp.rs index 2e726ae..e8dc816 100644 --- a/src/acp.rs +++ b/src/acp.rs @@ -82,6 +82,9 @@ pub const PROTOCOL_VERSION: u32 = 1; pub const SERVED: &[&str] = &[ "initialize", "session/new", + // 0.39.0. Gated by `loadSession`, which is now declared — see [`GATED`], + // whose whole job is that these two lists cannot disagree. + "session/load", "session/prompt", "session/cancel", ]; @@ -469,11 +472,18 @@ pub fn encode(frame: &Outgoing) -> String { /// decline a capability is to say `false` or say nothing, which is what the two /// excluded families do. /// -/// `loadSession` is `false` because `session/load` is not served. io has `io -/// resume` and the mapping is plausible, but the correspondence between an ACP -/// session id and a stored run has to be designed rather than guessed, and -/// declaring a capability whose method returns "method not found" is the defect -/// this module's own [`SERVED`]/[`GATED`] agreement exists to prevent. +/// **`loadSession` is `true` as of 0.39.0.** It was `false` because the +/// correspondence between an ACP session id and a stored conversation "has to be +/// designed rather than guessed" — and the design turned out to be the one the id +/// already carried: this adapter issues `io-`, so loading is reading +/// the number back out and reopening that session. What a client gets for it is +/// the whole point of an editor integration, and its absence was the sharpest +/// thing missing: an operator who closed their editor yesterday had no way back +/// into the conversation, while `io resume` in a terminal had one all along. +/// +/// The [`SERVED`]/[`GATED`] agreement is what keeps this honest in both +/// directions — a capability declared here with no method behind it fails, and so +/// does a method served without its capability. /// /// The filesystem and terminal families are the client's to implement, not the /// agent's, and this adapter never calls them — io-harness owns the disk inside @@ -502,7 +512,7 @@ pub struct PromptCapabilities { impl Default for AgentCapabilities { fn default() -> Self { Self { - load_session: false, + load_session: true, prompt_capabilities: PromptCapabilities { image: false, audio: false, @@ -981,7 +991,7 @@ impl crate::provider::WithProvider for Editor { type Out = Result; async fn call( - mut self, + self, make: impl Fn(&str) -> Result, model: String, ) -> Self::Out { @@ -993,12 +1003,17 @@ impl crate::provider::WithProvider for Editor { let correlator = std::sync::Arc::new(Correlator::new()); let mut handler = Editing { store: &self.store, - session: &mut self.session, config: &self.config, policy: &self.policy, provider: &provider, outbound, - session_id: None, + // **Empty, and the session handed in is not put in it.** `Editor` + // opened one before the provider was built, the way every other door + // does, and a client that sends `session/new` gets a fresh one + // anyway — so seeding the map would leave a conversation in the store + // that no client ever addressed. The root is what is kept. + sessions: std::collections::BTreeMap::new(), + root: self.session.root().to_path_buf(), cancel: None, correlator: std::sync::Arc::clone(&correlator), }; @@ -1014,14 +1029,27 @@ impl crate::provider::WithProvider for Editor { /// The live session's state, between frames. struct Editing<'a, P: io_harness::Provider> { store: &'a io_harness::Store, - session: &'a mut io_harness::Session, config: &'a io_harness::Config, policy: &'a io_harness::Policy, provider: &'a P, outbound: tokio::sync::mpsc::UnboundedSender, - /// The one session this adapter holds. ACP permits an agent to hold several; - /// 0.36.0 holds one and refuses a second rather than multiplexing badly. - session_id: Option, + /// Every session this adapter holds, by the id the client addresses it with + /// (0.39.0). + /// + /// **0.36.0 held one and refused a second**, which is the honest thing to do + /// rather than multiplex badly and was named as a limitation in its own + /// release notes. ACP permits several, and an editor with two files open + /// wanting two conversations is the ordinary case rather than an exotic one. + /// + /// A map keyed on the wire id, not a `Vec` and not a counter: the client + /// chooses which session a prompt is for by naming it, so the lookup is the + /// whole mechanism. `Session::open` creates a new row every call, so two + /// sessions here are two conversations in the store — which is the same thing + /// two terminals in one repository are, and why the session lock is keyed on + /// the session rather than the workspace. + sessions: std::collections::BTreeMap, + /// The workspace every session in the map is opened against. + root: std::path::PathBuf, cancel: Option>, /// Where an approval this session raises will be answered. Handed to each /// turn's [`Consulting`], and shared with the read task inside @@ -1029,6 +1057,37 @@ struct Editing<'a, P: io_harness::Provider> { correlator: std::sync::Arc, } +/// One stored turn as the `session/update` payloads a client already renders. +/// +/// **The same two shapes a live turn sends**, `user_message_chunk` for what the +/// operator said and `agent_message_chunk` for what came back, so a loaded +/// conversation goes through the client's existing rendering rather than a second +/// path built for replay. A bespoke transcript in the result would be that second +/// path, and it would be the one that rots — nobody looks at it until they reopen +/// an old session, which is exactly when being wrong costs the most. +/// +/// **A turn with no reply contributes only its prompt.** `Turn::reply` is `None` +/// while a turn is still running and for one that stopped without a closing +/// message, and an empty agent chunk would draw the model as having answered with +/// silence rather than as not having answered. +/// +/// The tool calls, the reasoning and the steps are not replayed. They are on the +/// durable trace and not on a `Turn`, and a client asking for a conversation is +/// asking what was said. +pub fn replayed(turn: &io_harness::Turn) -> Vec { + let mut out = vec![json!({ + "sessionUpdate": "user_message_chunk", + "content": { "type": "text", "text": turn.prompt }, + })]; + if let Some(reply) = &turn.reply { + out.push(json!({ + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": reply }, + })); + } + out +} + impl Handler for Editing<'_, P> { async fn handle(&mut self, incoming: Incoming) -> Vec { match incoming { @@ -1060,26 +1119,93 @@ impl Editing<'_, P> { ) -> Result { match method { "initialize" => Ok(initialize_result(env!("CARGO_PKG_VERSION"))), + // **A new conversation, every time, and as many as the client wants + // (0.39.0).** `Session::open` writes a new row per call, so each of + // these is its own conversation in the store — the same thing two + // terminals in one repository are. "session/new" => { - if self.session_id.is_some() { - return Err(( - ErrorCode::InvalidRequest, - "this agent holds one session per process; start another `io acp` \ - for a second" - .into(), - )); - } - let id = format!("io-{}", self.session.id()); - self.session_id = Some(id.clone()); + let opened = io_harness::Session::open(self.store, &self.root) + .map_err(|error| (ErrorCode::Internal, error.to_string()))?; + let id = format!("io-{}", opened.id()); + self.sessions.insert(id.clone(), opened); Ok(json!({ "sessionId": id })) } + // **`session/load` — a conversation from a previous process (0.39.0).** + // + // 0.36.0 declared `loadSession: false` and 0.38.0 named it a + // limitation. What it costs a client is the whole point of an editor + // integration: an operator who closed their editor yesterday and + // reopened it today had no way back into the conversation, while + // `io resume` in a terminal had one all along. + // + // **The history is replayed as ordinary `session/update` + // notifications**, in the shape a live turn already sends, so a client + // renders a loaded conversation with the code that renders a running + // one. The alternative — a bespoke result carrying the transcript — + // would be a second rendering path for the same content, and the one + // that rotted would be the one nobody looks at until they reopen an + // old session. + // + // **`Session::history` and never `Store::session_turns`**, which is + // the whole tree rather than one path through it: a rewind moves the + // head without deleting the turn, so the tree contains undone turns + // and replaying them would show a client work that was taken back. + // `src/export.rs` makes the same choice for the same reason. + "session/load" => { + let id = params + .get("sessionId") + .and_then(Value::as_str) + .ok_or(( + ErrorCode::InvalidParams, + "`session/load` names the session to load".to_string(), + ))? + .to_string(); + let numeric = id + .strip_prefix("io-") + .and_then(|rest| rest.parse::().ok()) + .ok_or(( + ErrorCode::InvalidParams, + format!("`{id}` is not a session this agent issued"), + ))?; + let opened = io_harness::Session::reopen(self.store, numeric) + .map_err(|error| (ErrorCode::InvalidParams, error.to_string()))?; + let turns = opened + .history(self.store) + .map_err(|error| (ErrorCode::Internal, error.to_string()))?; + for turn in &turns { + for update in replayed(turn) { + let _ = self.outbound.send(Outgoing::Notification { + method: "session/update".into(), + params: json!({ "sessionId": id, "update": update }), + }); + } + } + self.sessions.insert(id, opened); + Ok(json!({})) + } "session/prompt" => { - let Some(session_id) = self.session_id.clone() else { + // **The client names which session, and this stopped being + // rhetorical when a second one became possible.** Through 0.38.2 + // there was one and the parameter could be ignored; now a prompt + // that named nothing would go to whichever conversation happened + // to be first in a map. + let session_id = params + .get("sessionId") + .and_then(Value::as_str) + .ok_or(( + ErrorCode::InvalidParams, + "a prompt names the session it is for".to_string(), + ))? + .to_string(); + if !self.sessions.contains_key(&session_id) { return Err(( ErrorCode::InvalidRequest, - "no session; send `session/new` first".into(), + format!( + "`{session_id}` is not a session this agent holds; \ + `session/new` opens one and `session/load` reopens one" + ), )); - }; + } let goal = prompt_text(params).ok_or(( ErrorCode::InvalidParams, "a prompt carries at least one text content block".to_string(), @@ -1110,9 +1236,17 @@ impl Editing<'_, P> { let (reporter, shared) = Reporter::new(session_id, self.outbound.clone()); self.cancel = Some(std::sync::Arc::clone(&shared.cancelled)); - let contract = crate::exec::contract(self.config, self.session, goal, None); - let outcome = self - .session + // **The session the client named, and the caller has already checked it + // is here.** Taken out of the map and put back afterwards rather than + // borrowed across the turn: `turn_bounded_observed` takes `&mut Session` + // for the whole turn, and `self` is borrowed mutably by every other field + // this function reads. + let Some(mut session) = self.sessions.remove(session_id) else { + return "refusal"; + }; + let contract = crate::exec::contract(self.config, &session, goal, None); + let run_id = session.id(); + let outcome = session .turn_bounded_observed( &contract, self.provider, @@ -1121,7 +1255,7 @@ impl Editing<'_, P> { &Consulting { session_id: session_id.to_string(), outbound: self.outbound.clone(), - run_id: self.session.id(), + run_id, step: std::sync::Arc::clone(&shared.step), correlator: std::sync::Arc::clone(&self.correlator), }, @@ -1130,6 +1264,12 @@ impl Editing<'_, P> { .await; self.cancel = None; + // **Back in the map however the turn ended.** A session removed for the + // turn and not returned would be a conversation the client can still name + // and this agent can no longer find — and the failure would arrive on the + // *next* prompt, which is the worst place for it. The head has moved, so + // this is the session as the turn left it. + self.sessions.insert(session_id.to_string(), session); match outcome { Ok(result) => crate::acp_map::stop_reason(&result.outcome), // A turn that failed did not refuse anything and did not run out of diff --git a/tests/acp.rs b/tests/acp.rs index 64e033c..abd4c99 100644 --- a/tests/acp.rs +++ b/tests/acp.rs @@ -170,7 +170,11 @@ fn f2_initialize_answers_protocol_version_one_and_needs_no_authentication() { "`{name}` is not carried into a run yet and must not be promised", ); } - assert_eq!(result["agentCapabilities"]["loadSession"], json!(false)); + // **`true` since 0.39.0.** It was false because the correspondence between an + // ACP session id and a stored conversation had to be designed rather than + // guessed; the design turned out to be the one the id already carried, since + // this adapter issues `io-`. + assert_eq!(result["agentCapabilities"]["loadSession"], json!(true)); } /// **A malformed frame is answered, never fatal.** @@ -1129,8 +1133,9 @@ fn the_served_methods_are_the_ones_this_release_implements() { assert!(acp::serves("session/cancel")); assert!( - !acp::serves("session/load"), - "`session/load` is excluded from 0.36.0 and must not answer", + acp::serves("session/load"), + "`session/load` is served from 0.39.0, and its `loadSession` capability \ + is declared — the two lists agreeing is what the GATED table exists for", ); assert!( !acp::serves("fs/read_text_file"), @@ -1140,7 +1145,7 @@ fn the_served_methods_are_the_ones_this_release_implements() { assert_eq!( SERVED.len(), - 4, + 5, "the dispatch table changed without this test being updated", ); } @@ -1445,6 +1450,268 @@ async fn f11_a_cancelled_outcome_is_a_denial() { /// /// Asserted against `acp_map`'s own `toolCallId` for the same event rather than /// against a literal, so the two cannot drift into two spellings of one id. +/// Drive the real `io acp` over pipes, and hand back every frame it answered. +/// +/// **A spawned process rather than a `Handler` fixture**, because the thing under +/// test is the session map inside `Editing`, which is private and needs a +/// provider to construct. None of the frames below needs a completion — opening, +/// loading and refusing a prompt for an unknown session all happen before a turn +/// — so this runs with a provider configured at an address nothing serves and +/// reaches no network. +fn spoken(frames: &[serde_json::Value]) -> Vec { + let home = tempfile::tempdir().expect("a home"); + let workspace = tempfile::tempdir().expect("a workspace"); + spoken_in(home.path(), workspace.path(), frames) +} + +/// The same, against a home that outlives the process. +/// +/// **Two `io acp` runs over one store is what `session/load` is for**, and a +/// helper that made a fresh home per call could only ever load a session from the +/// process that was still running — which is the half that needs no persistence +/// and proves the least. +fn spoken_in( + home: &std::path::Path, + workspace: &std::path::Path, + frames: &[serde_json::Value], +) -> Vec { + use std::io::Write; + use std::process::{Command, Stdio}; + + let config = home.join("io.toml"); + std::fs::write( + &config, + "[[provider]]\nkind = \"compatible\"\nmodel = \"a-model\"\n\ + base_url = \"http://127.0.0.1:9\"\napi_key = \"not-a-key\"\n", + ) + .expect("the configuration"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_io")) + .arg("-C") + .arg(workspace) + .arg("acp") + .env("IO_CONFIG", &config) + .env("IO_CONFIG_HOME", home) + .env("NO_COLOR", "1") + .env_remove("OPENROUTER_API_KEY") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("io acp starts"); + + { + let stdin = child.stdin.as_mut().expect("stdin"); + for frame in frames { + writeln!(stdin, "{frame}").expect("the agent takes a frame"); + } + // Closing stdin is what ends the loop, which is the protocol's own + // shutdown and not a kill. + } + child.stdin.take(); + + let out = child.wait_with_output().expect("io acp ends"); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("every frame is JSON")) + .collect() +} + +/// A request frame as a `Value`, for the arms that drive a real process. +/// +/// Distinct from [`request`] above, which hands back a `String` for the arms that +/// drive a `Handler` fixture directly. +fn asked(id: i64, method: &str, params: serde_json::Value) -> serde_json::Value { + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }) +} + +/// **F12 — the agent holds more than one session, and they do not cross.** +/// +/// 0.36.0 held one and refused a second, which was the honest thing to do rather +/// than multiplex badly, and 0.38.0 named it as a limitation. An editor with two +/// files open wanting two conversations is the ordinary case. +/// +/// The ids must differ, which is the whole of "they do not cross": every update a +/// turn sends carries the id it belongs to, so two sessions sharing one would put +/// one conversation's output into the other's pane. +#[test] +fn f12_the_agent_opens_more_than_one_session() { + let answers = spoken(&[ + asked(1, "initialize", json!({ "protocolVersion": 1 })), + asked(2, "session/new", json!({})), + asked(3, "session/new", json!({})), + ]); + + let id_of = |id: i64| -> String { + answers + .iter() + .find(|frame| frame["id"] == json!(id)) + .and_then(|frame| frame["result"]["sessionId"].as_str()) + .unwrap_or_else(|| panic!("no session id for request {id}: {answers:#?}")) + .to_string() + }; + + let first = id_of(2); + let second = id_of(3); + assert_ne!( + first, second, + "two sessions share an id, so every update either sends lands in both \ + panes", + ); + for frame in &answers { + assert!( + frame.get("error").is_none(), + "a second session was refused: {frame}", + ); + } +} + +/// **F12 — a prompt names its session, and one the agent does not hold is +/// refused.** +/// +/// Through 0.38.2 there was one session and the parameter could be ignored. With +/// a map, a prompt that named nothing would go to whichever conversation happened +/// to be first — which is the failure mode of multiplexing badly that 0.36.0 +/// refused a second session to avoid, arriving by the back door. +#[test] +fn f12_a_prompt_for_a_session_the_agent_does_not_hold_is_refused() { + let answers = spoken(&[ + asked(1, "initialize", json!({ "protocolVersion": 1 })), + asked( + 2, + "session/prompt", + json!({ + "sessionId": "io-999999", + "prompt": [{ "type": "text", "text": "do something" }], + }), + ), + ]); + + let refusal = answers + .iter() + .find(|frame| frame["id"] == json!(2)) + .expect("the prompt was answered"); + assert!( + refusal.get("error").is_some(), + "a prompt for a session the agent does not hold was accepted: {refusal}", + ); + let message = refusal["error"]["message"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!( + message.contains("session/new") || message.contains("session/load"), + "the refusal has to say how to get a session: {message}", + ); +} + +/// **F12 — `session/load` reopens a conversation this agent issued.** +/// +/// Its absence was the sharpest thing missing from the editor door: an operator +/// who closed their editor yesterday had no way back into the conversation, while +/// `io resume` in a terminal had one all along. +/// +/// A session opened in this same process is loaded back, which is the case that +/// needs no history and no provider. That the *ids* round-trip is the mechanism — +/// this adapter issues `io-`, and loading is reading that number back +/// out and reopening it. +#[test] +fn f12_a_session_can_be_loaded_back() { + // **One home across two processes**, which is the case that matters: an + // operator closing their editor and reopening it is a second `io acp` over + // the same store, and a load that only worked inside one process would be + // the half that needs no persistence. + let home = tempfile::tempdir().expect("a home"); + let workspace = tempfile::tempdir().expect("a workspace"); + + let answers = spoken_in( + home.path(), + workspace.path(), + &[ + asked(1, "initialize", json!({ "protocolVersion": 1 })), + asked(2, "session/new", json!({})), + ], + ); + let id = answers + .iter() + .find(|frame| frame["id"] == json!(2)) + .and_then(|frame| frame["result"]["sessionId"].as_str()) + .expect("a session id") + .to_string(); + + let reloaded = spoken_in( + home.path(), + workspace.path(), + &[ + asked(1, "initialize", json!({ "protocolVersion": 1 })), + asked(2, "session/load", json!({ "sessionId": id })), + ], + ); + let answer = reloaded + .iter() + .find(|frame| frame["id"] == json!(2)) + .expect("the load was answered"); + assert!( + answer.get("error").is_none(), + "a session this agent issued could not be loaded back: {answer}", + ); + + // And a name it never issued is refused rather than opened as something. + let refused = spoken(&[ + asked(1, "initialize", json!({ "protocolVersion": 1 })), + asked(2, "session/load", json!({ "sessionId": "not-ours" })), + ]); + assert!( + refused + .iter() + .find(|frame| frame["id"] == json!(2)) + .expect("answered") + .get("error") + .is_some(), + "an id this agent never issued was loaded: {refused:#?}", + ); +} + +/// **F12 — a replayed turn is the two shapes a live turn already sends.** +/// +/// A loaded conversation goes through the client's existing rendering rather than +/// a second path built for replay — a bespoke transcript in the result would be +/// that second path, and it would be the one that rots, because nobody looks at +/// it until they reopen an old session. +/// +/// A turn with no reply contributes only its prompt: `Turn::reply` is `None` while +/// a turn runs and for one that stopped without a closing message, and an empty +/// agent chunk would draw the model as having answered with silence. +#[test] +fn f12_a_replayed_turn_carries_what_was_said_and_nothing_it_did_not() { + let turn = |reply: Option<&str>| io_harness::Turn { + id: 1, + session_id: 7, + parent_turn_id: None, + run_id: 3, + prompt: "tidy the parser".into(), + reply: reply.map(str::to_string), + outcome: Some("Success".into()), + created_at: "2026-09-06T00:00:00Z".into(), + }; + + let both = acp::replayed(&turn(Some("done"))); + assert_eq!(both.len(), 2, "{both:#?}"); + assert_eq!(both[0]["sessionUpdate"], json!("user_message_chunk")); + assert_eq!(both[0]["content"]["text"], json!("tidy the parser")); + assert_eq!(both[1]["sessionUpdate"], json!("agent_message_chunk")); + assert_eq!(both[1]["content"]["text"], json!("done")); + + let unanswered = acp::replayed(&turn(None)); + assert_eq!( + unanswered.len(), + 1, + "a turn with no reply drew an empty agent chunk, which says the model \ + answered with silence rather than that it did not answer: {unanswered:#?}", + ); +} + #[test] fn f9_the_permission_cell_id_is_the_one_the_run_is_on() { let updates = tokio::sync::mpsc::unbounded_channel().0; From bbbfff72342ca122d8fc1ce16e87374a6520fcf5 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 21:16:31 +0530 Subject: [PATCH 22/27] docs(release): the changelog and the contract for 0.39.0 The whole release rather than the pin: three ways a session could lose an operator's work, the four surfaces a running turn now shows, the three small doors, the three io-harness capabilities that were unreleased in practice, and the ceiling. Two things an upgrader has to find are stated as such rather than left in a feature list -- the rewind's shipped binding changed from `esc esc` to `esc`, and a run now contacts a catalogue host before its first step and costs more per step because it stops trimming at 24,000. `docs/CONTRACT.md` gains `io mcp serve`: what it takes, what it will not take and why, that stdout is the protocol on that door too, and that eleven tools are deliberately not served. --- CHANGELOG.md | 100 +++++++++++++++++++++++++++++++++++++++++++++++ docs/CONTRACT.md | 11 +++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee2f14d..593e8ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,106 @@ All notable changes to this project are documented here. The format follows ## [0.39.0] - 2026-09-06 +No keystroke loses your work, a running turn shows more of what it is doing, and +everything io-harness has shipped is reachable from the interface it has. + +### Fixed + +Three ways a session could lose what you had done, all found in one field test on +2026-09-05. These are behaviour changes rather than corrections, which is why this +is a minor release. + +- **An approval is a modal, and it says so.** Every key that was not `y`, `a`, `n`, + an arrow or `Enter` was ignored in silence — so an operator who began typing a + sentence saw their words go nowhere with nothing on screen saying the interface + was waiting for one of three keys. The answers row now leads with `press y, a or + n` when the last keystroke was refused, in a word rather than a colour, and + clears on the next key it does take. + +- **You can read the whole change before approving it.** The diff was cut at + whatever the viewport had, with `⋯ N more lines` on the last row: approving a + write whose end you cannot see is approving the elision. `Up`, `Down`, `PageUp`, + `PageDown` and `Home` move it under a header that stays put, and none of them + can answer anything. + +- **The `+`/`-` header counts the diff drawn under it.** io-harness measures the + fragment an edit replaced while the hunk is the whole file's diff — correct for + a library, wrong for a header sitting on top of the change it claims to count. + The field test met the pair as `+3 -9` above an edit the transcript recorded as + `-9 +10`. + +- **Undo asks, and no keystroke arms it.** `Esc` was bound to `esc esc`: the first + press armed and wrote what it would undo into the scrollback, the second acted. + Pressed to dismiss a picker, that footer is a line that has already scrolled and + the second press is not consent. The binding is one chord now and it raises + `/undo`'s own confirmation, which waits for an answer and names what would go + back. **If you have rebound `rewind`, the shipped default has changed from `esc + esc` to `esc`.** + +- **A stopped turn leaves a record.** `Ctrl+C` wrote its ending to the footer, + which is gone at the next keystroke, so a turn stopped after doing work left no + sign in the scrollback that it had ever started. It is committed to the + transcript now and says what was kept. The two-press contract is unchanged: the + first asks io-harness to stop at the next step boundary, the second stops now. + +- **`Enter` runs a command line you typed.** `/effort high` matched no palette row + — the row is `/effort` and the argument is not part of it — and `Enter` did + nothing at all, so a line copied from these guides sat behind `No row matches`. + It now runs, on that one `Enter`. + +- **The session lock followed the store.** Two `io` processes with different + `IO_CONFIG_HOME`s refused each other, because the lock file was keyed on a + session id that is only unique within one `runs.db` while living in one fixed + directory. Found by the new pty harness on its first run. + +### Added + +- **`io exec` without `--json` says what it did.** One line per tool call and one + per refusal, on stderr; stdout still carries the agent's reply and nothing else. + The README has promised that this door shows what it refused since it was + written, and it was true only in a session and in the JSON stream. + +- **The footer separates cached input from fresh.** `52k tok · 44k cached`. The + running figure read as though every re-sent catalogue were paid for in full; + four one-word turns moved it from 8.1k to 52k at a spend of $0.0001 each. + +- **A picture the agent was handed gets a row**, naming where it came from — an + MCP reply, a browser screenshot, its own `view_image`, or your attachment — its + type and its size. Something entered the model's context and nothing said so. + +- **A command a turn runs costs one row, not four.** The sandbox created, ran and + torn down lines are gone; that the command was contained is on the status line + already. + +- **`/setup` runs the wizard here** instead of telling you to leave and run `io + setup`. The theme applies at once and the rest arrives with your next message; + a changed provider or credential still needs `io` restarted, and it says so. + +- **`io skill add` takes a folder**, with its companion files, which is the layout + io-harness has always discovered. + +- **`/contain on` offers a fan-out** — four agents, two at once, one deep, and a + token ceiling — instead of naming four keys you would have to choose values for. + +- **`/context` says when `[run] collapse` is shortening entries**, and how much + survives. That lever has worked since 0.38.2 and appeared on no surface. + +- **`io mcp serve`** offers this install's own tools to another agent over MCP on + stdio, under this install's policy, saying which tools it serves and which + eleven it does not. + +- **`[codeact]`** lets a turn write one contained program instead of a chain of + tool calls. Absent, nothing changes: the tool catalogue is byte for byte what + 0.38.2 sent. + +- **`[otel]`** exports a run as OpenTelemetry spans. It says what it configured + and never that anything arrived, because io-harness accounts for a dropped + batch in a log rather than in a value. + +- **`io acp` holds several sessions and answers `session/load`**, so an editor can + open two conversations and reopen yesterday's. Both were named limitations in + 0.36.0 and 0.38.0. + ### Changed - **The context ceiling is the model's real window, and reading it contacts one diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index b097289..d09728c 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -27,7 +27,7 @@ With no subcommand, `io` opens an interactive session. | `io setup` | The first-run wizard: provider, credential, model, permission posture, theme | | `io exec ""` | Runs one goal to completion with no terminal interaction | | `io resume` | Carries on a run that stopped for a question, a plan or an interrupted call; `--list` shows what is waiting | -| `io mcp …` | Manage MCP servers without opening a session | +| `io mcp …` | Manage MCP servers without opening a session; `io mcp serve` makes io one, over stdio | | `io plugin …` | Manage capability bundles and marketplaces — the verbs are `add`, `install`, `list`, `search`, `remove` and `marketplace`. `io plugin marketplace add /` clones an index, `io plugin marketplace list` and `remove` are its other two, and `io plugin add ` installs a bundle out of one | | `io config …` | Read and write configuration keys | | `io skill …` | Add, list and remove skills | @@ -48,6 +48,15 @@ stdin. **stdout is the protocol**, so nothing else is written there and diagnost It always exits `0` when the client closes the pipe, because the protocol reports a run's outcome in its own `stopReason` and a process exit code would be a second, disagreeing answer. +`io mcp serve` (0.39.0) is the mirror of that door: it makes io an **MCP server**, offering this +install's own tools to another agent over newline-delimited JSON-RPC on stdio, under the policy +this install resolved. It takes no arguments — the workspace is `-C`'s and the policy is the +configuration's, because either as an argument would be a way to serve a workspace under a posture +the operator's own file does not grant. **stdout is the protocol** on this door too: what it serves, +what it does not, and under which posture are written to stderr before the loop starts. It ends +when the client closes stdin, and exits `0`. Eleven of io-harness's tools are deliberately not +served, and it names them. + ## Exit codes These are the contract a CI job depends on. **A code never changes meaning under a dependency From 9dd3293941019318085562ee5ef502ef18c6d13b Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 21:44:27 +0530 Subject: [PATCH 23/27] fix: the review's findings, and the splash card that contradicted the status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial review found ten things behind a green suite. The one that mattered: **`session/load` would open a session belonging to another workspace.** There is one `runs.db` for every workspace on the machine and `Session::reopen` reads a session's root out of its stored row, so a client walking `io-1`, `io-2`, `io-3` would have been handed the history of every conversation the operator has ever had, from every repository — and a prompt afterwards would have edited that other workspace's files under *this* workspace's policy, because the contract is rooted at the session while the policy was resolved once at the `-C` root. The refusal is the same sentence a malformed id gets, because a message that told them apart would be an oracle for which ids exist. Gated with two workspaces over one home, which the existing arm could not see. Also from the review: - `io acp` opened a session at startup that nothing used once sessions became a map, leaving an empty conversation in the store per editor launch. - A loaded session is keyed on the id io would have issued, not the string the client sent: `i64` parsing accepts `+7` and `007`, so three spellings were three map entries over one conversation head. - `End` fell into the arm that flashes `press y, a or n`, so a reader pressing it to reach the bottom of a diff was told to answer. Every other navigation key was taken silently; the refusal sweep only walks printable ASCII, so it never pressed it. - A doc link named `failure::reference_refusal`, which does not exist. The nightly rustdoc gate would have caught it; the suite could not. - `tests/timing.rs` tested for a `sleep` twice and the second test was unreachable, so the rule was enforced entirely by the first. - `servers::serving`'s doc claimed it reports which tools are served. It reports which are not, and now says why that is the half worth writing. - The OTel exporter reaches three doors and not `io acp`, which composes no observers at all. Named at the call site, in the function's doc and in the guide rather than left to be found as an empty collector. **And the splash card is dated.** After `/model` the status line said one model and the card said the one the session started with — two names on screen with nothing saying which the next turn would use. The card is committed scrollback, so it cannot be redrawn; it now opens with `opened with — the status line is current`. One caption rather than a model-specific fix, because `policy` and `workspace` go stale the same way, on a posture change and on `/resume`. Several doc comments written for this release had been spliced onto the wrong item — a new item inserted above an existing one absorbs its neighbour's doc block — which left four sabotage instructions naming the wrong test. Corrected where found. --- CHANGELOG.md | 8 ++++ docs/guide/configuration.md | 5 +++ src/acp.rs | 77 +++++++++++++++++++++++++++++++++---- src/approval.rs | 14 +++++++ src/context.rs | 51 ++++++++++++------------ src/contract.rs | 62 ++++++++++++++++------------- src/provider.rs | 3 +- src/servers.rs | 48 +++++++++++++---------- src/settings.rs | 32 +++++++-------- src/splash.rs | 19 +++++++++ tests/acp.rs | 72 ++++++++++++++++++++++++++++++++++ tests/approval.rs | 14 ++++++- tests/exec.rs | 40 +++++++++---------- tests/status.rs | 70 +++++++++++++++++++++++++++++++++ tests/timing.rs | 28 ++++++++------ 15 files changed, 413 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 593e8ef..171da9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,14 @@ is a minor release. session id that is only unique within one `runs.db` while living in one fixed directory. Found by the new pty harness on its first run. +- **The banner dates what it says.** The card goes into the terminal's own + scrollback and no later frame can reach a row of it, so after `/model` it went + on naming the model the session started with while the footer named the one the + next turn was going to — two model names on screen and nothing saying which. + Its three facts now sit under `opened with — the status line is current`. The + policy and the workspace are dated by the same caption, for the same reason: + both move mid-session and neither could be corrected either. + ### Added - **`io exec` without `--json` says what it did.** One line per tool call and one diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index bfc121b..eb7d259 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -385,6 +385,11 @@ given. A section that will not open at all is a different case and does say so. Prompts, replies, tool arguments and tool output are never sent. That is io-harness's decision and its own documentation states it. +**`io acp` does not export.** The session, `io exec` and `io resume` do; the editor +door has never composed observers at all — it takes no `[[hook]]` either — so a +run driven from Zed or a JetBrains IDE produces no spans. If your collector has +traces from the terminal and none from your editor, that is why. + ### Where io keeps your things **One directory: `~/.io-cli`, or `%USERPROFILE%\.io-cli` on Windows.** The diff --git a/src/acp.rs b/src/acp.rs index e8dc816..9322c99 100644 --- a/src/acp.rs +++ b/src/acp.rs @@ -962,7 +962,12 @@ pub async fn main( let spec = crate::exec::spec_for(None, &config, model_override.as_deref())?; let store = crate::settings::store_path().ok_or("no place to keep the run store")?; let store = io_harness::Store::open(&store).map_err(|error| error.to_string())?; - let session = io_harness::Session::open(&store, &root).map_err(|error| error.to_string())?; + // **No session is opened here as of 0.39.0.** This door used to open one + // before the provider was built, the way every other door does, and hand it + // to `Editor` — and since sessions became a map keyed on what the client + // asks for, nothing ever used it. `Session::open` writes a row, so every + // `io acp` launch left an empty conversation in the store for `/resume` to + // list. The root is what the adapter needs; `session/new` opens the sessions. let policy = crate::exec::policy_for(&config, None); crate::provider::build( @@ -971,7 +976,7 @@ pub async fn main( crate::settings::reference_catalogue(crate::settings::stored(&config).0.as_ref()), Editor { store, - session, + root, config, policy, }, @@ -982,7 +987,14 @@ pub async fn main( /// The ACP session, as something [`crate::provider::build`] can run. struct Editor { store: io_harness::Store, - session: io_harness::Session, + /// The workspace this adapter was pointed at, and the **only** one it will + /// serve. + /// + /// Every session it opens is rooted here, and a session it is asked to load + /// is refused unless the store says it was rooted here too — see + /// `session/load`, where that check is the difference between reopening a + /// conversation and reading somebody else's. + root: std::path::PathBuf, config: io_harness::Config, policy: io_harness::Policy, } @@ -1013,7 +1025,7 @@ impl crate::provider::WithProvider for Editor { // anyway — so seeding the map would leave a conversation in the store // that no client ever addressed. The root is what is kept. sessions: std::collections::BTreeMap::new(), - root: self.session.root().to_path_buf(), + root: self.root.clone(), cancel: None, correlator: std::sync::Arc::clone(&correlator), }; @@ -1169,19 +1181,53 @@ impl Editing<'_, P> { ))?; let opened = io_harness::Session::reopen(self.store, numeric) .map_err(|error| (ErrorCode::InvalidParams, error.to_string()))?; + // **The session has to belong to the workspace this adapter was + // pointed at, and nothing else here checks that.** + // + // There is one `runs.db` for every workspace on the machine, and + // `Session::reopen` reads a session's root out of its stored row + // — so without this a client could walk `io-1`, `io-2`, `io-3` + // and be handed the history of every conversation the operator + // has ever had, from every repository, before anything failed. + // Worse than the disclosure: a prompt afterwards would edit that + // other workspace's files, because `exec::contract` roots the + // contract at the session's own root, while `self.policy` was + // resolved once from the configuration discovered at **this** + // root. Somebody else's tree, under this tree's rules. + // + // Refused with the same sentence a bad id gets. A client has no + // business distinguishing "that session is not yours" from "that + // is not a session", and a message that told them apart would be + // an oracle for the ids that exist. + if opened.root() != self.root { + return Err(( + ErrorCode::InvalidParams, + format!("`{id}` is not a session this agent can open here"), + )); + } let turns = opened .history(self.store) .map_err(|error| (ErrorCode::Internal, error.to_string()))?; + // **Keyed on the id this adapter would have issued, not on the + // string the client sent.** `i64::from_str` accepts a leading `+` + // and leading zeros, so `io-7`, `io-007` and `io-+7` are three + // map keys over one stored session — and prompts against them + // would interleave three `Session` values on one conversation + // head. Canonicalising here makes the key a property of the + // session rather than of how it was spelled, and the answer + // carries it back so a client that spelled it loosely learns the + // one to use. + let key = format!("io-{}", opened.id()); for turn in &turns { for update in replayed(turn) { let _ = self.outbound.send(Outgoing::Notification { method: "session/update".into(), - params: json!({ "sessionId": id, "update": update }), + params: json!({ "sessionId": key, "update": update }), }); } } - self.sessions.insert(id, opened); - Ok(json!({})) + self.sessions.insert(key.clone(), opened); + Ok(json!({ "sessionId": key })) } "session/prompt" => { // **The client names which session, and this stopped being @@ -1259,6 +1305,23 @@ impl Editing<'_, P> { step: std::sync::Arc::clone(&shared.step), correlator: std::sync::Arc::clone(&self.correlator), }, + // **One observer, and this is the door that gets no `[[hook]]`, + // no `Broadcast` and no OpenTelemetry exporter.** + // + // The other three doors compose a `Fanout` and the exporter joins + // it there; this one has never composed anything, which predates + // 0.39.0 and is not something the exporter introduced. It is + // named here rather than left to be discovered, because an + // operator who configures `[otel]` and sees spans from the + // terminal and from CI would reasonably assume the editor was + // sending them too. + // + // Not fixed in this release deliberately: giving this door a + // fan-out means giving it hooks and a broadcast as well — a hook + // that fires for two doors and not the third is a worse state + // than one that fires for two and says so — and that is a change + // to what an editor session *does*, which belongs beside the + // decision rather than inside a release about reaching it. &reporter, ) .await; diff --git a/src/approval.rs b/src/approval.rs index 61ffad5..f6785a7 100644 --- a/src/approval.rs +++ b/src/approval.rs @@ -356,6 +356,20 @@ impl Approval { self.scroll = 0; None } + // **`End` too, and its absence was a real refusal.** Every other + // navigation key was taken silently while `End` — which is what a + // reader presses to reach the bottom of a diff — fell into the arm + // that flashes `press y, a or n`. Being told to answer for pressing + // a key that means "show me the rest" is the exact confusion this + // release's approval work exists to remove. + // + // `usize::MAX` rather than a computed bottom: the clamp is in + // `as_diff`, which is the only place that knows how many rows the + // change has at this frame's width. + KeyCode::End => { + self.scroll = usize::MAX; + None + } KeyCode::Enter => Some(self.chosen()), KeyCode::Char(c) => { let answer = Answer::ALL diff --git a/src/context.rs b/src/context.rs index 0899b99..d482881 100644 --- a/src/context.rs +++ b/src/context.rs @@ -817,31 +817,6 @@ pub fn window(contract: &TaskContract, remaining: Option, announced: Option .unwrap_or_else(|| contract.context.effective_tokens(remaining)) } -/// What a `source` word from `EventKind::ContextCeiling` means, in a sentence. -/// -/// **The word alone is not readable and that is why this exists.** io-harness -/// names one of three rungs — `contract`, `model`, `fallback` — and drawing the -/// bare word would tell an operator which enum arm fired rather than what -/// happened to their run. Each sentence says what decided the number and, where -/// there is one, what to do about it. -/// -/// **`fallback` no longer means 24,000, and this sentence is where io-cli stops -/// saying it does.** Through io-harness 0.81.0 the fallback was one constant for -/// every case; 0.82.0 splits it into 128,000 for a remote endpoint and 24,000 for -/// a loopback one, sized down to 7,616. So the honest sentence names an -/// assumption rather than a number, and points at the two things that turn an -/// assumption into a reading — the reference catalogue for a vendor provider, and -/// `[run.context]` for anybody who would rather state it themselves. -/// -/// **A local ceiling is not a regression and the sentence says so**, because it -/// looks exactly like one: an operator moving from a hosted model to Ollama or -/// llama.cpp sees the number fall, and nothing else on the page explains that the -/// harness is being careful rather than that this crate is wrong. -/// -/// An unrecognised word is passed through rather than swallowed. `source` is a -/// `String` on a `#[non_exhaustive]` variant, so io-harness may grow a fourth -/// rung, and a surface that silently drew nothing for it would hide exactly the -/// case somebody needs to see. /// What a run announced about the window it assembles inside. /// /// **One value rather than two parameters, because they are one fact.** The @@ -866,6 +841,32 @@ pub struct Ceiling<'a> { pub source: Option<&'a str>, } +/// What a `source` word from `EventKind::ContextCeiling` means, in a sentence. +/// +/// **The word alone is not readable and that is why this exists.** io-harness +/// names one of three rungs — `contract`, `model`, `fallback` — and drawing the +/// bare word would tell an operator which enum arm fired rather than what +/// happened to their run. Each sentence says what decided the number and, where +/// there is one, what to do about it. +/// +/// **`fallback` no longer means 24,000, and this sentence is where io-cli stops +/// saying it does.** Through io-harness 0.81.0 the fallback was one constant for +/// every case; 0.82.0 splits it into one default for a remote endpoint and a much +/// smaller one for a loopback endpoint. So the honest sentence names an +/// assumption rather than a number, and points at the two things that turn an +/// assumption into a reading — the reference catalogue for a vendor provider, and +/// `[run.context]` for anybody who would rather state it themselves. +/// +/// **A local ceiling is not a regression and the sentence says so**, because it +/// looks exactly like one: an operator moving from a hosted model to Ollama or +/// llama.cpp sees the number fall, and nothing else on the page explains that the +/// harness is being careful rather than that this crate is wrong. +/// +/// An unrecognised word is passed through rather than swallowed. `source` is a +/// `String` on a `#[non_exhaustive]` variant, so io-harness may grow a fourth +/// rung, and a surface that silently drew nothing for it would hide exactly the +/// case somebody needs to see. +/// /// `dash` is the glyph set's own, so the sentence degrades with every other one /// under `--plain` and the ASCII set rather than putting an em dash on a terminal /// that cannot draw it. diff --git a/src/contract.rs b/src/contract.rs index 1ab0240..88852f2 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -660,14 +660,45 @@ pub fn hooks( (!hooks.is_empty()).then_some(hooks) } +/// What a configured exporter is announced as. +/// +/// **Pure, and split out so the claim can be gated at all.** io-harness refuses +/// an `[otel]` section in any file inside a workspace — a collector is a host +/// every span of every run is posted to, and `io.toml` arrives with a `git +/// clone` — so a `Config` carrying one cannot be built from a string, only +/// discovered from a user-scope file. A test that had to write a file and set an +/// environment variable to read one sentence would be a test about the +/// filesystem; this way the argument is the answer, the way +/// [`crate::keys::Newline::of`] is written for the same reason. +/// +/// The vocabulary is the point. Every word this may not use is a word an +/// operator would read as "it arrived", and none of them is something this +/// process can know — see [`otel`]. +#[must_use] +pub fn otel_said(endpoint: &str, service: &str) -> String { + format!( + "spans go to {endpoint} as `{service}` — io reports what it configured and cannot \ + report what arrived, because io-harness accounts for a dropped batch in a log rather \ + than in a value" + ) +} + /// The OpenTelemetry exporter an `[otel]` section asks for, and the line that /// says what it is (0.39.0). /// /// **Beside [`hooks`] because it is the same kind of thing**: configuration that -/// becomes an observer, read once per door and attached to the fan-out every door -/// already builds. io-harness has shipped the exporter since its 0.78.0 behind a -/// feature flag this crate did not enable, so a capability the harness released -/// was unreleased in practice for anyone whose only interface is `io`. +/// becomes an observer, read once per door and attached to the fan-out that door +/// builds. io-harness has shipped the exporter since its 0.78.0 behind a feature +/// flag this crate did not enable, so a capability the harness released was +/// unreleased in practice for anyone whose only interface is `io`. +/// +/// **Three doors of four, and the fourth is `io acp`.** The session, `io exec` +/// and `io resume` each compose a [`crate::fanout::Fanout`] and the exporter +/// joins it there. The editor door composes nothing — it has never had a +/// `[[hook]]` or a `Broadcast` either, which predates this release — so an +/// operator with `[otel]` configured gets spans from the terminal and from CI and +/// none from their editor. Said here and at that call site rather than left to be +/// discovered from an empty collector. /// /// The section is io-harness's own — `Config::otel` deserializes it, and this /// crate names no key of its own for it and holds no copy of the defaults. @@ -688,29 +719,6 @@ pub fn hooks( /// exactly as much as this process knows; a line saying "exporting" would be a /// claim with nothing behind it. Filed upstream, and stated in the guide. /// -/// What a configured exporter is announced as. -/// -/// **Pure, and split out so the claim can be gated at all.** io-harness refuses -/// an `[otel]` section in any file inside a workspace — a collector is a host -/// every span of every run is posted to, and `io.toml` arrives with a `git -/// clone` — so a `Config` carrying one cannot be built from a string, only -/// discovered from a user-scope file. A test that had to write a file and set an -/// environment variable to read one sentence would be a test about the -/// filesystem; this way the argument is the answer, the way -/// [`crate::keys::Newline::of`] is written for the same reason. -/// -/// The vocabulary is the point. Every word this may not use is a word an -/// operator would read as "it arrived", and none of them is something this -/// process can know — see [`otel`]. -#[must_use] -pub fn otel_said(endpoint: &str, service: &str) -> String { - format!( - "spans go to {endpoint} as `{service}` — io reports what it configured and cannot \ - report what arrived, because io-harness accounts for a dropped batch in a log rather \ - than in a value" - ) -} - /// Both halves are `None` when no `[otel]` section asks for one, which is every /// configuration written before this release. pub fn otel(config: &Config) -> (Option, Option) { diff --git a/src/provider.rs b/src/provider.rs index 994d2bf..665e4a0 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -519,7 +519,8 @@ impl Provider for Chain

{ /// from its first, not a run that refuses to start. The one failure that is /// **not** swallowed is an egress policy denying the reference host, which /// io-harness raises before this is ever reached — see - /// [`crate::failure::reference_refusal`]. + /// [`crate::failure::advice_with`], which is what turns that refusal into a + /// sentence naming the key that turns the catalogue off. async fn warm_sizing(&self) -> io_harness::Result<()> { // The one loop this module is permitted, over `self.links` and nothing // else — the shape `tests/dependencies.rs` holds this file to by exact diff --git a/src/servers.rs b/src/servers.rs index 1e47a84..1f37c3c 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -860,23 +860,6 @@ fn decided_by(rule: Option<&str>, layer: Option<&str>) -> String { } } -/// A TOML basic string, escaped. -/// -/// Here rather than through `toml::to_string`, which would need a document to -/// serialise: this crate writes VALUES, and a value is the one thing the -/// serialiser cannot be asked for on its own. -/// -/// **Public, because [`edit`] takes TOML source and a caller has a Rust string.** -/// The alternative every call site reaches for is `format!("\"{value}\"")`, which -/// is either a parse error or a different value the moment the text carries a -/// quote or a backslash — a Windows command path is full of the second. -/// -/// **Every escape TOML defines and not just the two that are obvious.** A quote -/// and a backslash are the pair a reader thinks of; a newline inside a basic -/// string is a parse error, and an MCP `env` value is whatever an imported -/// server definition put there. Getting it wrong is a refusal rather than a -/// corruption — [`crate::edit::apply`] reads its own result back — but a -/// refusal an operator cannot act on is still a verb that does not work. /// What `io mcp serve` says about itself, before stdout becomes the protocol /// (0.39.0). /// @@ -884,8 +867,15 @@ fn decided_by(rule: Option<&str>, layer: Option<&str>) -> String { /// The whole argument for handing somebody else this install's tools is that they /// arrive under this install's policy, gated and journalled — so a server that /// started silently would be asking a client to trust a boundary it has no way to -/// inspect. These lines are that inspection: the root, the policy in force, and -/// which of io-harness's tools are served and which are not. +/// inspect. These lines are that inspection: the root, the protocol, the posture +/// in force, and the tools that are **not** served. +/// +/// **The served set is not enumerated, and saying so is the honest version.** It +/// is whatever `Toolbox` this install resolved minus the withheld names, it can +/// run to dozens of entries, and a client learns it authoritatively from +/// `tools/list` a moment later. What a reader cannot get from `tools/list` is the +/// *absence* — that `spawn` and `ask_question` are missing by design rather than +/// because something failed — which is why that is the half written here. /// /// **Every line goes to stderr and the caller is what writes them**, because /// stdout is the JSON-RPC stream from the moment the loop starts and one @@ -925,14 +915,30 @@ pub fn serving(config: &io_harness::McpServerConfig) -> Vec { ]; if !io_harness::MCP_SERVER_UNSERVED.is_empty() { lines.push(format!( - "io: not served{}{}", - " — ", + "io: not served — {}", io_harness::MCP_SERVER_UNSERVED.join(", "), )); } lines } +/// A TOML basic string, escaped. +/// +/// Here rather than through `toml::to_string`, which would need a document to +/// serialise: this crate writes VALUES, and a value is the one thing the +/// serialiser cannot be asked for on its own. +/// +/// **Public, because [`edit`] takes TOML source and a caller has a Rust string.** +/// The alternative every call site reaches for is `format!("\"{value}\"")`, which +/// is either a parse error or a different value the moment the text carries a +/// quote or a backslash — a Windows command path is full of the second. +/// +/// **Every escape TOML defines and not just the two that are obvious.** A quote +/// and a backslash are the pair a reader thinks of; a newline inside a basic +/// string is a parse error, and an MCP `env` value is whatever an imported +/// server definition put there. Getting it wrong is a refusal rather than a +/// corruption — [`crate::edit::apply`] reads its own result back — but a +/// refusal an operator cannot act on is still a verb that does not work. pub fn quoted(text: &str) -> String { let mut out = String::with_capacity(text.len() + 2); out.push('"'); diff --git a/src/settings.rs b/src/settings.rs index f87d1e3..8432edd 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -317,22 +317,6 @@ pub fn reference_catalogue(stored: Option<&CliSettings>) -> Option (String, Vec String { format!( "contained {dash} up to {} agents, {} at once per tier, {} deep, {} tokens for the \ diff --git a/src/splash.rs b/src/splash.rs index 04bccff..15902f2 100644 --- a/src/splash.rs +++ b/src/splash.rs @@ -134,6 +134,25 @@ pub fn lines(theme: &Theme, tty: bool, width: u16, about: &About) -> Vec Date: Sun, 6 Sep 2026 21:49:49 +0530 Subject: [PATCH 24/27] docs: reattach the doc comments this release spliced onto their neighbours A new item inserted immediately above an existing one absorbs that neighbour's doc block -- Rust attaches a run of `///` lines to whatever item follows it, and nothing in the toolchain sees the mistake: it formats, lints and tests clean, and only `cargo doc` would show it. Fourteen items were affected across this release's diff. Four of them carried sabotage instructions, which is what makes this worth a commit rather than a tidy-up: `f8_the_headless_commentary_cannot_be_written_to_stdout` ended with the sabotage for `f7_every_headless_turn_still_refuses_every_approval`, and three others were the same. A sabotage argument attached to the wrong test is worse than none, because the next author reads it, applies it, sees a green suite and concludes the gate is sound. Every block is back on the item it was written for, in `src/context.rs`, `src/contract.rs`, `src/servers.rs`, `src/settings.rs` and six test files. No prose changed except where it was already wrong: `servers::serving` no longer claims to report which tools are served, because it reports which are not. --- tests/acp.rs | 30 +++++++++++++------------- tests/approval.rs | 15 +++++++------ tests/contain.rs | 2 +- tests/contract.rs | 52 +++++++++++++++++++++++----------------------- tests/docs.rs | 46 ++++++++++++++++++++-------------------- tests/events.rs | 28 ++++++++++++------------- tests/servers.rs | 12 +++++------ tests/skillview.rs | 10 ++++----- 8 files changed, 99 insertions(+), 96 deletions(-) diff --git a/tests/acp.rs b/tests/acp.rs index 6bf52a4..c84caff 100644 --- a/tests/acp.rs +++ b/tests/acp.rs @@ -1435,21 +1435,6 @@ async fn f11_a_cancelled_outcome_is_a_denial() { ); } -/// **F9 — the permission request names the cell the run is actually on.** -/// -/// **This is the release's own adversarial-review finding.** `Consulting` held an -/// `AtomicU32` it constructed at zero and only ever read — nothing stored it — so -/// every request named cell `{run_id}-0` whatever step raised it. Through 0.37.0 -/// that mis-addressed a `tool_call_update` on a failed call; from 0.38.0 it would -/// mis-address the dialog an operator acts on, attaching the question to a call -/// that had already finished. -/// -/// The step now comes from the observer, which is the only thing in the module -/// that sees one — an `Approver` is handed a `Request` carrying an act and a -/// target and no step at all. -/// -/// Asserted against `acp_map`'s own `toolCallId` for the same event rather than -/// against a literal, so the two cannot drift into two spellings of one id. /// Drive the real `io acp` over pipes, and hand back every frame it answered. /// /// **A spawned process rather than a `Handler` fixture**, because the thing under @@ -1784,6 +1769,21 @@ fn f12_a_replayed_turn_carries_what_was_said_and_nothing_it_did_not() { ); } +/// **F9 — the permission request names the cell the run is actually on.** +/// +/// **This is the release's own adversarial-review finding.** `Consulting` held an +/// `AtomicU32` it constructed at zero and only ever read — nothing stored it — so +/// every request named cell `{run_id}-0` whatever step raised it. Through 0.37.0 +/// that mis-addressed a `tool_call_update` on a failed call; from 0.38.0 it would +/// mis-address the dialog an operator acts on, attaching the question to a call +/// that had already finished. +/// +/// The step now comes from the observer, which is the only thing in the module +/// that sees one — an `Approver` is handed a `Request` carrying an act and a +/// target and no step at all. +/// +/// Asserted against `acp_map`'s own `toolCallId` for the same event rather than +/// against a literal, so the two cannot drift into two spellings of one id. #[test] fn f9_the_permission_cell_id_is_the_one_the_run_is_on() { let updates = tokio::sync::mpsc::unbounded_channel().0; diff --git a/tests/approval.rs b/tests/approval.rs index d845c23..125acf7 100644 --- a/tests/approval.rs +++ b/tests/approval.rs @@ -488,12 +488,6 @@ async fn nothing_remembered_changes_nothing() { // and a new file is all addition. // --------------------------------------------------------------------------- -/// Open an overlay for a write of `content` to `target`, and return what it -/// draws at `width` columns in `height` rows. -/// -/// Rendered through a real `Screen` rather than by calling the private layout, -/// because what matters is what reaches the terminal — and because the overlay -/// is height-constrained, which is the whole reason its content flexes. /// An approval over a plain write, with no file behind it — everything a /// keyboard arm needs and nothing it does not. /// @@ -556,6 +550,15 @@ async fn overlay_for(target: &std::path::Path, content: &str, height: u16) -> St overlay_with(target, content, height, &[]).await } +/// Open an overlay for a write of `content` to `target`, and return what it +/// draws at `width` columns in `height` rows. +/// +/// Rendered through a real `Screen` rather than by calling the private layout, +/// because what matters is what reaches the terminal — and because the overlay +/// is height-constrained, which is the whole reason its content flexes. +/// +/// `keys` are pressed before the frame is taken, which is how the scrolling arms +/// reach a row that is not on the first screen. async fn overlay_with( target: &std::path::Path, content: &str, diff --git a/tests/contain.rs b/tests/contain.rs index 11241dd..b4d5338 100644 --- a/tests/contain.rs +++ b/tests/contain.rs @@ -239,7 +239,6 @@ fn f7_the_interrupt_says_where_the_turn_will_stop() { ); } -/// F2 — the mode is a fact about the turn, and it does not outlive it. /// **F10 — `/contain on` with nothing configured offers a fan-out rather than /// four key names.** /// @@ -329,6 +328,7 @@ fn f10_the_offered_caps_bound_the_spend_and_nothing_it_cannot_know() { ); } +/// F2 — the mode is a fact about the turn, and it does not outlive it. #[test] fn f2_the_contained_flag_is_cleared_with_the_turn() { let mut app = App::new(DARK, "a-model"); diff --git a/tests/contract.rs b/tests/contract.rs index e840ef6..5354952 100644 --- a/tests/contract.rs +++ b/tests/contract.rs @@ -2121,32 +2121,6 @@ fn n3_this_release_adds_no_configuration_key() { ); } -/// **F16 — the default skills directory sits under the home in force, beside the -/// memory note.** -/// -/// A skill is something the operator wrote, so it belongs wherever they put the -/// rest of what they wrote: an `$IO_CONFIG_HOME` pointed somewhere else moved -/// `io.toml` and `IO.md` there, and a skills default that stayed with io-cli's own -/// default home would read a directory beside a configuration file this session is -/// not using. -/// -/// **Both halves are asserted here, in one test, because that is the only thing -/// that keeps them together.** A test for the skills half alone permits -/// `contract::default_skills` and `memory::path` to answer about two different -/// directories, which is the state this release exists to end. -/// -/// The fixture makes the *wrong* answer available on purpose: `HomeFixture` -/// creates `~/.io-cli/skills` under a home that is not the one in force, so a -/// regression to `home::path` fails loudly with a path rather than quietly with a -/// `None` that could mean anything. -/// -/// Sabotage: put `home::path()` back in `contract::default_skills` — under which -/// this fails on the first assertion, naming the home it followed. -/// -/// The lock is held for the whole of it. `IO_CONFIG_HOME` is left set on the way -/// out, the way `tests/memory.rs` leaves it: the directory it names is gone by -/// then, so a later `Config::discover` finds no user scope, which is what every -/// other fixture in this file wants anyway. /// **F15 — enabling `codeact` hands the agent nothing on its own.** /// /// This is the fact worth gating, because `media` behaved differently in 0.9.0 @@ -2187,6 +2161,32 @@ fn f15_a_configuration_with_no_codeact_section_carries_none() { ); } +/// **F16 — the default skills directory sits under the home in force, beside the +/// memory note.** +/// +/// A skill is something the operator wrote, so it belongs wherever they put the +/// rest of what they wrote: an `$IO_CONFIG_HOME` pointed somewhere else moved +/// `io.toml` and `IO.md` there, and a skills default that stayed with io-cli's own +/// default home would read a directory beside a configuration file this session is +/// not using. +/// +/// **Both halves are asserted here, in one test, because that is the only thing +/// that keeps them together.** A test for the skills half alone permits +/// `contract::default_skills` and `memory::path` to answer about two different +/// directories, which is the state this release exists to end. +/// +/// The fixture makes the *wrong* answer available on purpose: `HomeFixture` +/// creates `~/.io-cli/skills` under a home that is not the one in force, so a +/// regression to `home::path` fails loudly with a path rather than quietly with a +/// `None` that could mean anything. +/// +/// Sabotage: put `home::path()` back in `contract::default_skills` — under which +/// this fails on the first assertion, naming the home it followed. +/// +/// The lock is held for the whole of it. `IO_CONFIG_HOME` is left set on the way +/// out, the way `tests/memory.rs` leaves it: the directory it names is gone by +/// then, so a later `Config::discover` finds no user scope, which is what every +/// other fixture in this file wants anyway. #[test] fn f16_the_skills_default_follows_the_home_in_force() { let _guard = env_lock(); diff --git a/tests/docs.rs b/tests/docs.rs index 90d5b8d..ace6540 100644 --- a/tests/docs.rs +++ b/tests/docs.rs @@ -2512,29 +2512,6 @@ fn f6_the_headless_guide_carves_out_the_provider_endpoint() { } } -/// **N3 — no shipped page says withholding a tool saves anything.** -/// -/// **This is the release's top risk written as a gate, and it is written because -/// the risk is a sentence rather than a branch.** io-harness offers a masked turn a -/// byte-identical catalogue on purpose: the tool array sits ahead of the provider's -/// cache breakpoint, so dropping a definition would save its tokens once and pay a -/// cache *write* on every later turn (`io-harness-0.82.0/src/tools/mod.rs:33-42`). -/// Withholding in fact makes the request marginally **larger**, by one sentence -/// naming what is withheld (`io-harness-0.82.0/src/run/prompts.rs:1381`). -/// -/// The roadmap entry 0.37.0 was planned from assumed the opposite and said so in -/// its headline. That framing is what a writer reaches for, because "withhold" means -/// "remove" everywhere else in computing — so the wrong sentence is the *natural* -/// one to write and nothing else in the suite can see it. A page claiming a saving -/// compiles, passes every other gate, and misleads the operator into using the -/// wrong lever for the problem they have. -/// -/// Scoped to sentences that put a saving verb near the masking vocabulary rather -/// than to the verbs alone: `docs/guide/accounting.md` legitimately discusses cost -/// and cheapness throughout, and a blanket ban on the word "cheaper" would forbid -/// the page that exists to talk about money from doing so. -/// -/// Sabotage: write "withholding a tool makes the turn cheaper" into any guide page. /// **N3 — no shipped page says a thought streams as it is written (0.39.0).** /// /// The roadmap entry for this release said reasoning would stream into the @@ -2642,6 +2619,29 @@ fn n5_no_shipped_page_claims_a_span_was_delivered() { } } +/// **N3 — no shipped page says withholding a tool saves anything.** +/// +/// **This is the release's top risk written as a gate, and it is written because +/// the risk is a sentence rather than a branch.** io-harness offers a masked turn a +/// byte-identical catalogue on purpose: the tool array sits ahead of the provider's +/// cache breakpoint, so dropping a definition would save its tokens once and pay a +/// cache *write* on every later turn (`io-harness-0.82.0/src/tools/mod.rs:33-42`). +/// Withholding in fact makes the request marginally **larger**, by one sentence +/// naming what is withheld (`io-harness-0.82.0/src/run/prompts.rs:1381`). +/// +/// The roadmap entry 0.37.0 was planned from assumed the opposite and said so in +/// its headline. That framing is what a writer reaches for, because "withhold" means +/// "remove" everywhere else in computing — so the wrong sentence is the *natural* +/// one to write and nothing else in the suite can see it. A page claiming a saving +/// compiles, passes every other gate, and misleads the operator into using the +/// wrong lever for the problem they have. +/// +/// Scoped to sentences that put a saving verb near the masking vocabulary rather +/// than to the verbs alone: `docs/guide/accounting.md` legitimately discusses cost +/// and cheapness throughout, and a blanket ban on the word "cheaper" would forbid +/// the page that exists to talk about money from doing so. +/// +/// Sabotage: write "withholding a tool makes the turn cheaper" into any guide page. /// Every other test in the repository stays green. #[test] fn n3_no_shipped_page_claims_a_mask_reduces_what_a_turn_costs() { diff --git a/tests/events.rs b/tests/events.rs index cb47d60..aa11875 100644 --- a/tests/events.rs +++ b/tests/events.rs @@ -3287,20 +3287,6 @@ fn f8_a_thought_is_committed_above_the_prose_it_produced() { ); } -/// **F8 — a spawned child's outcome is words, not a struct literal.** -/// -/// io-harness formats it with `{outcome:?}`, so the sentence handed to this crate -/// is literally `spawned child 149: Success { steps: 1 }` and the fleet result -/// line drew it verbatim — a Rust struct in the one row meant to tell an operator -/// what their children did. -/// -/// Asserted over the punctuation rather than over one expected string, and over -/// several `RunOutcome` shapes rather than one, because the point is that no -/// `Debug` survives — including a variant a later pin adds, which is precisely -/// what a table of the eighteen would miss. -/// -/// Sabotage: drop the `said_plainly` rebinding in the `Step` arm and every row -/// with a brace in it fails. /// **F15 — a program the turn wrote gets a row, and its acts are the rows under /// it.** /// @@ -3442,6 +3428,20 @@ fn f18_an_image_the_agent_was_handed_is_named_with_its_door_and_its_size() { } } +/// **F8 — a spawned child's outcome is words, not a struct literal.** +/// +/// io-harness formats it with `{outcome:?}`, so the sentence handed to this crate +/// is literally `spawned child 149: Success { steps: 1 }` and the fleet result +/// line drew it verbatim — a Rust struct in the one row meant to tell an operator +/// what their children did. +/// +/// Asserted over the punctuation rather than over one expected string, and over +/// several `RunOutcome` shapes rather than one, because the point is that no +/// `Debug` survives — including a variant a later pin adds, which is precisely +/// what a table of the eighteen would miss. +/// +/// Sabotage: drop the `said_plainly` rebinding in the `Step` arm and every row +/// with a brace in it fails. #[test] fn f8_a_spawned_childs_outcome_is_drawn_as_words() { for debug in [ diff --git a/tests/servers.rs b/tests/servers.rs index ca0286a..eb7a016 100644 --- a/tests/servers.rs +++ b/tests/servers.rs @@ -1202,12 +1202,6 @@ fn f10_a_typed_value_becomes_the_toml_its_key_needs() { ); } -/// The driver reaches the verb, and reaches it through `At`. -/// -/// Nothing under `tests/` links `src/main.rs`, so the wiring is read as text — -/// the instrument `tests/contract.rs` and `tests/context_share.rs` already use for -/// exactly this. Two properties: the edit row exists at all, and the write is -/// aimed at the scope the lookup returned rather than at a scope somebody picked. /// **F14 — the server says what it serves, and says it where it cannot corrupt /// the protocol.** /// @@ -1302,6 +1296,12 @@ fn f14_the_serve_arm_writes_nothing_to_stdout() { ); } +/// The driver reaches the verb, and reaches it through `At`. +/// +/// Nothing under `tests/` links `src/main.rs`, so the wiring is read as text — +/// the instrument `tests/contract.rs` and `tests/context_share.rs` already use for +/// exactly this. Two properties: the edit row exists at all, and the write is +/// aimed at the scope the lookup returned rather than at a scope somebody picked. #[test] fn f10_the_driver_writes_into_the_scope_the_lookup_found() { let driver = std::fs::read_to_string( diff --git a/tests/skillview.rs b/tests/skillview.rs index 6a9acad..3c1d770 100644 --- a/tests/skillview.rs +++ b/tests/skillview.rs @@ -877,11 +877,6 @@ fn enable_is_refused_on_a_bundle_skill_and_leaves_the_bundle_untouched() { // 0.30.0 F1, F2, F3 — a skill arrives and leaves. // --------------------------------------------------------------------------- -/// **F1.** Installing copies the operator's file in, and the row reads `yours`. -/// -/// The provenance half is the load-bearing one. `skills::wrote` answers *are these -/// the bytes io-cli last wrote?*, so an install that recorded itself in the -/// manifest would make the listing credit this crate with somebody else's work — /// **F10 — a directory holding a `SKILL.md` installs, with its companions.** /// /// io-harness has admitted folder skills all along: `Skills::discover` takes a @@ -954,6 +949,11 @@ fn f10_a_directory_without_a_manifest_is_refused_by_name() { ); } +/// **F1.** Installing copies the operator's file in, and the row reads `yours`. +/// +/// The provenance half is the load-bearing one. `skills::wrote` answers *are these +/// the bytes io-cli last wrote?*, so an install that recorded itself in the +/// manifest would make the listing credit this crate with somebody else's work — /// the same misattribution `home::origin` exists to prevent one level up. #[test] fn f1_an_installed_skill_is_copied_in_and_belongs_to_the_operator() { From 7bddf6e63b9cce4af18528143d7edb21eb2430b9 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 21:54:03 +0530 Subject: [PATCH 25/27] test(live): the mask arm needs a run opened, not a classifier's judgement `live_f6_a_withheld_tool_is_refused_by_the_mask_and_says_so` was failing on its own guard: the run came back `Finished { steps: 0 }`, no tool was reached, and the arm said so rather than passing on an absence -- which is exactly what it was written to do. The cause is not a defect. `contract::session` starts at `Verification::None`, and io-harness reads `conversational.unwrap_or(matches!(verify, Verification::None))`, so an ungated turn may be answered in one completion whenever its classifier decides the goal is only a question. It decided that here. That makes the arm's instrument a classifier's judgement, which is not a thing to gate a tool mask on. `[app.io-cli] conversational = false` opens a run for every prompt, which is what the key exists for, and leaves the model with the tools as its only route. The arm passes again. Checked against `develop` before touching it: the same arm fails identically on 0.38.2, so this is a pre-existing gate that stopped holding as provider behaviour moved and not something this release broke. --- tests/live.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/live.rs b/tests/live.rs index 6c2c8d7..6f33368 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -4023,13 +4023,34 @@ async fn live_f6_a_withheld_tool_is_refused_by_the_mask_and_says_so() { let provider = io_harness::OpenRouter::new(&key, model()); let policy = workspace_policy(); + // **`conversational = false`, and without it this arm cannot hold (0.39.0).** + // + // `contract::session` starts at `Verification::None`, and io-harness reads + // `contract.conversational.unwrap_or(matches!(verify, Verification::None))` — + // so an ungated turn may be answered in one completion with no steps at all + // whenever the harness's classifier decides the goal is only a question. It + // decided exactly that here: the run came back `Finished { steps: 0 }`, no + // tool was ever reached, and the arm failed on its own "the goal was not + // reached" guard. + // + // That is a real behaviour and not a defect — but it makes this arm's + // *instrument* a classifier's judgement, which is not a thing to gate a mask + // on. Turning the key off opens a run for every prompt, which is what the key + // exists for, and leaves the model with the tools as its only route. + // + // Verified against `develop` before changing anything: the same arm fails + // identically on 0.38.2, so this is a pre-existing gate that stopped holding + // as provider behaviour moved, and not something 0.39.0 broke. + let with_a_run = Config::from_toml("[app.io-cli]\nconversational = false\n") + .expect("io-cli's own section parses at any scope"); + let (answerer, _questions) = io_cli::intent::channel(); let contract = io_cli::contract::session( "Create a file called notes.txt containing the single word hello. \ Use the write_file tool.", root.to_path_buf(), - &no_configuration(), - &no_configuration().plugins(), + &with_a_run, + &with_a_run.plugins(), &io_cli::contract::Capabilities::default(), Arc::new(answerer), None, From 910ab8b90d1756351e3b14899efa7fdf19eeedfb Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 22:02:39 +0530 Subject: [PATCH 26/27] test(live): the ceiling a real run announces is the model's window The release's headline, and the only arm that can decide it. 0.38.2 built this whole path -- four wrappers delegating, three surfaces reading the announced ceiling -- and shipped it inert, because io-harness 0.81.0 answered `context_window` on `Compatible` alone and only from a catalogue nothing primed. Every provider this crate could construct assembled under a flat 24,000, and US-IO-CLI-0.38.2-I01 had to withdraw the claim. So an offline gate over the plumbing is exactly what was green through that release. Nothing but a live run tells this release apart from the last one, and until now the live suite had no arm that looked at a ceiling at all. It asserts the two things the release claims rather than a number: the rung is no longer `fallback`, and the ceiling is no longer io-harness's old flat constant. The number itself is whatever the configured model declares and moves when the catalogue does. Measured: 1,171,456 tokens, source `model`. Closes initorigin/io-cli#105. --- tests/live.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/live.rs b/tests/live.rs index 6f33368..9ceb413 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -4404,3 +4404,74 @@ fn live_o3_a_gated_run_that_cannot_pass_exits_six() { "stderr must say the gate is what ended this: {stderr}", ); } + +/// **F16/O3 — the ceiling a real run announces is the model's window, not 24,000.** +/// +/// **This is the release's headline and the only arm that can decide it.** 0.38.2 +/// built the whole path — all four wrappers delegating, three surfaces reading the +/// announced ceiling — and shipped it inert, because in io-harness 0.81.0 only +/// `Compatible` answered `context_window` and only from a catalogue nothing +/// primed. Every provider this crate could construct assembled under a flat +/// 24,000 and `US-IO-CLI-0.38.2-I01` had to withdraw the claim. So an offline gate +/// asserting the plumbing is exactly what was green through that release, and +/// nothing but a live run against a real provider can tell this release apart +/// from the last one. +/// +/// The number is not asserted, and that is deliberate. It is whatever the model +/// the fixture names actually declares, which moves when the catalogue moves. What +/// is asserted is what the release claims: that the rung is no longer the +/// fallback, and that the ceiling is no longer io-harness's old flat constant. +/// +/// Nothing is written and no tool is reached — the ceiling is announced beside +/// `Started`, before the first step — so this is the cheapest arm in the file. +#[tokio::test] +#[ignore = "live: needs OPENROUTER_API_KEY"] +async fn live_f16_the_announced_ceiling_is_the_models_window() { + let key = key(); + let dir = tempfile::tempdir().expect("a workspace"); + let root = dir.path(); + + let store = Store::open(root.join("runs.db")).expect("a store"); + let mut session = Session::open(&store, root).expect("a session"); + let provider = io_harness::OpenRouter::new(&key, model()); + let policy = workspace_policy(); + let (_steer, inbox) = Steer::channel(); + let collected = Arc::new(Mutex::new(Vec::new())); + let observer = Collector { + events: Arc::clone(&collected), + }; + + let _ = session + .turn_steered( + "Reply with the single word ok.", + &provider, + &store, + &policy, + &DenyAll, + &observer, + &inbox, + ) + .await; + + let events = collected.lock().expect("not poisoned"); + let announced = events + .iter() + .find_map(|event| match &event.kind { + EventKind::ContextCeiling { max_tokens, source } => Some((*max_tokens, source.clone())), + _ => None, + }) + .expect("a real run announces its ceiling once, beside Started"); + + let (max_tokens, source) = announced; + assert_ne!( + source, "fallback", + "the run took the fallback rung, so nothing sized this provider — which is \ + exactly the state 0.38.2 shipped and this release exists to end. \ + ceiling: {max_tokens}", + ); + assert_ne!( + max_tokens, 24_000, + "the ceiling is io-harness's old flat constant, announced as `{source}`", + ); + println!("live ceiling: {max_tokens} tokens, source {source}"); +} From 7ffb798efe4993251aec73cb5fd3e052db05a653 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Sun, 6 Sep 2026 22:14:20 +0530 Subject: [PATCH 27/27] fix: pass the pty size as a raw pointer `libc::openpty` takes `*const winsize` on Linux and `*mut winsize` on macOS, so `&mut size` was a borrow only one of the two signatures wanted. Clippy's `unnecessary_mut_passed` fired on Linux while every macOS gate stayed green. --- tests/pty.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/pty.rs b/tests/pty.rs index 3c16f13..1c5e125 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -275,13 +275,16 @@ mod unix { ws_xpixel: 0, ws_ypixel: 0, }; + // The size argument is `*mut winsize` on macOS and `*const winsize` on + // Linux, so it is passed as a raw pointer: `&mut size` is a borrow the + // Linux signature does not want, and `&size` one macOS refuses. let opened = unsafe { libc::openpty( &mut master, &mut slave, std::ptr::null_mut(), std::ptr::null_mut(), - &mut size, + std::ptr::addr_of_mut!(size), ) }; assert!(opened == 0, "openpty: {}", std::io::Error::last_os_error());