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() private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it } private fun session(id: String = "s") = cache().session(TranscriptAddress(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, id: String = "s") { dirOf(id).mkdirs() File(dirOf(id), name).writeText(lines.joinToString("\n", postfix = "\n")) } private fun seqs(lines: List?) = 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))) } }