Draw a model's thinking, and what a reply cost to produce
A llama.cpp session's `reasoning_content` becomes `Event::Thinking` deltas closed by an `Event::ThinkingDone` carrying the span the driver measured, and the phone draws it as a card of its own: "Thinking" with the spinner a running command has, then "Thought for 12.4s". Deliberately not a tool call, so a run of calls cannot collapse the reasoning into "Called 6 tools"; the reasoning is also kept out of the next prompt, which `conversation` already ignored. `UsageDelta` gains `tokensPerSecond`, the provider's own figure or nothing -- llama.cpp reports `timings.predicted_per_second` and the coding CLIs report no such thing -- and a finished reply carries a small line under it saying when it was sent and, where there is one, how fast it came out: "3:00 PM · 149 tok/s". The compact usage bar drops the provider's name for the window and puts its length after the time left instead: "42% · 3h 20m left / 5h". Three things that had to come with it: the transcript coalesces runs of thinking deltas as it does reply deltas, so one block is one row of a page rather than a page of its own; `joinPages` welds a block cut by a page boundary (`healSplitThinking`), since the half with no ending spun for ever; and `UsageDelta` now reaches the fold, which is what carries the rate to the reply. Verified on the emulator against a real Qwen3-0.6B session and the echo rig's new `/think [seconds]`: the spinner while it runs, "Thought for 1.4s" and "2:54 PM · 149 tok/s" after, the reasoning on tapping the card, and the usage bar reading "42% · 3h 19m left / 5h". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45f249ae91
commit
bb5ac1a242
18 files changed
+900
-101
No files matched your search
@@ -0,0 +1,137 @@
|
||||
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 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 was generated at`() {
|
||||
val items =
|
||||
fold(emptyList(), SessionEvent.AssistantText("Done."), ts = 1_788_609_600.0).let {
|
||||
fold(it, SessionEvent.UsageDelta(42, 100, 18.37))
|
||||
}
|
||||
val reply = items.filterIsInstance<TranscriptItem.AssistantMsg>().single()
|
||||
assertEquals(1_788_609_600.0, reply.ts)
|
||||
assertEquals(18.37, reply.tokensPerSecond)
|
||||
|
||||
val footer = replyFooterText(reply.ts, reply.tokensPerSecond, ZoneId.of("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!!.contains("12:00"), footer)
|
||||
assertTrue(footer.endsWith("18.4 tok/s"), footer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a provider that measures no speed gets a footer of the time alone`() {
|
||||
val footer = replyFooterText(1_788_609_600.0, null, ZoneId.of("UTC"))
|
||||
assertTrue(footer!!.contains("12:00"), footer)
|
||||
assertTrue(!footer.contains("tok/s"), footer)
|
||||
// And a reply with neither has no line at all rather than an empty one.
|
||||
assertNull(replyFooterText(0.0, null, ZoneId.of("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),
|
||||
)
|
||||
assertNull(items.filterIsInstance<TranscriptItem.AssistantMsg>().single().tokensPerSecond)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user