io-cli 0.17.0 - #46
Merged
Merged
Conversation
0.67 -> 0.69, which is the newest published version. Two breaking changes
reach this crate and both land in tests rather than src:
* `EventKind::Mcp` gained `tools: Option<u32>` in 0.68.0 — how many tools
a server offered, set on the event announcing it reaching the run and
`None` on every other form. Every construction of the variant names it
now, with `Some(n)` on the announcement and `None` on a call.
* `SteerInbox::pending` returns `Steering` instead of `(Vec<String>, bool)`
in 0.69.0, so the third thing an operator can send — a fold — did not
have to grow the tuple. The interrupt test binds the struct, and asserts
that an interrupt is neither a message nor a fold.
Nothing else moves: no dependency was added by either harness release, the
feature list is untouched, and the suite is unmoved at 61 binaries / 792
passed / 0 failed / 24 ignored.
…used io-harness 0.68.0 put the count on the wire: `EventKind::Mcp` carries `tools: Option<u32>`, set only on the event announcing a server reaching the run, over the server's whole listed catalogue, and `None` on every other form. That closes the deferral 0.16.0 recorded as US-IO-CLI-0.16.0-I01. Offered and asked-for are two different questions and are now two different numbers. `Some(0)` is a server that stated it offers nothing; `None` is an event with nothing to say about the count. Collapsing them would make a server that has only ever answered CALLS — every one of whose events carries `None` — report offering no tools while visibly using them, so a count is assigned only from a `Some` and a later `None` can neither write a zero nor erase one that was heard. The module doc that said the fact could not be obtained is corrected in the same commit that obtains it: a doc comment asserting a property is a claim the next reader trusts. Also fixes the one site 0.16.0's rename missed. `/status` said "offering N tools" over a number that has counted CALLS since 0.10.0, while the status line itself has said `mcp N/M calls` since. It says "answering N calls" now — two different numbers under one word is worse than either alone, and doubly so now that `/mcp` draws a real offered count beside it.
… order A prompt typed while a turn is in flight already reached the composer and drew there — `App::key` has never had a running guard — and the `Enter` that followed returned a submit into the driver's catch-all arm, which discarded it while the composer that held it had already cleared. The keystroke looked accepted and the prompt was gone. That is the defect this release exists to remove, and it had no test because nothing under tests/ links the binary. So the guard lives in `App::compose`, not in the driver: a mid-turn Enter queues the text and returns `Command::None`. That is the only place deciding what a mid-turn prompt becomes, which is also the seam mid-turn steering needs. `/` and `!` still fall through to the driver's existing refusal — a queue that held shell lines would put a second construction site behind the spawn ban. The queue is its own field rather than an overload of the picture queue or of `submitted`, which is the current turn's prompt and exists so a rewind can put it back. It drains one prompt per turn: three queued lines are three turns in the order they were typed, each its own exchange in the scrollback and each interruptible. Joining them would answer three questions in one breath. A turn the operator stopped drops what was waiting. One press of the stop key should not start the next three turns. Queueing announces through `say` rather than `record`: the prompt enters the transcript when it runs, as its own echo, and a line here would be two entries for one prompt. `src/main.rs` also carries the driver wiring for `/context` in this commit, because both changes edit the same file.
…quest
io-harness enumerates no context window: the prompt composer is private, the
event announcing a composed prompt carries a byte count and deliberately no
text, and the tool catalogue offered to the model is behind no public accessor
at all. What it does hand the caller is the `CompletionRequest` itself, whose
`system`, `tools` and `messages` are public — and io-cli already routes every
provider through one generic seam. So a decorator over that seam records the
request on its way past and `/context` renders what was actually sent, tools
this crate never registered included.
The maker is wrapped rather than the provider it makes, so a `/model` switch
keeps reporting instead of quietly reverting to a provider nothing watches. All
twelve trait methods are delegated: nine are defaulted, and a default firing
here would change behaviour silently — the trace would lose the provider's
name, attachments would start being refused, and streaming would stop.
The sections are a disjoint partition of the request rather than six
overlapping wholes, because the harness puts the instructions inside the system
block, the MCP tools inside the tool list and the recalled memory inside the
user turn. Reporting each whole would double-count and the page would exceed
the request it describes. Every count is io-harness's own `estimate_tokens`,
the same estimator its assembler uses, and the denominator is the budget the
operator's own configuration put on the contract.
The window is forgotten wherever the run is: a window that outlives its
conversation describes a turn the operator has left.
Four gates moved, and none was relaxed to a tolerance:
* the hand-written command list, the README table and the palette's narrowing
counts all take `/context` — the gates that make adding a command a
decision somebody makes.
* `f6_both_arms_are_handed_one_contract` counts a fourth `contract::session`
call. It is a reader bound as `reading`, not a turn bound as `contract`,
which is the distinction the assertion above it rests on.
* `f7_no_source_file_loops_over_provider_responses` exempts the decorator's
forwards BY PATH and adds a new assertion keeping the exemption a boundary:
a forward hands the call to the inner provider and must never read what
comes back. The forwards are subtracted rather than the file skipped, so the
module is still held to the no-top-level-loop rule — the half of that gate
that matters most. Same shape as 0.7.0's amendment of the spawn ban.
io-harness 0.67.0 opened the two entry points that take the caller's contract AND a steer inbox on one call. Until then a turn could carry a contract or be steered and never both, which is why 0.11.0 dropped steering rather than an oversight. Both arms now take `_steered`, positionally identical to what they took before plus the inbox. The inbox is a parameter of the harness's drive call and not a field of `TaskContract`, so the contract builder is untouched and the gate asserting an unconfigured session reproduces the harness's own default contract is not at risk. `/steer` is a word rather than a default, and the reason is that a delivered steer emits no event this interface can draw — the harness records it in the run's trace, not on the observer stream. A line sent automatically would leave the screen with no echo of any kind, which is the shape of the keystroke this release just stopped losing, and worse, because a queue is drawable state and a steer is not. `Steer::say` also has no undo: default-send would make every note typed to oneself mid-turn an instruction to the agent. So the operator is told three things and never a fourth. The words go into the transcript, because that is the only place they survive. The footer says "sent — the turn reads it at its next step", never "delivered": `Ok` means the channel took it and cannot mean a step read it. And anything still in the inbox when the turn ends is reported as not delivered, which is the one delivery fact this interface can state. `Ctrl+C` does not move. It stays on the observer's cancellation flag rather than `Steer::interrupt`: both end in a cancelled outcome, but changing the mechanism changes which path records it, for no operator-visible gain, on the one key a configuration file is refused permission to rebind. The queue is now on screen and on the status line. The surface follows the fleet view and not the three modal overlays — those are modal because a run is blocked inside a harness callback waiting for an answer, and nothing here is blocked. It stays out of `App::modal()`, takes `Up`, `Down` and `Esc` only while open, and lets every other key through. **It takes the blank row, and that is the whole reason it is visible.** At the viewport a running turn actually holds — eight rows — the streaming tail, the activity line, the rule and the three-row footer take six and the composer keeps its floor of one. There is no spare row above the composer, so a surface drawn from that allowance would have drawn nothing on every real session while passing its tests; and the allowance is widest on SHORT terminals, so it would have appeared on those and never on tall ones. Growing the viewport costs every session a row of scrollback forever, and taking the composer's own row is what this layout has refused since 0.1.0. The blank carries nothing by its own argument, the queue carries something, the frame is the same height either way, and the blank returns when the queue empties. Where it has exactly one row that row goes to the line that runs next, with the rest counted on the end of it — a surface that says three are waiting and names none of them has spent its only row on the half of the answer the status line already carries. The depth is on the status line AND the footer, from one method, because the footer is what any terminal of seven rows or more is given — a field on the line alone is a field no operator sees, which is how 0.12.0 shipped an invisible planning field. It is not cleared by `forget_run`: the queue belongs to the session and outlives the run, and blanking the count would stop reporting prompts that still fire. Recorded as US-IO-CLI-0.17.0-I02.
The queue is editable, `/compact` is real, and the interrupt has a gate. **Editing.** The mark lives on a `Cursor` in the queue's own module and not on `App`: a selection is a fact about the surface drawing it, meaningless with the surface shut, read by nothing but the rows — and on `App` it would be a fifth field about a list that already has three, which every mutation would then have to remember to keep honest. The verbs take the queue by reference, so `App` is still its one owner. The index is stored raw and every read clamps, because the queue drains from the front underneath it. `Enter` at an empty prompt takes the marked line OUT of the queue and into the composer — out rather than copied, so a turn ending mid-edit cannot run it twice. `Enter` puts it back at its own position, emptied it is a drop, and `Esc` restores the text as it was taken, which is 0.13.1's rule in the one place this release can honour it. Because the take needs an empty prompt, a half-typed line is never overwritten. A turn ending under an edit forgets the position and not the text: re-inserting would put a second copy behind a drain that is about to start, and both would run. `Up` stays prompt history. The surface's arrows are scoped to the open surface AND an empty prompt, so a recall in progress and the arrows inside a multi-line prompt never reach it at all. Bound at the bare composer they would have worked perfectly for anyone with something queued and silently cost history to everyone else — a feature nobody asked to trade, broken by a release about a different one. **`/compact`.** The roadmap said io-harness had no fold-now call and would have to lower the threshold and hope. It has two: `TaskContract::fold_now` for the next turn and `Steer::fold()` for the turn already running. So a request is a request and a fold is a fold, and one type carries both states because the defect this exists to prevent is a request wearing an outcome's words. Four documented conditions make an accepted request fold nothing, and the request is spent under all four. Two can be predicted — the timing, and folding configured off, which is read from the contract and said before anything leaves the process. Two can only be observed: an interrupt at the same boundary wins, and a conversation shorter than the fold's floor has no prefix to stand in for. So nothing but `EventKind::Compacted` may say a fold happened, and when the turn ends without one the line says the request was spent rather than that a fold occurred. What the summary replaced is named from the store, which keeps the observations a fold only stops sending. **The interrupt.** Eight new tests pin every branch of it, including the one no test could reach before: the mechanism. It is a source gate over the driver with comments stripped — the driver names `Steer::interrupt` in the comment saying why it is not used — asserting the cancellation flag is what the key sets and that the inbox is not, plus the live half through the real bridge so the gate does not rest on a variable's name. `tests/interrupt.rs` said in its own module doc that `Ctrl+C` "must reach `Steer::interrupt`", which was the opposite of what the driver has ever done. **Gates.** The clock rule now covers `SystemTime::now` and `.elapsed()` as well as `Instant::now` — the criterion always named three and the gate swept for one — walks `src/` recursively rather than one level, and refuses an aliased or glob-imported clock, scoped to import lines so prose arguing about time does not redden. The dependency sweep now reads build-dependencies and every target-conditional table: `nix` is on the forbidden list and cfg-guarded is exactly how a sandbox crate arrives. Dev-dependencies are pinned separately. Two gates were wrong rather than the code, and both are corrected here rather than worked around: the decorator gate matched a contiguous string that rustfmt splits across two lines, and a steer gate asserted a fold was not offered — both written against a half-built tree. And two tests asserted the wrong thing about behaviour older than this release. A drop notice naming which line went is the product working; `Up` with text typed has always recalled history and stashed the draft. Both now assert what F3 is actually owed — that the surface did not take a key belonging to the composer.
…p lying **`ctx N%` has been wrong since the field was added, and silently.** It divided by `ContextBudget::default()` with no remaining allowance, so an operator who set `[run.context]` — or `[run] max_tokens`, which the harness takes a share of — saw a percentage of a window they do not have. The comment above it claimed the denominator was "io-harness's own declared budget, asked of the harness rather than copied here", which was the shape of the answer without being the answer. The window now comes from the contract the turn is running under, through `Budgets::in_force` — which the driver already assigns at startup and once a turn, so the denominator costs no new driver line and cannot drift from the other budgets. No window means no field, rather than a fallback: a loud absence beats a plausible wrong number, which is the whole lesson of this defect. And it no longer waits for a fold. The numerator is the per-step assembly the harness already makes durable — `ContextEvent::assembled.est_tokens`, read once per step on the same anchor `commit_edits` uses, not once per event. `/status` now names the window even before the first turn, because that half is the contract's and is knowable at an idle prompt; it is the half an operator checking `[run.context]` came to the page for, and the old sentence withheld it. Note it is `[run.context]` and not a top-level `[context]` table — the root refuses unknown keys, so the table the first draft of this contract named would have been rejected outright. **And roughly twenty sentences across fourteen files said a turn cannot be steered.** They were the most dangerous thing in the tree for this release: a reader trusting them would conclude the work needed a dependency change it did not. Corrected in the release that falsifies them, along with the claim that containment decides whether a turn can be steered — it decides fan-out, and as of this release that is the only thing it decides. Sentences that are still TRUE were left alone, and that is the harder half: `Session::turn` and `turn_steered` really do still build their own contracts, the interrupt really does stay on the observer's flag, and a delivered steer really cannot be confirmed from the event stream. One test's title was backwards in the other direction — it said a steered turn is never cancelled through the observer, which is exactly the mechanism. Two claims found while sweeping, neither about steering: `src/events.rs` argued from a contract lineage this crate does not have (the conclusion held — nothing here calls `with_verification`), and eight files still described a viewport of four rows, which has been eight since 0.13.0. `tests/docs.rs` gains a gate for this release's version of the mistake: every comment line under `src/` and `tests/`, plus the README, against the phrases that assert a turn cannot be steered. Comment-lines-only is what makes it safe — the absent-lists inside existing tests are code and do not redden — and it has a positive half too, because a gate made only of absences passes on a file that says nothing. The README gained the queue's keys in prose, outside the generated table. They are borrowed while the surface is open rather than bound all session, which is why they are not in the key table, and the paragraph says so. Before this the release's headline feature had no documentation surface at all.
**Found by driving the binary, ninth release running.** `/context` totalled 4,363 tokens of 24,000 while the status line one keystroke away read `ctx 0%`. Neither number was wrong on its own: the page measures the whole request, and the field measured the observation section — which is what `ContextBudget` bounds and what `Compacted::after_tokens` reports, and which early in a turn is a rounding error next to the tool catalogue. The pair was wrong. Two surfaces disagreeing about a word, on one screen, one keystroke apart, is worse than either number is useful — and the percentage is what makes an operator open the page, so the page is the one it has to match. Both now come from one snapshot through one helper, by the same calls: the request's sections, totalled, over the window the contract declares. The trace read is kept as the fallback rather than deleted, because it answers a real window — a step lands before the completion call after it is snapshotted, so on the very first step the section the trace records is the only number there is. Re-cut the binary and re-drove the capture: `ctx 18%` against `4363 tokens of 24000`. The test asserts the field IS the page's total over the page's window rather than that the two are close, and asserts the fixture is not the degenerate agreement of two zeroes — which is the shape the defect wore on screen. The live evidence is in the release's evidence directory: four captures, their scripts and their timelines. It includes the run where a steer was sent and NOT delivered, because the turn reached its last step before the next boundary — the interface said so, which is the one delivery fact it can state.
… them Clippy's type-complexity rule caught the tuple this release grew to three, and its judgement is the right one: two facts read fine positionally and the third — the offered count 0.68.0 put on the event — is where `entry.2` stops saying what it holds at the call site. `asked` and `offered` are exactly the pair a reader of this module must not confuse, so they are named. No behaviour change; the same events fold in the same way.
Every check was green — 68 binaries, 893 tests, clippy clean, the live captures taken — and a read-only review of the diff found four real defects. All four are the same shape, and it is the shape that was being hunted: **the tests exercise each feature once, and the defect only appears the second time.** **`/clear` never forgot the window.** `/resume`, `/fork` and the rewind all drop the recorded request beside their `forget_run`; `/clear` did not — and it is the site `Seen::forget`'s own doc names first. So `/context` drew a whole conversation the operator had just discarded, on a session with no turns in it, while the `ctx` field beside it was blank because `forget_run` did clear that. Two surfaces disagreeing about one fact, which is what this release exists to stop, reappearing through a door nobody had opened yet. **A `/steer` on a turn's last step destroyed the queue.** Sending REMOVES each line to hand it over, and the send cannot fail while the inbox is alive, so the footer said "3 sent" and meant it. If the turn then ended before another step boundary, the lines were reported as not delivered and dropped — three prompts' worth of work gone, from the release whose headline is that a mid-turn prompt is no longer destroyed. The window is not narrow either: `/steer` is typed exactly when the agent looks close to done, which is exactly when there is no boundary left. They now go back to the FRONT of the queue, in order, and run as the next turns. A turn the operator STOPPED still drops them, because one press of the stop key must not start another — the old comment claimed a composer was waiting to catch them, and nothing ever put them in it. **`ctx N%` divided by the whole run budget rather than what is left.** io-harness assembles against the unspent remainder — and `context::window`'s own doc says so, in this crate, above the function no call site used that way. With 40k budgeted and 30k spent the real window is the floor while io-cli still printed the flat maximum and a share several times too small: under-reporting pressure exactly when there is pressure. Only moves when `[run] max_tokens` is set, which is why the fixtures missed it. **The `… N more` row counted what was outside the window rather than under it.** Nine queued, four rows, marked on the eighth: the rows read 6, 7, 8 and the count said six more, five of which were above. Correct on the first draw, when nothing is marked and the window starts at the top; wrong from the first arrow. The test that pinned it had baked the wrong number in. Plus three smaller ones. The fleet view and the queue could be open together, so the queue took the blank row for a surface drawn in its place and `Enter` reached into a queue behind a view covering it — a surface acting while invisible. `/steer do the thing` missed its guard and got the mid-turn refusal, which tells the operator to interrupt the turn first: the opposite of what the command does. And the fleet's own `Esc` guard reads a field this function has already emptied, so it was always true — pre-existing, found because the queue's version of the same guard gets it right and its comment describes the trap by name. Six new tests, each driving the SECOND use rather than the first. The review also confirmed what holds: no mutation of the queue leaves the status count stale, the one `unreachable!` is genuinely unreachable, no indexing path in the surface can panic, a mark cannot survive a drain, the decorator delegates all twelve provider methods with no default firing silently, and `Ctrl+C` is untouched on every path.
io-cli 0.17.0 — say something while it works
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release PR for io-cli 0.17.0 — say something while it works.
A prompt typed during a turn is kept instead of destroyed, drawn in a queue above the composer, editable there, and fired when the turn ends.
/steersends a queued line into the turn that is still running, delivered at the next step boundary.Ctrl+Cmeans exactly what it meant./contextsays what is in the model's window, read off the request that carried the turn./compactfolds when you say so and reports what folded./mcpsays how many tools a server offered.ctx N%is true for the first time for anyone who configured their own window.io-harness 0.67 → 0.69; both breaking changes land in tests, and no package was added to
Cargo.lock.Feature PR #45 — 8/8 green on three platforms, no flake.
git diff --quiet feat/0.17.0 developis clean, so this is the tree the gates ran on.Suite 68 binaries / 899 passed / 0 failed / 24 ignored, clippy and fmt exit 0, five live pty captures of the built binary against a real endpoint.
Record sealed and pinned at
sha256:4a155fef2fe54e9b016c96362f9d9b229bfb7e9439297a7d75af79985166c4a4,ultraship validateexit 0 over 52 checks..ultraship/is gitignored whole here, so the record and its evidence live on disk rather than in this diff.