186 lines
6.7 KiB
Kotlin
186 lines
6.7 KiB
Kotlin
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<TranscriptItem>, event: SessionEvent) =
|
||
foldEvent(items, SeqEvent(seq = ++seq, ts = 1.0, event = event))
|
||
|
||
private fun fold(vararg events: SessionEvent) =
|
||
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
|
||
|
||
private fun texts(items: List<TranscriptItem>) =
|
||
items.filterIsInstance<TranscriptItem.AssistantMsg>().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<TranscriptItem.ToolRun>()
|
||
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")
|
||
}
|
||
}
|