A subagent is a second transcript owned by a session, in the same event model, with no process and no controls. The claude translator routes lines carrying parent_tool_use_id to a per-subagent translator and transcript under <session>/subagents/<tool_use_id>; three routes expose the list, a transcript page and the SSE stream. Echo grows /subagent [n] as the rig. On the phone a card with subagents ends in a chevron expander, collapsed by default, opening to outlined subcards styled like dev-updater's components; a subcard opens SessionScreen in read-only form, addressed through TranscriptAddress so paging, cache and stream are shared. Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
311 lines
13 KiB
Kotlin
311 lines
13 KiB
Kotlin
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(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<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)))
|
|
}
|
|
}
|