Page back at all, and merge the run the boundary fell through

Two defects on the same path, the second found while trying to reproduce
the first. Both are invisible against a loopback server and both show up at
`--delay 150`, which is what a phone over the tunnel actually costs.

**A run of tool calls came back as two groups.** `joinPages` heals three
things across a page boundary -- a message cut in half, a call separated
from its result, and the *run* a group is named after -- but the third only
ran on the path where a split call had been found. A boundary landing
cleanly between two finished calls, which is most of them, went straight to
concatenation and left the older page's calls under the name they were
folded with. On screen, one run of twelve drawn as "Called 7 tools" and
"Called 5 tools", with the seam wherever the reader happened to have paged.
The two early returns were an optimisation on a list the size of one page,
and what they saved was the work.

**And nothing older loaded at all.** The history pager fires on the first
layout, before a single event has arrived: `moreHistory` starts true, so the
spinner is in the list, so `visibleItemsInfo` is not empty, and with no
units loaded the room ahead adds up to zero. It then asked for the events
`before = 0` -- the ones before the first one, which is none -- and an empty
page is precisely how this code is told it has reached the start of the
conversation. So `moreHistory` latched false, racing the opening page's own
write of true, and a session that lost the race stopped one page from its
newest end with no spinner and nothing on screen to say why. Guarded inside
`loadOlderPage`, because it is a fact about the question rather than about
who asked: the post-open fetch reaches it too, on the path where the opening
page failed and left `oldestSeq` unset.

Checked both ways round on the emulator, with the boundary placed on
purpose (the opening page is 80 events, so it is a matter of counting back
from the newest): 7 + 5 without the join fix, one group of 12 with it. And
the case the change had no reason to touch still holds -- a boundary that
*does* split a call, which is the path that always worked, and one through a
streamed reply, which `healSplitMessage` owns and this does not go near.
This commit is contained in:
iris committed 2026-08-31 22:56:23 -04:00
1 parent a1eedd7a78
commit fe6a36bde4
3 files changed
+40 -2

No files matched your search

@@ -503,6 +503,24 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
* value, which does not change under a running one.
*/
suspend fun loadOlderPage(limit: Int = HISTORY_PAGE): Boolean {
// Nothing is loaded, so there is no "before" to ask about, and asking anyway is not a
// harmless no-op: `before = 0` fetches the events before the first one, which is none,
// and an empty page is how this function is told it has reached the start of the
// conversation -- so it would latch `moreHistory` false and the session could never be
// paged back at all.
//
// The window it fires in is the first layout. `moreHistory` starts true, which puts the
// history spinner in the list, which makes `visibleItemsInfo` non-empty before a single
// event has arrived -- and with no units loaded the room ahead adds up to zero, so the
// pager fetches. On a loopback server the opening page beat it and nothing was ever
// wrong; at `--delay 150`, which is what a phone over the tunnel actually costs, it won
// the race and the transcript stopped one page from its newest end with no spinner and
// nothing to say why.
//
// Guarded here rather than at the two callers because it is a fact about the question,
// not about who is asking: the post-open fetch reaches it too, on the path where the
// opening page failed and left `oldestSeq` unset.
if (oldestSeq == 0L) return false
// The fetch *and* the fold, both off the thread that draws. Only the fetch used to be,
// and the fold is the expensive half: `foldEvent` returns a new list per event, so a page
// of [HISTORY_PAGE] events is that many copies of a list growing to that length -- around