P1b's pure half (docs/RUST.md). Three pieces, all testable with no
widget in sight:
- `event_model::Event::ToolEnd` gains `is_error`, read from the CLI's own
`tool_result` field by both the live translator and the import replay
(`import::tool_result_is_error`, one reader so the two cannot disagree
about the same conversation). Without it a result is all a card has,
and a broken call draws exactly as confidently as one that worked --
the missing state, not a wrong one. `#[serde(default)]`, so an older
transcript reads back as "not reported to have failed".
- `client_core::transcript_fold::ToolState`: Running, Deciding,
Succeeded, Failed, NoResult. The pair it exists for is the last two
against Succeeded-with-empty-output -- a call that printed nothing and
a call whose result never arrived leave the same empty string, and only
the session's status separates "still going" from "nobody found out".
- `client_core::tool_summary::parse_tool_input` and
`client_core::durations`: `ToolInput.kt`'s subject/description/timeout
split and `Durations.kt`'s span formatting, ported with their tests.
The echo driver's three-call run now has a failing middle call, so the
failed appearance is reachable from `ui-sandbox.sh` at all.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review of 73251d6's port of TranscriptSource/joinPages.
`TranscriptSource::page` answered `before == 0` with an empty `Vec`, which
is the same value it answers "this conversation has no more history" with.
That is the state the Kotlin keeps apart: `loadOlderPage` returns false at
`oldestSeq == 0` *without* touching `moreHistory`, and returns false on an
empty page *by latching it*. Collapsing the two moved AGENTS.md's paging
bug one layer down rather than fixing it. `page` returns `OlderPage` now --
`Events(vec![])` is the start of the conversation, `NothingLoaded` is not
an answer about the conversation at all.
`join_pages`' `debug_assert!` on seq ordering across the boundary is not a
true invariant: a peer note carries the seq its turn began at, which can be
older than the page it arrived in, so an ordinary transcript would have
panicked a debug build there. Replaced with the one the function exists to
enforce -- no tool id surviving in both halves.
`fetch_transcript_lines` stores `RawValue`'s exact server bytes, so the
"neither source can produce a newline" comment in `SessionCache::append`
now rests on the server's serializer staying compact rather than on a
local normalization. Checked with a `debug_assert!` in `append` and
`store_page` rather than trusted.
Tests for the failure half, which the port had none of: a 500 mid-page, a
cached line this build cannot read, and the `after` bound in the case that
actually carries one (the existing test asserted only the case with no
bound). `cargo fmt`, `cargo clippy --all-targets`, `cargo test` (112) clean
in client-core; `cargo check -p desktop-app` clean.
Closes docs/RUST.md's "client-core prerequisites for P1" box: the
cache-vs-server stitching TranscriptSource.kt does, and the
joinPages/healSplitMessage/adoptRun page-boundary healing
TranscriptItems.kt does, both ported into client-core with no UI
framework dependency.
Neither Kotlin file had a JVM unit test of its own, so the port used the
Kotlin source and AGENTS.md's "things that have bitten" paging incidents
as the spec instead of a test-for-test transcription. Both regressions
get a dedicated test: TranscriptSource::page refuses before == 0 before
touching the cache or the network (loadOlderPage's incident), and
adopt_run now runs on every page join rather than only the one where a
split call was found (the "one run drawn as two" incident).
fetch_transcript_lines (api.rs, additive) pairs each transcript line with
the exact server bytes via serde_json::value::RawValue rather than
re-serializing a parsed Value, so a cached line and a live SSE frame for
the same event agree byte-for-byte -- the fetch_transcript_page other
callers under iris/ depend on is untouched.
client-core: 85 -> 109 tests. cargo test/clippy --all-targets/fmt clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The merge that brought main into rustify added Event::LimitReached to the
server's drivers, but on this branch the enum lives in event-model, which
the merge left without it, so ai-server (and ui-sandbox.sh) did not build.
Definition copied from main's driver.rs; the fold mirrors TranscriptItems.kt's
LimitNote; the iris row shows the epoch until P1 brings a time formatter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both desktop-app's app.rs and the new Android transcript client (RUST.md's
I5) need the same page-fold and live-stream resume-cursor logic; per
CODE_RULES's "write the logic once" it now lives in client-core alongside
fold_event/group_tool_runs instead of being duplicated. desktop-app calls
the shared functions; its own copies and their tests moved with them.
Also fixes a clippy::collapsible_if in config.rs's percent_decode, found
while re-running clippy after this change (let-chains are stable now).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ports the non-Compose half of app/.../TranscriptItems.kt (TranscriptItem,
foldEvent, runIdFor, settleReply, placePeerNote, splitRun) and
ToolRows.kt (TranscriptRow, groupToolRuns) into transcript_fold.rs, with
7 tests covering delta accumulation, settling, tool-run grouping, a
ToolEnd with no matching start, and a question attaching to its call's
row versus drawing its own.
Not ported: TranscriptUnits.kt's flatten of a row into bounded Compose
list units (a fact about that UI framework, not the transcript), and
joinPages/healSplitMessage/adoptRun (page-boundary healing) -- both
recorded in CLIENT_CORE.md as left for whoever picks this up next.
Also noted there: event_model::Event has no Unknown catch-all, so an
event type this build doesn't recognise fails to parse rather than
degrading to a placeholder row, unlike Events.kt's hand-kept mirror.
cargo test (85 passed), clippy --all-targets and fmt clean.
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>