diff --git a/CHANGELOG.md b/CHANGELOG.md index 3196006..171da9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,166 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [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. + +- **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 + 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 + 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 +3750,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/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/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/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/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", diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index ffc1bbb..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 @@ -127,7 +136,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 +157,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..aa3b5fc 100644 --- a/docs/guide/accounting.md +++ b/docs/guide/accounting.md @@ -138,6 +138,75 @@ 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. + +### 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/docs/guide/configuration.md b/docs/guide/configuration.md index e71661a..eb7d259 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 @@ -307,6 +308,88 @@ 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 +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. + +**`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/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/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/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/acp.rs b/src/acp.rs index 5eacfa5..9322c99 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, @@ -952,15 +962,21 @@ 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( spec, model_override, + crate::settings::reference_catalogue(crate::settings::stored(&config).0.as_ref()), Editor { store, - session, + root, config, policy, }, @@ -971,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, } @@ -980,7 +1003,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 { @@ -992,12 +1015,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.root.clone(), cancel: None, correlator: std::sync::Arc::clone(&correlator), }; @@ -1013,14 +1041,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 @@ -1028,6 +1069,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 { @@ -1059,26 +1131,127 @@ 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() { + 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()))?; + // **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::InvalidRequest, - "this agent holds one session per process; start another `io acp` \ - for a second" - .into(), + ErrorCode::InvalidParams, + format!("`{id}` is not a session this agent can open here"), )); } - let id = format!("io-{}", self.session.id()); - self.session_id = Some(id.clone()); - Ok(json!({ "sessionId": id })) + 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": key, "update": update }), + }); + } + } + self.sessions.insert(key.clone(), opened); + Ok(json!({ "sessionId": key })) } "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(), @@ -1109,9 +1282,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, @@ -1120,15 +1301,38 @@ 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), }, + // **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; 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/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/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/approval.rs b/src/approval.rs index 5f481bd..f6785a7 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,57 @@ 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 + } + // **`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) => 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 +583,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 +618,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 +649,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 +685,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/commands.rs b/src/commands.rs index e0c343f..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", @@ -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..d482881 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,77 @@ pub fn window(contract: &TaskContract, remaining: Option, announced: Option .unwrap_or_else(|| contract.context.effective_tokens(remaining)) } +/// 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>, +} + +/// 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. +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 +905,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 +951,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 @@ -910,6 +990,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/src/contract.rs b/src/contract.rs index b447662..88852f2 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. @@ -500,7 +527,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] @@ -633,6 +660,101 @@ 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 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. +/// +/// # 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. +/// +/// 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. /// @@ -657,7 +779,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 +804,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/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/src/events.rs b/src/events.rs index 80de9f9..cf03200 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. @@ -1796,6 +1822,135 @@ 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 + // `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/exec.rs b/src/exec.rs index 0a72036..61d8e6e 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 @@ -839,6 +900,7 @@ pub async fn main( provider::build( spec, model_override, + settings::reference_catalogue(settings::stored(&config).0.as_ref()), Headless { store, session, @@ -880,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 @@ -895,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()); @@ -1552,6 +1633,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, @@ -1598,7 +1687,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 @@ -1606,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)); @@ -1656,8 +1763,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/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/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 88b6c99..a480ce0 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 @@ -557,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())?; @@ -577,7 +593,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. @@ -634,6 +669,7 @@ async fn drive( provider::build( provider::chain_of(&config), model_override, + catalogue, Interactive { screen, inputs, @@ -996,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 @@ -1426,6 +1465,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() { @@ -4285,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( @@ -4350,9 +4443,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 @@ -4376,7 +4496,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; } @@ -4587,20 +4713,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 @@ -4623,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 @@ -6106,11 +6274,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 { @@ -6407,34 +6590,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"), } @@ -6563,7 +6730,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(), @@ -7264,6 +7434,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); + } } } @@ -7602,10 +7783,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`, @@ -7796,7 +7989,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 @@ -8096,7 +8294,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(), @@ -8422,8 +8623,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 @@ -8581,8 +8804,32 @@ 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); + } + // **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 @@ -9503,6 +9750,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 @@ -10101,6 +10355,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 ac8375c..2fe41b0 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, @@ -159,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. @@ -403,7 +422,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")?, ), })) } @@ -453,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", &[])?; @@ -554,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 @@ -710,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)], @@ -1408,7 +1441,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/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/provider.rs b/src/provider.rs index 70a7602..665e4a0 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,53 @@ 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::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 + // 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 +605,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 +627,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 +639,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 +702,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 +739,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 +856,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 +981,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/servers.rs b/src/servers.rs index 0a55306..1f37c3c 100644 --- a/src/servers.rs +++ b/src/servers.rs @@ -860,6 +860,68 @@ fn decided_by(rule: Option<&str>, layer: Option<&str>) -> String { } } +/// 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 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 +/// 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 +} + /// A TOML basic string, escaped. /// /// Here rather than through `toml::to_string`, which would need a document to diff --git a/src/settings.rs b/src/settings.rs index b19c801..8432edd 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,117 @@ 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"; + +/// 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", + ), + ], + ) +} + /// 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 +784,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/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/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, + /// 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 + /// 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 +705,8 @@ impl Status { cost: None, context: None, ceiling: None, + cached: None, + ceiling_source: None, containment: None, boundary: None, branch: None, @@ -740,6 +790,15 @@ 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; + // 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 @@ -1014,9 +1073,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()); } } @@ -1266,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/src/triage.rs b/src/triage.rs index dfeb069..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", @@ -349,34 +348,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/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/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/acp.rs b/tests/acp.rs index 64e033c..c84caff 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", ); } @@ -1430,6 +1435,340 @@ async fn f11_a_cancelled_outcome_is_a_denial() { ); } +/// 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 session belonging to another workspace cannot be loaded.** +/// +/// **The defect this exists for was real and shipped in the first draft of +/// `session/load`.** 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 a check 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 — and a prompt afterwards would edit that other workspace's files +/// under *this* workspace's policy, because the contract is rooted at the +/// session and the policy was resolved once at the `-C` root. +/// +/// Found by the adversarial review, after the suite, clippy and the formatter +/// were green — and it could not have been found by the arm above it, which uses +/// one workspace for both processes. +/// +/// The refusal is deliberately the same sentence a malformed 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 which +/// ids exist. +#[test] +fn f12_a_session_from_another_workspace_is_refused() { + let home = tempfile::tempdir().expect("a home"); + let mine = tempfile::tempdir().expect("this workspace"); + let theirs = tempfile::tempdir().expect("another workspace"); + + // A session opened against a *different* workspace, in the same store. + let opened = spoken_in( + home.path(), + theirs.path(), + &[ + asked(1, "initialize", json!({ "protocolVersion": 1 })), + asked(2, "session/new", json!({})), + ], + ); + let elsewhere = opened + .iter() + .find(|frame| frame["id"] == json!(2)) + .and_then(|frame| frame["result"]["sessionId"].as_str()) + .expect("a session id") + .to_string(); + + // Now ask an agent pointed at *this* workspace to load it. + let answers = spoken_in( + home.path(), + mine.path(), + &[ + asked(1, "initialize", json!({ "protocolVersion": 1 })), + asked(2, "session/load", json!({ "sessionId": elsewhere })), + ], + ); + + let answer = answers + .iter() + .find(|frame| frame["id"] == json!(2)) + .expect("the load was answered"); + assert!( + answer.get("error").is_some(), + "a session rooted in another workspace was loaded: {answer}", + ); + + // And nothing of that conversation was streamed on the way to the refusal. + // The replay runs before any failure could, so an ordering mistake here + // discloses the history and then says no. + for frame in &answers { + assert_ne!( + frame["method"], + json!("session/update"), + "another workspace's history was replayed before the refusal: {frame}", + ); + } +} + +/// **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:#?}", + ); +} + /// **F9 — the permission request names the cell the run is actually on.** /// /// **This is the release's own adversarial-review finding.** `Consulting` held an 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/approval.rs b/tests/approval.rs index 229c6f0..125acf7 100644 --- a/tests/approval.rs +++ b/tests/approval.rs @@ -488,13 +488,83 @@ async fn nothing_remembered_changes_nothing() { // and a new file is all addition. // --------------------------------------------------------------------------- +/// 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 +} + /// 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. -async fn overlay_for(target: &std::path::Path, content: &str, height: u16) -> String { +/// +/// `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, + 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 +583,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 +626,214 @@ 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() { + // `End` is in this list because it was not in the code: every other + // navigation key was taken silently while `End` — what a reader presses to + // reach the bottom of a diff — flashed `press y, a or n`. Found by the + // adversarial review, which noticed that the refusal sweep only walks + // printable ASCII and so never presses it. + for key in [ + KeyCode::Up, + KeyCode::Down, + KeyCode::PageUp, + KeyCode::PageDown, + KeyCode::Home, + KeyCode::End, + ] { + let (drawn, answer) = struck(key).await; + assert!( + !drawn.contains(io_cli::approval::ONLY_THREE_KEYS), + "{key:?} was refused. A key that moves the proposal is a key this \ + surface takes, and being told to answer for pressing it is the \ + confusion this overlay exists to remove: {drawn}", + ); + 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/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/contain.rs b/tests/contain.rs index 4b78383..b4d5338 100644 --- a/tests/contain.rs +++ b/tests/contain.rs @@ -239,6 +239,95 @@ fn f7_the_interrupt_says_where_the_turn_will_stop() { ); } +/// **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", + ); +} + /// 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() { diff --git a/tests/context.rs b/tests/context.rs index beeacc2..7d2c9d7 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,192 @@ 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:#?}", + ); +} + +/// **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 +/// 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..5354952 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 @@ -2121,6 +2121,46 @@ fn n3_this_release_adds_no_configuration_key() { ); } +/// **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", + ); +} + /// **F16 — the default skills directory sits under the home in force, beside the /// memory note.** /// 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.", 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}", + ); +} diff --git a/tests/docs.rs b/tests/docs.rs index e7fed03..ace6540 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"); @@ -2511,15 +2512,122 @@ fn f6_the_headless_guide_carves_out_the_provider_endpoint() { } } +/// **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 +/// 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", + ); + } + } +} + /// **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.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/events.rs b/tests/events.rs index a75db12..aa11875 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. @@ -3277,6 +3287,147 @@ fn f8_a_thought_is_committed_above_the_prose_it_produced() { ); } +/// **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 +/// 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}", + ); + } +} + /// **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 diff --git a/tests/exec.rs b/tests/exec.rs index ab40cec..e216e41 100644 --- a/tests/exec.rs +++ b/tests/exec.rs @@ -3025,6 +3025,66 @@ fn f10_a_configuration_with_no_templates_passes_every_plain_goal_through() { ); } +/// **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`", + ); +} + /// **F7 — `io exec` still refuses every approval, and 0.36.0 is the release that /// could have changed that by accident.** /// 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/glyphs.rs b/tests/glyphs.rs index 2c69142..ae6f2b0 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 @@ -602,6 +613,31 @@ 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(), + }, + // 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 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/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/live.rs b/tests/live.rs index 9dffe13..9ceb413 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, @@ -4019,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, @@ -4059,7 +4084,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. @@ -4379,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}"); +} 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. 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/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, + ); +} 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/pty.rs b/tests/pty.rs new file mode 100644 index 0000000..1c5e125 --- /dev/null +++ b/tests/pty.rs @@ -0,0 +1,544 @@ +//! 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, + }; + // 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(), + std::ptr::addr_of_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"); + } + + /// **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 + /// 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 + /// "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/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 { 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/servers.rs b/tests/servers.rs index dd12742..eb7a016 100644 --- a/tests/servers.rs +++ b/tests/servers.rs @@ -1202,6 +1202,100 @@ fn f10_a_typed_value_becomes_the_toml_its_key_needs() { ); } +/// **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}", + ); +} + /// The driver reaches the verb, and reaches it through `At`. /// /// Nothing under `tests/` links `src/main.rs`, so the wiring is read as text — diff --git a/tests/skillview.rs b/tests/skillview.rs index fe1d318..3c1d770 100644 --- a/tests/skillview.rs +++ b/tests/skillview.rs @@ -877,6 +877,78 @@ fn enable_is_refused_on_a_bundle_skill_and_leaves_the_bundle_untouched() { // 0.30.0 F1, F2, F3 — a skill arrives and leaves. // --------------------------------------------------------------------------- +/// **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}", + ); +} + /// **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 diff --git a/tests/status.rs b/tests/status.rs index 074649b..f36c344 100644 --- a/tests/status.rs +++ b/tests/status.rs @@ -6,6 +6,7 @@ mod support; use std::time::Duration; use io_cli::app::App; +use io_cli::splash; use io_cli::status::{format_elapsed, Budgets, Status}; use io_cli::theme::{DARK, MONO}; use io_harness::{EventKind, RunEvent, TaskContract}; @@ -3109,3 +3110,129 @@ 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", + ); +} + +/// **The other surface that names a model is the splash card, and it is dated.** +/// +/// `Status::model` is live: `/model` writes the chosen name into it and the +/// footer redraws. The splash card is not, and cannot be — it goes into the +/// terminal's own scrollback through `Screen::commit`, which is `insert_before`, +/// so once it is written no later frame reaches a row of it. After one `/model` +/// the operator therefore had two model names on screen at once, the card's and +/// the footer's, with nothing saying which one the next turn was going to. +/// +/// A redraw is not available here, so the card says instead that its facts are +/// the ones the session opened with, and names the surface that is current. The +/// two then say different things without contradicting each other, which is the +/// only shape a permanent record and a live field can both be right in. +/// +/// Sabotage: drop the caption row from `splash::lines`. The card is a bare +/// `model` row again, reading exactly like the footer's, and this test goes red +/// while every other splash test stays green. +#[test] +fn the_splash_card_dates_its_facts_rather_than_contradicting_the_status_line() { + let opened_with = "deepseek/deepseek-v4-flash-0731"; + let about = splash::About { + model: Some(opened_with.into()), + policy: Some("ask".into()), + workspace: Some("/w".into()), + }; + let card: String = splash::lines(&DARK, true, 100, &about) + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect(); + + // What `/model` does, in the field it does it to: the live surface moves and + // the committed card cannot follow it. + let mut status = Status::new(opened_with); + status.model = "openai/gpt-6-astra".into(); + assert!( + rendered(&status, 200).contains(&status.model), + "the footer is the live answer and must carry the chosen model", + ); + + assert!( + card.contains(opened_with), + "the card still names what the session started with, because it is a \ + record of an opening and nothing can rewrite it: {card:?}", + ); + assert!( + card.contains("opened with"), + "the card states a model with nothing marking it as the session's \ + opening state, so it reads as the current one: {card:?}", + ); + assert!( + card.contains("status line"), + "the card dates itself but never says which surface is current, which \ + leaves the operator with two names and no tie-break: {card:?}", + ); + + // Nothing to date when there is nothing to say. A card with no facts on it + // draws no caption, so the mark-only form keeps its shape. + let bare: String = splash::lines(&DARK, true, 100, &splash::About::default()) + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect(); + assert!( + !bare.contains("opened with"), + "a caption over an empty table is a heading for nothing: {bare:?}", + ); +} 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", diff --git a/tests/timing.rs b/tests/timing.rs index a6257fc..2ea6d2b 100644 --- a/tests/timing.rs +++ b/tests/timing.rs @@ -154,6 +154,60 @@ 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. A `sleep` on the same line is the poll + // interval, which decides how often the harness looks and never whether + // it gives up. + // + // **The `sleep` test was written twice and once was dead.** A second + // check re-read the same line through `source.lines().nth(number)` and + // asked whether it contained `sleep` — which the condition above had + // just established it does not, so the branch was unreachable and the + // rule was enforced entirely by the condition. Removed rather than + // repaired, because there was nothing the second read could add: the + // line is the whole context this rule needs. + if trimmed.contains("Duration::from_millis") && !trimmed.contains("sleep") { + 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 +229,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 { 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}");