Keep visited transcripts on the phone

Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.

Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.

Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.

Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.

transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.

Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 15:00:25 -04:00
1 parent 8881a40919
commit a802522039
17 files changed
+2140 -74

No files matched your search

@@ -0,0 +1,310 @@
package com.example.aiapp
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.junit.jupiter.api.io.TempDir
/**
* The cache's file logic, which is the half of the transcript cache that can be wrong without
* anything on screen saying so: a page served short, a chunk served across a gap, or a run of lines
* whose recorded coverage does not match what is in it.
*
* Lines here are the shape the server writes -- `{"seq":N,"ts":T,"type":...}` -- because that is
* what the cache reads its two facts off. Nothing parses JSON on either side.
*/
class TranscriptCacheTest {
@field:TempDir lateinit var temp: File
private val said = mutableListOf<String>()
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
private fun session(id: String = "s") = cache().session(id)
private fun line(seq: Long, type: String = "toolStart") =
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
private fun delta(seq: Long) = line(seq, "assistantText")
private fun dirOf(id: String = "s") = File(temp, "v1/host_8443/$id")
private fun names(id: String = "s") = dirOf(id).list().orEmpty().sorted()
private fun write(name: String, lines: List<String>, id: String = "s") {
dirOf(id).mkdirs()
File(dirOf(id), name).writeText(lines.joinToString("\n", postfix = "\n"))
}
private fun seqs(lines: List<String>?) = lines?.map {
Regex("\"seq\":(\\d+)").find(it)!!.groupValues[1].toLong()
}
@Test
fun an_appended_run_is_one_open_chunk_and_its_newest_line_is_the_tail() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
cache.flush()
assertEquals(listOf("1-open.raw.jsonl"), names())
assertEquals(CachedTail(3, line(3)), cache.tail())
assertEquals(listOf(line(2), line(3)), cache.newest(2))
// More than there is is what there is, which is a short opening window and not a failure.
assertEquals(3, cache.newest(80).size)
}
@Test
fun a_gap_in_the_stream_closes_the_open_chunk_under_the_end_it_turned_out_to_have() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
// What a `reset` looks like from here: the next event is not the one after the last.
cache.append(line(90), 90)
cache.flush()
assertEquals(listOf("1-4.raw.jsonl", "90-open.raw.jsonl"), names())
// Nothing is served across the gap: the suffix is the newest chunk alone.
assertEquals(listOf(line(90)), cache.newest(80))
assertEquals(CachedTail(90, line(90)), cache.tail())
}
@Test
fun an_event_already_covered_is_not_written_again() {
val cache = session()
(1L..3L).forEach { cache.append(line(it), it) }
cache.append(line(2), 2)
cache.flush()
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
}
@Test
fun an_adjacent_page_extends_the_suffix_and_a_gap_stops_it() {
val cache = session()
(100L..102L).forEach { cache.append(line(it), it) }
cache.flush()
// Adjacent: its end is the open chunk's first.
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
assertEquals(listOf(98L, 99L), seqs(cache.page(before = 100, limit = 2, rows = false)))
assertEquals(60L, seqs(cache.newest(80))?.first())
// Behind a gap: kept on disk, because paging usually closes the gap, but never served
// across it.
assertTrue(cache.storePage((1L..9L).map { line(it) }, 1, 10, rows = true))
assertNull(cache.page(before = 10, limit = 5, rows = false))
assertEquals(60L, seqs(cache.newest(200))?.first())
}
@Test
fun a_page_that_overlaps_what_is_here_is_not_stored() {
val cache = session()
cache.append(line(100), 100)
cache.flush()
assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true))
assertFalse(cache.storePage((50L..79L).map { line(it) }, 50, 80, rows = true))
assertFalse(cache.storePage(emptyList(), 40, 60, rows = true))
assertEquals(listOf("100-open.raw.jsonl", "60-100.rows.jsonl"), names())
}
@Test
fun a_miss_is_null_and_never_an_empty_page() {
val cache = session()
(100L..102L).forEach { cache.append(line(it), it) }
cache.flush()
// At or below where the run starts, so what the reader is scrolling into is the server's.
// An empty list here would be read as the start of the conversation and would stop the
// transcript scrolling back at all.
assertNull(cache.page(before = 100, limit = 40, rows = true))
assertNull(cache.page(before = 40, limit = 40, rows = true))
assertNull(session("never-visited").page(before = 100, limit = 40, rows = true))
}
@Test
fun a_page_starts_from_anywhere_inside_the_run_not_only_at_a_boundary() {
val cache = session()
(1L..10L).forEach { cache.append(line(it), it) }
cache.flush()
// Where a warm open leaves the cursor: in the middle of the live run, because the screen
// drew the newest lines of it. A cache that could only answer at a chunk boundary would
// send this to the server -- and the page that came back would overlap the run and be
// thrown away, so the whole of the scroll back would be fetched again on every visit.
assertEquals(listOf(5L, 6L, 7L), seqs(cache.page(before = 8, limit = 3, rows = false)))
assertEquals((1L..7L).toList(), seqs(cache.page(before = 8, limit = 99, rows = false)))
}
@Test
fun a_page_counted_in_rows_folds_each_delta_run_into_one_and_cuts_only_between_rows() {
val cache = session()
// Two replies of three deltas each, split by a tool call: the same fixture as the
// server's `coalescing_counts_rows_and_joins_delta_runs`.
val lines =
listOf(delta(1), delta(2), delta(3), line(4), delta(5), delta(6), delta(7), line(8))
write("1-9.raw.jsonl", lines)
cache.append(line(9), 9)
cache.flush()
// Three rows: the tool call at 8, the run 5..7, and the tool call at 4. The cut lands
// between rows, so the older run is not started.
assertEquals(
listOf(4L, 5L, 6L, 7L, 8L),
seqs(cache.page(before = 9, limit = 3, rows = true)),
)
// One row is one whole run, however many deltas it is made of.
assertEquals(listOf(8L), seqs(cache.page(before = 9, limit = 1, rows = true)))
// A page of lines counts lines, which is what the anchor restore asks for.
assertEquals(listOf(7L, 8L), seqs(cache.page(before = 9, limit = 2, rows = false)))
}
@Test
fun a_row_page_crosses_a_chunk_boundary_and_stops_short_at_the_oldest_chunk() {
val cache = session()
write("5-9.raw.jsonl", listOf(delta(5), delta(6), line(7), delta(8)))
cache.append(delta(9), 9)
cache.append(line(10), 10)
cache.flush()
// A run straddling the boundary is one row, as it will be once folded.
assertEquals(listOf(8L, 9L, 10L), seqs(cache.page(before = 11, limit = 2, rows = true)))
// Asking for more rows than the suffix holds is a short page, not a failure and not a
// claim that the conversation starts here.
assertEquals((5L..10L).toList(), seqs(cache.page(before = 11, limit = 40, rows = true)))
}
@Test
fun the_floor_for_a_fetch_is_the_nearest_chunk_at_or_below_it() {
val cache = session()
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
write("10-40.rows.jsonl", (10L..39L).map { line(it) })
cache.append(line(90), 90)
cache.flush()
// The run behind the gap, which is what makes the fetched page adjacent to it: a page
// fetched before 90 with a floor of 39 stops at 40 and closes the gap exactly.
assertEquals(40L, cache.coveredUpTo(90))
assertEquals(40L, cache.coveredUpTo(41))
assertEquals(10L, cache.coveredUpTo(10))
// Nothing at or below the oldest chunk's start, so the page is bounded only by its limit.
assertNull(cache.coveredUpTo(9))
}
@Test
fun a_newest_chunk_that_is_not_raw_discards_the_session() {
val cache = session()
write("1-10.rows.jsonl", (1L..9L).map { line(it) })
// Only reachable by dying between closing one live run and opening the next, and there is
// no cursor to be read off a coalesced line -- so the open is a cold one.
assertNull(cache.tail())
assertFalse(dirOf().exists())
}
@Test
fun a_half_written_last_line_is_dropped_and_the_file_repaired() {
val cache = session()
dirOf().mkdirs()
File(dirOf(), "1-open.raw.jsonl").writeText(line(1) + "\n" + line(2) + "\n" + """{"se""")
assertEquals(CachedTail(2, line(2)), cache.tail())
assertEquals(line(1) + "\n" + line(2) + "\n", File(dirOf(), "1-open.raw.jsonl").readText())
// And the run continues from where the good tail left off.
cache.append(line(3), 3)
cache.flush()
assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80)))
}
@Test
fun damage_anywhere_else_discards_the_session_when_a_read_reaches_it() {
val cache = session()
write("1-open.raw.jsonl", listOf(line(1), "not ours", line(3)))
// Not seen by the tail, which reads the newest line and stops -- reading a chunk from its
// end is exactly not reading the rest of it, and that is what keeps a warm open cheap on
// a conversation of tens of megabytes.
assertEquals(CachedTail(3, line(3)), cache.tail())
// Reached by a read that walks past it, and there is no honest way to say what a chunk
// covers with a line of it unreadable -- so what is served is nothing, and the session
// opens cold from here on.
assertEquals(emptyList(), cache.newest(80))
assertFalse(dirOf().exists())
assertTrue(said.any { it.contains("damaged") })
}
@Test
fun a_name_this_does_not_recognise_is_ignored() {
val cache = session()
write("notes.txt", listOf("hello"))
write("1-open.raw.jsonl", listOf(line(1)))
assertEquals(CachedTail(1, line(1)), cache.tail())
}
@Test
fun a_chunk_larger_than_one_read_block_is_walked_across_the_boundaries() {
val cache = session()
// Well past the 64 kB block the backwards reader takes at a time, so a page has to be
// stitched across several of them -- including a line that straddles a boundary, which
// is the case nothing else here would notice going wrong.
val padding = "x".repeat(300)
val lines = (1L..500L).map { """{"seq":$it,"ts":1.5,"type":"toolStart","id":"$padding"}""" }
write("1-open.raw.jsonl", lines)
assertEquals(500L, cache.tail()!!.seq)
assertEquals(lines.takeLast(80), cache.newest(80))
assertEquals(lines.subList(0, 400), cache.page(before = 401, limit = 999, rows = false))
// And a non-ASCII line, whose bytes a naive split could cut through a character.
val accented = """{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}"""
cache.append(accented, 501)
cache.flush()
assertEquals(accented, cache.tail()!!.line)
}
@Test
fun eviction_takes_the_least_recently_touched_and_never_the_one_on_screen() {
val cache = cache()
listOf("old", "middle", "open").forEachIndexed { at, id ->
write("1-open.raw.jsonl", List(50) { line(it + 1L) }, id = id)
dirOf(id).setLastModified(1_000_000L + at * 1000L)
}
val each = dirOf("old").walkTopDown().filter { it.isFile }.sumOf { it.length() }
// Room for two of the three, so the oldest goes -- and the session being read never does,
// however long ago it was last touched.
cache.evictToBudget(keep = "open", budget = each * 2)
assertEquals(listOf("middle", "open"), File(temp, "v1/host_8443").list()!!.sorted())
cache.evictToBudget(keep = "open", budget = 0)
assertEquals(listOf("open"), File(temp, "v1/host_8443").list()!!.sorted())
}
@Test
fun retaining_deletes_exactly_the_sessions_the_server_no_longer_lists() {
val cache = cache()
listOf("a", "b", "c").forEach { write("1-open.raw.jsonl", listOf(line(1)), id = it) }
cache.retainOnly(setOf("a", "c"))
assertEquals(listOf("a", "c"), File(temp, "v1/host_8443").list()!!.sorted())
}
@Test
fun size_and_purge_are_the_two_halves_of_the_reload_button() {
val cache = session()
assertEquals(0L, cache.bytes())
(1L..5L).forEach { cache.append(line(it), it) }
cache.flush()
assertTrue(cache.bytes() > 0)
cache.purge()
assertEquals(0L, cache.bytes())
assertNull(cache.tail())
// And the session is usable again straight afterwards, which is what a reload does next.
cache.append(line(9), 9)
cache.flush()
assertEquals(listOf(9L), seqs(cache.newest(80)))
}
}