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:
1 parent
a1eedd7a78
commit
fe6a36bde4
3 files changed
+40
-2
No files matched your search
@@ -638,6 +638,20 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or
|
||||
further means a chunked backwards reader.
|
||||
`RUST_LOG=ai_server=debug` logs each page with what was asked and what
|
||||
came back, which is how to see a phone paging back in real time.
|
||||
- **Paging back has two failures that look like "there is simply no more
|
||||
history", and neither says anything on screen.** Both fixed 2026-08-31,
|
||||
both invisible on a loopback server and reproducible at `--delay 150`.
|
||||
The pager fires on the *first layout*, before any event has arrived --
|
||||
`moreHistory` starts true, so the history spinner is in the list and
|
||||
`visibleItemsInfo` is not empty -- and `before = 0` asks for the events
|
||||
before the first one, which is none, which is exactly how this code is
|
||||
told it has reached the start. `loadOlderPage` refuses `oldestSeq == 0`
|
||||
now. And `joinPages` only ran `adoptRun` on the path where a *split* call
|
||||
had been found, so a boundary landing cleanly between two calls -- most of
|
||||
them -- left one run of tool calls drawn as two groups with the seam
|
||||
wherever the reader happened to have paged. Reproducing either takes a
|
||||
boundary placed on purpose: the opening page is 80 events, so arrange the
|
||||
transcript so that event counts back from the newest.
|
||||
- **A page is 800 events and a screen is a handful of rows, and the two
|
||||
have no fixed ratio.** A run of thirty-five tool calls is one row; a reply
|
||||
is hundreds of text deltas folded into one. So anything that budgets in
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -179,18 +179,24 @@ private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): Str
|
||||
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
|
||||
* newer on what an end knows (the output, and whether it finished), which is the only way round
|
||||
* that loses nothing.
|
||||
*
|
||||
* The third thing is the *run*, and it is the one that used to be missed. Every page ends up here,
|
||||
* but [adoptRun] only ran on the path where a split call had been found -- so the boundary that
|
||||
* falls cleanly between two finished calls, which is most of them, went straight to concatenation
|
||||
* and left the older page's calls under the run name they were folded with. On screen: one run of
|
||||
* tool calls drawn as two groups, 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 they were skipping work
|
||||
* rather than saving it.
|
||||
*/
|
||||
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
|
||||
val (older, newer) = healSplitMessage(earlier, later)
|
||||
val startedEarlier =
|
||||
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
|
||||
if (startedEarlier.isEmpty()) return older + newer
|
||||
val endedLater =
|
||||
newer
|
||||
.filterIsInstance<TranscriptItem.ToolRun>()
|
||||
.associateBy { it.id }
|
||||
.filterKeys { it in startedEarlier }
|
||||
if (endedLater.isEmpty()) return older + newer
|
||||
val healed = older.map { row ->
|
||||
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
|
||||
if (row is TranscriptItem.ToolRun && half != null) {
|
||||
|
||||
Reference in new issue
Block a user