client-core: an unasked page is not an empty one, and two guarded invariants
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.
This commit is contained in:
1 parent
312455956d
commit
bf3479f5c4
4 files changed
+164
-45
No files matched your search
+27
-9
@@ -104,7 +104,14 @@ 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.
|
||||
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
|
||||
that no tool id survives in both halves -- the duplicate row it exists to
|
||||
prevent, checked rather than assumed. What it deliberately does *not*
|
||||
assert is seq ordering across the boundary: a peer note carries the seq
|
||||
its turn began at (`place_peer_note`), which can be older than the page
|
||||
it arrived in, so the two pages' seqs legitimately interleave there. An
|
||||
earlier draft asserted it and would have panicked in debug builds on an
|
||||
ordinary transcript.
|
||||
|
||||
**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.
|
||||
@@ -142,14 +149,25 @@ 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.
|
||||
the network**, answering `OlderPage::NothingLoaded`. 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.
|
||||
**The return type is `OlderPage`, not a `Vec`, and that is the guard.**
|
||||
The Kotlin's two falses are different answers -- `oldestSeq == 0`
|
||||
returns without touching `moreHistory`, an empty page latches it false
|
||||
-- so a port that answered both with an empty list would have moved the
|
||||
bug rather than fixed it, one layer down and out of sight of the screen
|
||||
that used to hold the check. `OlderPage::Events(vec![])` means the start
|
||||
of the conversation; `OlderPage::NothingLoaded` is not an answer about
|
||||
the conversation at all. Reviewed 2026-09-06.
|
||||
`paging_before_the_first_event_makes_no_request_at_all` asserts zero
|
||||
transport calls, not just the variant, since a request that happens to
|
||||
answer empty is exactly what caused the original bug, and
|
||||
`a_failing_server_page_is_an_error_rather_than_an_empty_one` plus
|
||||
`an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one`
|
||||
are the same rule for the two ways a page can fail.
|
||||
- **`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
|
||||
|
||||
Reference in new issue
Block a user