feat(chat): use backend usage deltas for assistant stats

This commit is contained in:
M57 committed 2026-09-16 01:34:05 +03:00
1 parent aed8ba4a6d
commit d16a2ea35c
7 files changed
+726 -78

No files matched your search

@@ -1,5 +1,10 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@@ -12,6 +17,120 @@ import org.junit.Test
* mirror the real wire shape.
*/
class UsageSnapshotResponseTest {
private fun completePayload(): Map<String, Any?> =
mapOf(
"model" to "provider/model",
"input" to 13_000L,
"output" to 2_700L,
"reasoning" to 1_100L,
"prompt" to 48_000L,
"completion" to 2_700L,
"total" to 50_700L,
"calls" to 5L,
"compressions" to 2,
"context_used" to 12_345L,
"context_max" to 200_000L,
"context_percent" to 6,
"context_source" to "compressor",
"context_estimated" to true,
"cache_hit_pct" to 42,
"cache_read" to 1_200L,
"cache_write" to 300L,
"avg_latency_s" to 0.75,
"avg_tps" to 42.5,
"active_subagents" to 1,
"dev_credits_spent_micros" to 9_000L,
"cost_usd" to 0.0125,
"cost_status" to "estimated",
"credits_lines" to listOf("line one", "line two"),
)
@Test
fun `complete direct payload maps every usage field`() {
val parsed = parseUsageSnapshot(completePayload())
assertEquals("provider/model", parsed?.model)
assertEquals(13_000L, parsed?.inputTokens)
assertEquals(2_700L, parsed?.outputTokens)
assertEquals(1_100L, parsed?.reasoningTokens)
assertEquals(48_000L, parsed?.promptTokens)
assertEquals(2_700L, parsed?.completionTokens)
assertEquals(50_700L, parsed?.totalTokens)
assertEquals(5L, parsed?.apiCalls)
assertEquals(2, parsed?.compressions)
assertEquals(12_345L, parsed?.contextUsed)
assertEquals(200_000L, parsed?.contextMax)
assertEquals(6, parsed?.contextPercent)
assertEquals("compressor", parsed?.contextSource)
assertEquals(true, parsed?.contextEstimated)
assertEquals(42, parsed?.cacheHitPercent)
assertEquals(1_200L, parsed?.cacheReadTokens)
assertEquals(300L, parsed?.cacheWriteTokens)
assertEquals(0.75, parsed?.avgLatencySeconds ?: 0.0, 0.001)
assertEquals(42.5, parsed?.avgTps ?: 0.0, 0.001)
assertEquals(1, parsed?.activeSubagents)
assertEquals(9_000L, parsed?.devCreditsSpentMicros)
assertEquals(0.0125, parsed?.costUsd ?: 0.0, 0.0001)
assertEquals("estimated", parsed?.costStatus)
assertEquals(listOf("line one", "line two"), parsed?.creditsLines)
}
@Test
fun `nested usage envelope and JsonObject map correctly`() {
val json =
Json.parseToJsonElement(
"""{"usage":{"model":"m","input":100,"output":200,"reasoning":50,"prompt":300,"completion":200,"total":300,"calls":2,"context_used":400,"context_max":1000,"avg_tps":12.5,"context_estimated":false,"credits_lines":["credit"]}}""",
) as JsonObject
val parsed = parseUsageSnapshot(json)
assertEquals("m", parsed?.model)
assertEquals(100L, parsed?.inputTokens)
assertEquals(200L, parsed?.outputTokens)
assertEquals(50L, parsed?.reasoningTokens)
assertEquals(2L, parsed?.apiCalls)
assertEquals(12.5, parsed?.avgTps ?: 0.0, 0.001)
assertEquals(false, parsed?.contextEstimated)
assertEquals(listOf("credit"), parsed?.creditsLines)
}
@Test
fun `numeric strings booleans and large Long values are preserved`() {
val parsed =
parseUsageSnapshot(
mapOf(
"input" to "2147483648",
"output" to JsonPrimitive("9223372036854770000"),
"context_estimated" to "true",
"avg_tps" to "42.25",
),
)
assertEquals(2_147_483_648L, parsed?.inputTokens)
assertEquals(9_223_372_036_854_770_000L, parsed?.outputTokens)
assertEquals(true, parsed?.contextEstimated)
assertEquals(42.25, parsed?.avgTps ?: 0.0, 0.001)
}
@Test
fun `missing and malformed optional fields remain unavailable`() {
val parsed =
parseUsageSnapshot(
buildJsonObject {
put("input", JsonPrimitive("not-a-number"))
put("context_estimated", JsonPrimitive("maybe"))
put("avg_tps", JsonPrimitive("NaN"))
put("credits_lines", JsonPrimitive("not-a-list"))
},
)
assertNull(parsed?.inputTokens)
assertNull(parsed?.outputTokens)
assertNull(parsed?.contextEstimated)
assertNull(parsed?.avgTps)
assertNull(parsed?.creditsLines)
}
@Test
fun `compressions parses from Double and Int`() {
assertEquals(2, parseUsageSnapshot(mapOf("compressions" to 2.0))?.compressions)
@@ -50,4 +169,63 @@ class UsageSnapshotResponseTest {
assertEquals(50000L, parsed?.totalTokens)
assertEquals(45.5, parsed?.avgTps ?: 0.0, 0.001)
}
@Test
fun `deltaFrom derives independent cumulative fields and copies rolling values`() {
val baseline =
UsageSnapshotResponse(
inputTokens = 10_000,
outputTokens = 2_000,
reasoningTokens = 800,
promptTokens = 40_000,
completionTokens = 2_000,
totalTokens = 42_000,
apiCalls = 3,
cacheReadTokens = 100,
cacheWriteTokens = 50,
costUsd = 1.0,
)
val final =
UsageSnapshotResponse(
inputTokens = 13_000,
outputTokens = 2_700,
reasoningTokens = 1_100,
promptTokens = 48_000,
completionTokens = 2_700,
totalTokens = 50_700,
apiCalls = 5,
cacheReadTokens = 180,
cacheWriteTokens = 75,
costUsd = 1.25,
avgTps = 42.5,
avgLatencySeconds = 0.75,
)
val turn = final.deltaFrom(baseline)
assertEquals(3_000L, turn.inputTokens)
assertEquals(700L, turn.outputTokens)
assertEquals(300L, turn.reasoningTokens)
assertEquals(8_000L, turn.promptTokens)
assertEquals(700L, turn.completionTokens)
assertEquals(8_700L, turn.totalTokens)
assertEquals(2L, turn.apiCalls)
assertEquals(80L, turn.cacheReadTokens)
assertEquals(25L, turn.cacheWriteTokens)
assertEquals(0.25, turn.costUsd ?: 0.0, 0.001)
assertEquals(42.5, turn.avgTps ?: 0.0, 0.001)
}
@Test
fun `deltaFrom treats missing reset and equal counters correctly`() {
val baseline = UsageSnapshotResponse(outputTokens = 5_000, inputTokens = 10, totalTokens = 10)
val reset = UsageSnapshotResponse(outputTokens = 100, inputTokens = 20, totalTokens = 20)
val equal = UsageSnapshotResponse(outputTokens = 5_000, inputTokens = 10, totalTokens = 10)
assertNull(reset.deltaFrom(baseline).outputTokens)
assertNull(UsageSnapshotResponse(outputTokens = 6).deltaFrom(baseline).outputTokens)
assertEquals(0L, equal.deltaFrom(baseline).outputTokens)
assertEquals(0L, equal.deltaFrom(baseline).inputTokens)
assertEquals(0L, equal.deltaFrom(baseline).totalTokens)
}
}
@@ -4178,6 +4178,32 @@ class ChatViewModelTest {
assertFalse(state.showTokensPerSecond)
}
@Test
fun testSendMessage_capturesCurrentUsageBeforeTurnAndSessionSwitchClearsIt() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(
WsEvent.SessionUsage(
data = mapOf("usage" to mapOf("output" to 1000L)),
sessionId = sessionId,
),
)
advanceUntilIdle()
viewModel.sendMessage("prompt")
assertEquals(
1000L,
viewModel.streamingState.value.turnUsageBaseline
?.outputTokens,
)
assertTrue(viewModel.streamingState.value.turnUsageBaselineCaptured)
viewModel.switchSession("session-other")
assertNull(viewModel.streamingState.value.turnUsageBaseline)
assertFalse(viewModel.streamingState.value.turnUsageBaselineCaptured)
}
@Test
fun testToggleSearch() =
runTest {
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -979,7 +980,13 @@ class ChatWsEventReducerTest {
@Test
fun testSessionUsage_differentSession_isIgnored() {
val state = ChatUiState(currentSessionId = "session-1", compressionCount = 1)
val state =
ChatUiState(
currentSessionId = "session-1",
compressionCount = 1,
latestTps = 30.0,
sessionUsage = UsageSnapshotResponse(outputTokens = 1000, contextMax = 128000),
)
val usagePayload =
mapOf(
"usage" to
@@ -991,12 +998,19 @@ class ChatWsEventReducerTest {
val result =
ChatWsEventReducer.reduce(
state = state,
streamingState = StreamingState(),
streamingState =
StreamingState(
turnUsageBaseline = state.sessionUsage,
turnUsageBaselineCaptured = true,
),
event = WsEvent.SessionUsage(data = usagePayload, sessionId = "session-other"),
currentSessionId = "session-1",
)
assertEquals(1, result.state.compressionCount)
assertEquals(null, result.state.usedContextTokens)
assertEquals(30.0, result.state.latestTps ?: 0.0, 0.001)
assertEquals(1000L, result.state.sessionUsage?.outputTokens)
assertTrue(result.streamingState.turnUsageBaselineCaptured)
}
@Test
@@ -1082,4 +1096,229 @@ class ChatWsEventReducerTest {
assertEquals("b", extracted?.get(1)?.id)
assertEquals("a", extracted?.get(1)?.parent)
}
@Test
fun testSessionUsage_retainsCompleteSnapshotAndUpdatesConvenienceState() {
val state = ChatUiState(currentSessionId = "session-1")
val result =
ChatWsEventReducer.reduce(
state,
StreamingState(),
WsEvent.SessionUsage(
data =
mapOf(
"usage" to
mapOf(
"model" to "m",
"input" to 1000L,
"output" to 200L,
"reasoning" to 50L,
"prompt" to 1200L,
"completion" to 200L,
"total" to 1200L,
"calls" to 2L,
"compressions" to 3,
"context_used" to 400L,
"context_max" to 2000L,
"avg_tps" to 42.5,
),
),
sessionId = "session-1",
),
"session-1",
)
assertEquals(1000L, result.state.sessionUsage?.inputTokens)
assertEquals(200L, result.state.sessionUsage?.outputTokens)
assertEquals(3, result.state.compressionCount)
assertEquals(400L, result.state.usedContextTokens)
assertEquals(2000L, result.state.fullContextTokens)
assertEquals(42.5, result.state.latestTps ?: 0.0, 0.001)
}
@Test
fun testSessionUsage_partialSnapshotPreservesKnownFields() {
val state =
ChatUiState(
currentSessionId = "session-1",
sessionUsage = UsageSnapshotResponse(inputTokens = 100, outputTokens = 200, contextMax = 200_000),
fullContextTokens = 200_000,
)
val result =
ChatWsEventReducer.reduce(
state,
StreamingState(),
WsEvent.SessionUsage(mapOf("usage" to mapOf("output" to 250L)), "session-1"),
"session-1",
)
assertEquals(100L, result.state.sessionUsage?.inputTokens)
assertEquals(250L, result.state.sessionUsage?.outputTokens)
assertEquals(200_000L, result.state.sessionUsage?.contextMax)
assertEquals(200_000L, result.state.fullContextTokens)
}
@Test
fun testMessageComplete_usesBackendOutputDeltaAndFinalTps() {
val state =
ChatUiState(
currentSessionId = "session-1",
sessionUsage = UsageSnapshotResponse(outputTokens = 1000),
)
val start = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart("session-1"), "session-1")
val result =
ChatWsEventReducer.reduce(
state,
start.streamingState,
WsEvent.MessageComplete(
text = "short",
sessionId = "session-1",
rawPayload = mapOf("usage" to mapOf("output" to 1250L, "avg_tps" to 42.5)),
),
"session-1",
)
val message = result.state.messages.single()
assertEquals(250, message.tokenCount)
assertEquals(42.5, message.tps ?: 0.0, 0.001)
assertEquals(1250L, result.state.sessionUsage?.outputTokens)
assertTrue(!result.streamingState.turnUsageBaselineCaptured)
}
@Test
fun testMessageComplete_withoutBaselineFallsBackToVisibleTextEstimate() {
val result =
ChatWsEventReducer.reduce(
ChatUiState(currentSessionId = "session-1"),
StreamingState(
streamingMessage = ChatMessage(role = MessageRole.ASSISTANT, content = "short"),
),
WsEvent.MessageComplete(
text = "short",
sessionId = "session-1",
rawPayload = mapOf("usage" to mapOf("output" to 1250L)),
),
"session-1",
)
assertTrue(
result.state.messages
.single()
.tokenCount != 1250,
)
}
@Test
fun testMessageComplete_counterResetDoesNotProduceNegativeOrCumulativeCount() {
val state =
ChatUiState(currentSessionId = "session-1", sessionUsage = UsageSnapshotResponse(outputTokens = 5000))
val start = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart("session-1"), "session-1")
val result =
ChatWsEventReducer.reduce(
state,
start.streamingState,
WsEvent.MessageComplete("short", "session-1", rawPayload = mapOf("usage" to mapOf("output" to 100L))),
"session-1",
)
assertTrue(
result.state.messages
.single()
.tokenCount != -4900,
)
assertTrue(
result.state.messages
.single()
.tokenCount != 100,
)
}
@Test
fun testMessageComplete_exactZeroDeltaIsNotReplacedByEstimate() {
val state =
ChatUiState(currentSessionId = "session-1", sessionUsage = UsageSnapshotResponse(outputTokens = 5000))
val start = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart("session-1"), "session-1")
val result =
ChatWsEventReducer.reduce(
state,
start.streamingState,
WsEvent.MessageComplete("short", "session-1", rawPayload = mapOf("usage" to mapOf("output" to 5000L))),
"session-1",
)
assertEquals(
0,
result.state.messages
.single()
.tokenCount,
)
}
@Test
fun testToolLoop_keepsOriginalPreTurnBaseline() {
val state =
ChatUiState(currentSessionId = "session-1", sessionUsage = UsageSnapshotResponse(outputTokens = 1000))
val first = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart("session-1"), "session-1")
val live =
ChatWsEventReducer.reduce(
state.copy(sessionUsage = UsageSnapshotResponse(outputTokens = 1100)),
first.streamingState,
WsEvent.SessionUsage(mapOf("usage" to mapOf("output" to 1100L)), "session-1"),
"session-1",
)
val second =
ChatWsEventReducer.reduce(
state.copy(sessionUsage = UsageSnapshotResponse(outputTokens = 1100)),
live.streamingState.copy(streamingMessage = null),
WsEvent.MessageStart("session-1"),
"session-1",
)
val result =
ChatWsEventReducer.reduce(
state.copy(sessionUsage = UsageSnapshotResponse(outputTokens = 1100)),
second.streamingState,
WsEvent.MessageComplete("final", "session-1", rawPayload = mapOf("usage" to mapOf("output" to 1250L))),
"session-1",
)
assertEquals(
250,
result.state.messages
.last()
.tokenCount,
)
}
@Test
fun testMessageComplete_blankVisibleTextStillStoresFinalUsageAndClearsBaseline() {
val orphan = ChatMessage(role = MessageRole.ASSISTANT, content = "already shown", id = "orphan")
val state =
ChatUiState(
currentSessionId = "session-1",
messages = listOf(orphan),
sessionUsage = UsageSnapshotResponse(outputTokens = 1000),
)
val streaming =
StreamingState(
streamingMessage = orphan,
sealedOrphanIds = listOf("orphan"),
turnUsageBaseline = state.sessionUsage,
turnUsageBaselineCaptured = true,
)
val result =
ChatWsEventReducer.reduce(
state,
streaming,
WsEvent.MessageComplete(
"already shown",
"session-1",
rawPayload = mapOf("usage" to mapOf("output" to 1300L)),
),
"session-1",
)
assertEquals(1300L, result.state.sessionUsage?.outputTokens)
assertTrue(result.state.messages.none { it.id != "orphan" })
assertTrue(!result.streamingState.turnUsageBaselineCaptured)
}
}