package com.example.aiapp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** * Where one turn ends and the next begins, which is the part of the fold that had no way of saying * anything was wrong: two replies run together read as one long answer, and the seam is somewhere * in the middle of a sentence. */ class TranscriptItemsTest { private var seq = 0L private fun fold(items: List, event: SessionEvent) = foldEvent(items, SeqEvent(seq = ++seq, ts = 1.0, event = event)) private fun fold(vararg events: SessionEvent) = events.fold(emptyList()) { items, event -> fold(items, event) } private fun texts(items: List) = items.filterIsInstance().map { it.text } @Test fun an_authentication_failure_stays_visible_as_an_error_row() { val entry = SeqEvent( seq = 7, ts = 1.0, event = SessionEvent.AuthenticationRequired("sign in again"), ) assertEquals( TranscriptItem.ErrorMsg(7, "sign in again"), foldEvent(emptyList(), entry).single(), ) } @Test fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() { val items = fold( SessionEvent.AssistantText("You'll get the one-line notice when it lands."), SessionEvent.Status("idle"), SessionEvent.AssistantText("Dev Updater fix is pushed."), ) assertEquals( listOf("You'll get the one-line notice when it lands.", "Dev Updater fix is pushed."), texts(items), ) } @Test fun deltas_of_one_reply_still_accumulate_into_it() { val items = fold( SessionEvent.AssistantText("Still "), SessionEvent.AssistantText("running "), SessionEvent.Status("running"), SessionEvent.AssistantText("its tests."), ) assertEquals(listOf("Still running its tests."), texts(items)) } @Test fun completed_text_replaces_provisional_deltas_live() { val items = fold( SessionEvent.AssistantText("I'll inspect the color-c concrete implementation"), SessionEvent.AssistantTextFinal( "I’ll inspect the color-correction TODO and the relevant design." ), ) assertEquals( listOf("I’ll inspect the color-correction TODO and the relevant design."), texts(items), ) } @Test fun completed_text_discards_provisional_deltas_across_a_page_boundary() { val earlier = fold(SessionEvent.AssistantText("1. provisional section\n\n")) val later = fold( SessionEvent.AssistantTextFinal("1. final first section\n\n2. final second section") ) assertEquals( listOf("1. final first section\n\n2. final second section"), texts(joinPages(earlier, later)), ) } @Test fun a_final_value_after_a_settled_reply_is_a_new_message_across_a_page_boundary() { val earlier = fold( SessionEvent.AssistantText("Previous answer."), SessionEvent.Status("idle"), ) val later = fold(SessionEvent.AssistantTextFinal("Next answer.")) assertEquals( listOf("Previous answer.", "Next answer."), texts(joinPages(earlier, later)), ) } /** * The rule that replaced the wall of reports. A turn that starts with nothing recorded in front * of it -- a subagent finishing, the CLI picking a conversation back up -- leaves two replies * abutting, and only the break says they are two. */ @Test fun two_replies_that_meet_are_separated_by_a_rule_and_nothing_else() { val items = fold( SessionEvent.AssistantText("Launched it."), SessionEvent.Status("waiting"), SessionEvent.AssistantText("Noted."), ) assertEquals(3, items.size, "$items") assertTrue(items[1] is TranscriptItem.TurnBreak, "$items") assertEquals(listOf("Launched it.", "Noted."), texts(items)) // Distinct keys: the break shares the reply's seq, and two items with one key take the // app down. assertEquals(3, items.map { it.key }.toSet().size, "$items") } /** * A reply after anything that draws a row of its own needs no rule: that row is the boundary. */ @Test fun a_reply_after_a_row_of_its_own_gets_no_rule() { val items = fold( SessionEvent.AssistantText("Launched it."), SessionEvent.Status("idle"), SessionEvent.UserMessage("carry on", null, emptyList()), SessionEvent.AssistantText("Noted."), ) assertTrue(items.none { it is TranscriptItem.TurnBreak }, "$items") } @Test fun a_repeated_tool_start_is_still_one_row() { val start = SessionEvent.ToolStart("exec-1", "Bash", "{\"command\":\"cargo test\"}") val items = fold( start, SessionEvent.AssistantText("The test run is still going."), start, SessionEvent.ToolEnd("exec-1", "finished"), ) val tools = items.filterIsInstance() assertEquals(1, tools.size, "$items") assertEquals("finished", tools.single().output) assertTrue(tools.single().done) } /** * The page-join half of the same rule. A boundary that cuts one reply leaves an unfinished half * to be rejoined; a boundary that lands between two turns must not join anything, or paging * back puts the run-together paragraph straight back. */ @Test fun paging_back_rejoins_a_cut_reply_and_leaves_two_finished_ones_apart() { val cut = joinPages( listOf(TranscriptItem.AssistantMsg(1, "half a ")), listOf(TranscriptItem.AssistantMsg(2, "sentence", settled = true)), ) assertEquals(listOf("half a sentence"), texts(cut)) val whole = joinPages( listOf(TranscriptItem.AssistantMsg(1, "One turn.", settled = true)), listOf(TranscriptItem.AssistantMsg(2, "The next.", settled = true)), ) assertEquals(listOf("One turn.", "The next."), texts(whole)) // And the rule between them, which the fold that would have made it never got to see. assertTrue(whole.any { it is TranscriptItem.TurnBreak }, "$whole") } }