Skip to content

io-cli 0.36.0 - #94

Merged
aakashpawar1999 merged 12 commits into
mainfrom
develop
Sep 3, 2026
Merged

aakashpawar1999 merged 12 commits into
mainfrom
develop

Conversation

@aakashpawar1999

Copy link
Copy Markdown
Contributor

The release PR for 0.36.0. Everything in it is PR #93, which merged into develop with 8/8 green on all three platforms; git diff --quiet feat/0.36.0 develop confirms nothing else landed on develop while the release was in flight.

io is reachable from an editor. io acp serves the Agent Client Protocol on stdin and stdout. The direct dependency set is still exactly ten names — the wire is written here rather than taken, because the official crate's graph brings a second async executor family over the real stdin.

The io-harness pin moves 0.74 → 0.76.0.

The adversarial review found four High behind a fully green suite, the fifteenth consecutive release: nothing streamed, session/request_permission was never sent while six documents said it was, session/cancel was unreachable during a turn, and /cost drew a ceiling as an exact fact. The first and last are fixed; the middle two are shipped as stated limitations, on the fallback_scope this release declared in advance.

104 binaries / 1,896 passed / 0 failed / 37 ignored. fmt, clippy -D warnings, doctests and ultraship validate (113 checks) all clean. Live gate run against a real provider through the built binary.

…as unknown

io-harness 0.76.0 makes Usage::cache_write_tokens an Option<u64>. That is the
shape this module's own first honesty rule has always wanted — src/cost.rs has
said since 0.13.0 that a token count a provider reported no usage for is
unknown, never zero — and it is not a hypothetical distinction: every OpenRouter
call reports None, and OpenRouter is where this product's evidence is taken.

unwrap_or(0) would have compiled perfectly and put a fabricated figure on the
page an operator reads to find out what a turn cost. Instead:

- add_reported folds two counts. Two silences stay a silence; one silence beside
  a number keeps the number, because a turn that reported 1,000 writes and a turn
  that reported nothing together wrote at least 1,000. The residual — a mixed sum
  is a floor rather than a total — is stated in the function's own doc rather
  than hidden, matching how this page already treats an unpriced model's money.
- The remainder inherits the silence. "of which fresh" is the prompt less both
  cached parts, so an unreported write makes it unknown too.
- The cache block is drawn when reads are non-zero or writes are a reported
  non-zero, so a provider reporting reads and no write count still shows the
  written row as unknown. An absent row reads as zero.

src/ compiled clean on the pin; only tests/cost.rs broke, at exactly the sites
the pre-code audit named. tests/stats.rs and tests/status.rs were predicted safe
because they build Usage through ..Default::default(), and both compiled
untouched.

tests/cost.rs 12 passed / 0 failed, up from 10. Both new arms carry their
sabotage in the doc comment.
…t is not sent

The adversarial review found four High behind a fully green suite. Three of the
ACP ones shared a root cause.

The serve loop awaited handler.handle() inside a tokio::select! arm. For a
session/prompt that call IS the whole turn, so the outbound channel was not
polled again until the turn returned: every session/update queued and the client
received the entire conversation AFTER being told the turn had ended. The guide
promises "the answer, streamed as the provider returns it". `biased` could not
help, because the loop was not at the select point to be biased about.

The writer now runs on its own task and owns the writer outright, so a
notification sent from inside a running turn is written while the turn is still
running. No Mutex: a lock held across an await on a pipe a slow client is not
draining is a deadlock with no error message. Verified against a real provider —
message chunks and a thought chunk arrive before the result.

The suite could not see it. Nothing drove serve_with with a live channel, and
the loop test's updates came from the handler's return value, which is a path
the real handler never uses. Both loop tests now run over a duplex pipe, and a
new test asserts the wire ORDER across a handler that sends while it works.

Two more from the same review:

- ApprovalDecided reported in_progress for every decision, discarding the
  approve/deny/defer the harness carries. Since the ACP approver always denies,
  every grey-tier action left a cell spinning for ever on a call that would
  never complete.
- The refusal notification put the target PATH in toolCallId, so it named a cell
  that had never been announced and a conforming client had nothing to apply it
  to. It is built from the run and step now, as every tool_call frame is.

And the false claims. Five documents said io raises session/request_permission.
It does not: it sends a session/update refusal. Raising the request means
awaiting the answer, and routing that back into the run is not wired — a prompt
whose outcome is ignored is worse than no prompt. CONTRACT.md, the guide, the
CHANGELOG, --help and three comments now say what the code does. The model is
told the interface could not route the approval, not that the operator denied
it, because the operator was never asked.

Separately, on the pin side: a mixed cache-write fold drew "of which fresh" as
an exact number when it is a ceiling. add_reported folds one reporting call and
one silent one to Some(n), and n is a floor; subtracting a floor from the prompt
gives an upper bound, too large by exactly the amount nobody measured. That is
verbatim the failure the adjacent comment says must not happen — the guard only
distinguished "no call reported" from "some call reported". Total gains
unreported_writes, the written row reads "1.0k or more", and fresh reads
unknown. Reachable in practice: openai_wire reports no cache-write count and
Anthropic reports one only where a cache was created.

104 binaries / 1,896 passed / 0 failed / 37 ignored. fmt, clippy -D warnings and
doctests clean.
The adapter reads newline-delimited JSON-RPC off stdin and writes frames to
stdout, which is AsyncBufReadExt::read_line over tokio::io::stdin(). Features of
a name already in the tree rather than a name of its own, so the direct
dependency set stays at ten and tests/dependencies.rs is untouched.

The argument for hand-writing the wire instead of taking agent-client-protocol
lives beside the feature list: that crate's graph brings a second async executor
family — async-io, async-process, async-task, blocking, polling — with its stdio
transport putting blocking::Unblock over the real stdin and stdout. src/stdin.rs
exists because of what a second owner of stdin cost in 0.13.1.
io-cli 0.36.0 — the Agent Client Protocol adapter, and the io-harness 0.76.0 pin
@aakashpawar1999
aakashpawar1999 merged commit 40ed470 into main Sep 3, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant