`UsageDelta` gains `prefillMs`, llama-server's own `timings.prompt_ms`, so the footer under a finished reply is "read 9.5s · 50.3 tok/s · 3:00 PM". Prefill is the half of a turn that was invisible and is often the larger: measured on the 0.6B here, 1m 4s for the first turn after a model loads against 22ms for the next, whose prompt the server still had cached. The clock moves to the end of the line. Everything in front of it is a provider's own measurement, so a session on another provider has fewer of them or none, and a reader who has learned where the time is should not have to find it again because the model changed. The costs grow leftwards into the space instead, and a test asserts every shape of the line ends with the same thing. Verified on the emulator against a real llama session: three replies reading "read 1m 4s · 193 tok/s · 3:54 PM", "read 25ms · 308 tok/s · 3:54 PM" and "read 22ms · 194 tok/s · 3:54 PM", with the clock in one column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
6.3 KiB
Kotlin
151 lines
6.3 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import java.time.ZoneId
|
|
import kotlin.test.Test
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertNull
|
|
import kotlin.test.assertTrue
|
|
|
|
/**
|
|
* The model's working as its own row, and the line under a finished reply.
|
|
*
|
|
* Both have the same shape of hazard: a state nothing measured must not come out looking like one
|
|
* that was. A block interrupted mid-thought has no duration, and a provider that reports no
|
|
* generation speed has no figure -- neither may borrow one.
|
|
*/
|
|
class ThinkingTest {
|
|
private val utc = ZoneId.of("UTC")
|
|
private var seq = 0L
|
|
|
|
private fun fold(items: List<TranscriptItem>, event: SessionEvent, ts: Double = 1.0) =
|
|
foldEvent(items, SeqEvent(seq = ++seq, ts = ts, event = event))
|
|
|
|
private fun fold(vararg events: SessionEvent) =
|
|
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
|
|
|
|
private fun thinking(items: List<TranscriptItem>) =
|
|
items.filterIsInstance<TranscriptItem.ThinkingRow>()
|
|
|
|
@Test
|
|
fun `deltas accumulate into one block that ends with its duration`() {
|
|
val items =
|
|
fold(
|
|
SessionEvent.Thinking("the user "),
|
|
SessionEvent.Thinking("wants a card"),
|
|
SessionEvent.ThinkingDone(12_400),
|
|
SessionEvent.AssistantText("Here it is."),
|
|
)
|
|
val block = thinking(items).single()
|
|
assertEquals("the user wants a card", block.text)
|
|
assertEquals(12_400, block.ms)
|
|
assertEquals("Thought for 12.4s", thinkingHeadline(block))
|
|
// Its own row, above the reply rather than inside it.
|
|
assertEquals(1, items.filterIsInstance<TranscriptItem.AssistantMsg>().size)
|
|
}
|
|
|
|
@Test
|
|
fun `a block the turn ended in the middle of stops without naming a span`() {
|
|
val items = fold(SessionEvent.Thinking("half a thought"), SessionEvent.Status("idle"))
|
|
val block = thinking(items).single()
|
|
assertNull(block.ms)
|
|
assertTrue(!block.open)
|
|
assertEquals("Thought", thinkingHeadline(block))
|
|
}
|
|
|
|
@Test
|
|
fun `a block still being thought says so`() {
|
|
val block = thinking(fold(SessionEvent.Thinking("hmm"))).single()
|
|
assertTrue(block.open)
|
|
assertEquals("Thinking", thinkingHeadline(block))
|
|
}
|
|
|
|
@Test
|
|
fun `thinking between two replies is two replies and two blocks`() {
|
|
val items =
|
|
fold(
|
|
SessionEvent.Thinking("first"),
|
|
SessionEvent.ThinkingDone(1_000),
|
|
SessionEvent.AssistantText("One."),
|
|
SessionEvent.Thinking("second"),
|
|
SessionEvent.ThinkingDone(2_000),
|
|
SessionEvent.AssistantText("Two."),
|
|
)
|
|
assertEquals(listOf("first", "second"), thinking(items).map { it.text })
|
|
assertEquals(
|
|
listOf("One.", "Two."),
|
|
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text },
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `a reply carries when it was sent and what it cost to produce`() {
|
|
val items =
|
|
fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let {
|
|
fold(it, SessionEvent.UsageDelta(42, 100, 18.37, 9_489))
|
|
}
|
|
val reply = items.filterIsInstance<TranscriptItem.AssistantMsg>().single()
|
|
assertEquals(1_788_609_600.0, reply.ts)
|
|
assertEquals(18.37, reply.tokensPerSecond)
|
|
assertEquals(9_489, reply.prefillMs)
|
|
|
|
val footer = replyFooterText(reply.ts, reply.tokensPerSecond, reply.prefillMs, utc)
|
|
// The clock reading rather than the whole string: the platform's own short-time format
|
|
// differs by JDK and locale, which is the point of asking it for one.
|
|
assertTrue(footer!!.startsWith("read 9.5s · 18.4 tok/s · "), footer)
|
|
assertTrue(footer.contains("12:00"), footer)
|
|
}
|
|
|
|
@Test
|
|
fun `the clock stays at the end however much the provider measured`() {
|
|
// What a provider that measures nothing leaves: the time, and nothing in front of it.
|
|
val bare = replyFooterText(1_788_609_600.0, null, null, utc)
|
|
assertTrue(bare!!.contains("12:00"), bare)
|
|
assertTrue(!bare.contains("tok/s") && !bare.contains("read"), bare)
|
|
// Every shape ends with the same thing, which is the whole point of the order: the clock
|
|
// does not move because the session is on a provider that measures more or less.
|
|
val shapes =
|
|
listOf(
|
|
bare,
|
|
replyFooterText(1_788_609_600.0, 18.37, null, utc)!!,
|
|
replyFooterText(1_788_609_600.0, null, 9_489, utc)!!,
|
|
replyFooterText(1_788_609_600.0, 18.37, 9_489, utc)!!,
|
|
)
|
|
assertEquals(1, shapes.map { it.substringAfterLast("· ") }.distinct().size, "$shapes")
|
|
// A reply with nothing to say has no line at all rather than an empty one.
|
|
assertNull(replyFooterText(0.0, null, null, utc))
|
|
}
|
|
|
|
@Test
|
|
fun `a block cut by a page boundary is one block, and it is not still going`() {
|
|
// Each page folded on its own, as the app does: the older one holds the fragments before
|
|
// the cut and no ending, the newer one the rest and the ending.
|
|
val older = fold(SessionEvent.Thinking("half a "))
|
|
val newer = fold(SessionEvent.Thinking("thought"), SessionEvent.ThinkingDone(2_000))
|
|
|
|
val joined = joinPages(older, newer)
|
|
val block = thinking(joined).single()
|
|
assertEquals("half a thought", block.text)
|
|
assertEquals(2_000, block.ms)
|
|
assertTrue(!block.open)
|
|
}
|
|
|
|
@Test
|
|
fun `two blocks meeting at a page boundary stay two`() {
|
|
val older = fold(SessionEvent.Thinking("first"), SessionEvent.ThinkingDone(1_000))
|
|
val newer = fold(SessionEvent.Thinking("second"), SessionEvent.ThinkingDone(2_000))
|
|
assertEquals(listOf("first", "second"), thinking(joinPages(older, newer)).map { it.text })
|
|
}
|
|
|
|
@Test
|
|
fun `usage that lands after a tool call is not folded onto an older reply`() {
|
|
val items =
|
|
fold(
|
|
SessionEvent.AssistantText("Reading it."),
|
|
SessionEvent.ToolStart("t1", "Read", "{}"),
|
|
SessionEvent.ToolEnd("t1", "done"),
|
|
SessionEvent.UsageDelta(42, 100, 18.0, 500),
|
|
)
|
|
assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond)
|
|
}
|
|
}
|