# client-core `client-core/` is the app's pure logic held once instead of twice, per RUST.md's recommendation item 1. It is a plain Rust library crate with no UI framework dependency of any kind, so it can outlive whichever one the app ends up drawing with (Masonry, iris, or something else -- see RUST.md). `event-model/` is its sibling: the wire shape both this crate and `server/` share, extracted from `server/src/session/driver.rs` and `session/transcript.rs` on 2026-09-04. Neither crate is wired into anything yet. `server/` re-exports `event-model` so its own behaviour is unchanged (`./run-tests.sh` covers it); `client-core` has no caller -- it exists for whichever experiment in RUST.md picks it up next (a Masonry or iris transcript screen, most likely). ## What's here, and what Kotlin file it replaces | `client-core/src/…` | Kotlin original | Status | |------------------------------------------|-------------------------------------------|--------| | `event-model/src/lib.rs` (shared crate) | `Events.kt` (the enum mirror) | Done | | `ansi.rs` | `Ansi.kt` | Done, ported test-for-test | | `highlight/mod.rs`, `languages.rs` | `Highlighter.kt`, `Languages.kt` | Done, ported test-for-test | | `highlight/markdown.rs` | `MarkdownSyntax.kt` | Done, ported test-for-test | | `transcript_cache.rs` | `TranscriptCache.kt` | Done, ported test-for-test | | `sse.rs` | `Sse.kt` (the framing half) | Done, new tests (Kotlin had none of its own beyond integration) | | `api.rs` | `Api.kt` | Partial -- see below | | `event_stream.rs` | `EventStream.kt` | Done | | `transcript_fold.rs` | `TranscriptItems.kt`, `ToolRows.kt` | Done -- see below | | `config.rs` | `ServerConfig.kt`'s `handleEnrollment` | New, desktop-only so far -- see below | | `transcript_source.rs` | `TranscriptSource.kt` | Done -- see below | | *(not ported, and may never be)* | `TranscriptUnits.kt` | Out of scope -- see below | Every file above whose Kotlin counterpart had a JVM unit test (`AnsiTest`, `HighlighterTest`, `TranscriptCacheTest`) has had every one of those test cases ported alongside it, plus new tests for the pieces that had none (`sse.rs`, `api.rs`, `event_stream.rs`, `transcript_fold.rs`, `transcript_source.rs` -- the Kotlin `TranscriptSource.kt`/`TranscriptItems.kt` had no JVM unit tests of their own, so these were written fresh against the Kotlin source and AGENTS.md's paging incidents as the spec). Test count by crate as of this writing: **109 in `client-core`**, 0 in `event-model` (its types carry no logic of their own to test -- `server/`'s own tests exercise them via `session::transcript`'s round-trip coverage). ## Correspondence notes worth knowing before touching either side - **`ansi.rs`'s `StyledText`/`Style`/`Rgb`** stand in for Compose's `AnnotatedString`/`SpanStyle`/`Color`, since this crate has no Compose. `StyledText` is plain text plus a `Vec<(Range, Style)>` of non-overlapping spans. Whatever UI framework ends up consuming this crate maps `Style` onto its own text-styling type; nothing here should change to accommodate a particular one. - **`highlight`'s `Span`/`Kind`** use **char indices, not byte offsets** (`Vec` internally), mirroring the Kotlin original's `Char`-indexed strings. `highlight::span_text` turns a `Span` back into text for a caller working the same way; a caller that wants byte offsets into a `&str` has to convert. - **`transcript_cache.rs`'s `SessionCache::guard`** found a real translation bug while it was being written: an early draft let a *damaged* chunk (one file unreadable, discard just this session) and a genuine I/O failure (disk gone, disable the whole cache) both surface as the same `Err` from one closure, which would have disabled every session's cache over a single corrupt chunk. Fixed by checking a thread-local "was this damage" flag before deciding which failure mode it was -- see the comment on `guard` and the commit message for `transcript_cache.rs`. ## What `api.rs` covers, and what it does not yet `ApiClient` wraps a `Transport` trait (network I/O kept out from behind, so `ApiClient` and `event_stream::follow_session_events` are tested with a fake transport and no server). `UreqTransport` is the only real implementation, backed by `ureq` -- see its Cargo.toml comment for why (blocking, already a project dependency, no extra TLS crate needed since `ureq::tls::Certificate::from_pem` reads the pinned CA directly). Covered: session list/read, message send, unqueue, answer, interrupt, stop, start, rename, cwd, model, permission-mode, notify, command, compact, delete, and one transcript page. **Not covered, and each is real work rather than a stub to fill in:** setups (`/setups*`, machine and provider discovery), the file explorer (`/setups/{id}/dir|file`), usage (`/usage`), models (`/models*`, HuggingFace browsing and downloads), attachments (`/sessions/{id}/attachments`), importing (`/setups/{id}/importable*`), and the `/notifications` stream. `server/src/routes.rs`'s module doc is the full table to work from when one of these is next. ## What `transcript_fold.rs` covers, and what it does not yet `fold_event` covers every `Event` variant server/ can produce today, including tool-call/question/image attachment and peer-message placement. `group_tool_runs` groups adjacent calls into `TranscriptRow::Tools`. `join_pages` (with `heal_split_message` and `adopt_run`, both private) is now ported too, 2026-09-06 -- the page-boundary healing that merges a tool call split across two fetched pages, rejoins a message a boundary cut through, and renames a run of tool calls onto whichever name is already on screen. Ported with AGENTS.md's "things that have bitten" incidents as the spec rather than a JVM test file (`TranscriptItems.kt` had none of its own): `a_clean_boundary_between_two_finished_runs_is_still_healed_into_one_run` is the regression test for the bug that shipped -- `adopt_run` must run on *every* join, not only the one where a split call was found, or a boundary landing cleanly between two already-finished calls (most of them) leaves one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`, `a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`, and `adopt_run_never_renames_into_a_question_row` cover the other three edges the Kotlin doc calls out. **Known gap, and a decision for whoever closes it:** `event_model::Event` has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror. A server newer than this build that adds an event type will fail to parse that line rather than degrading to a placeholder row. Closing this means deciding how `event_model` itself represents "a shape I don't recognise" -- a shared-model decision affecting `server/` too, not a `client-core`-only fix, so it is recorded here rather than silently worked around. ## `config.rs`: `EnrolledServer` `EnrolledServer` (host, port, bearer token) plus `parse_link`, which reads the exact `aiapp://enroll?host=H&port=P&token=T` deep link `wg-app-link`'s `enroll` mints and `ServerConfig.kt`'s `handleEnrollment` parses on the phone -- so any Rust client enrols from the same text a phone would scan as a QR, with no second format invented for it (RUST.md's E4, DECISIONS.md 2026-09-05). Deliberately does not decide where it is persisted or under what file permissions -- a phone seals its token in the Android Keystore, `iris/desktop-app/src/config.rs` writes it to `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json` at 0600 -- since that is caller-specific (the code rules' "ask for the least you need"). Its only caller today is `desktop-app`; a future Android build of this crate would be a second one, not a reason to move the type. ## What `transcript_source.rs` covers, and what it does not `TranscriptSource` is the seam a session screen asks for a page, ported test-for-test against the Kotlin doc rather than a JVM test file (there wasn't one): `cached_opening`, `probe`, `fetch_opening`, `page` and `follow`, each matching its Kotlin namesake's contract -- including `probe`'s three-way outcome (matches / cache purged / unreachable, told apart so a caller never treats "couldn't ask" as "was wrong") and `page`'s cache-vs-server split bounded by `covered_up_to`. Two additions beyond a literal port, both load-bearing: - **`page(before, ..)` refuses `before == 0` before touching the cache or the network**, returning an empty page immediately. This is AGENTS.md's `loadOlderPage` incident (`before = 0` is "no event before the first one," indistinguishable from "reached the start of history" if a caller ever asks it) moved out of the Kotlin screen and into this layer, so every future caller gets the guard rather than having to remember it. `paging_before_the_first_event_makes_no_request_at_all` asserts zero transport calls, not just an empty result, since a request that happens to answer empty is exactly what caused the original bug. - **`fetch_transcript_lines`** (new in `api.rs`) hands back each line paired with the exact server bytes it came from, via `serde_json::value::RawValue` rather than re-serializing a parsed `Value` -- the cache and a live SSE frame for the same event have to agree byte-for-byte, which is exactly what the `serde_json` float-rounding bug (AGENTS.md) was about. The existing `fetch_transcript_page` is untouched (other callers under `iris/` depend on its signature); the two share a `transcript_path` helper so the query string is written in one place. **Not ported:** `EventStream.kt`'s reconnect-with-backoff loop, and `TranscriptSource.close`'s ability to cancel a live stream from another thread. Both are wall-clock/thread-lifetime policy that belongs to whichever runtime embeds this crate (iris's own timers, a Tokio task, a Kotlin coroutine scope), not to this pure logic -- `follow` is the same "write to the cache, then hand the frame to the caller" decorator `iris/desktop-app/src/app.rs` and `iris/android-app/src/transcript_client.rs` already hand-wrote around `event_stream::follow_session_events` before this existed; the cache write moved into one shared place so a third caller does not repeat it again by hand. ## What is not started at all - **The markdown *block* model beyond syntax spans** -- `highlight/markdown.rs` colours a `.md` file or fence for the highlighter, but does not build the block tree (headings, lists, tables, fences as distinct nodes) that a renderer walks to lay out prose versus code versus a table. `CodeFence.kt`'s use of `org.intellij.markdown` for that full CommonMark AST is Compose rendering plumbing, not something to port as-is; a Rust UI layer will want its own block parser or a crate for it, decided alongside the framework choice in RUST.md. - **`TranscriptUnits.kt`** (see above) -- deliberately out of scope, since it flattens a row into bounded units for a *specific* lazy-list framework's composition cost, which is a fact about that framework rather than about the transcript. ## Verifying `./run-tests.sh` from the repo root now runs `event-model`, `client-core` and `server` in that order (each `cargo test`, forwarding arguments the same way it always has). From `client-core/` directly: `cargo test` (109 tests), `cargo clippy --all-targets`, `cargo fmt` -- all clean as of this writing (2026-09-06).