feat(chat): use backend usage deltas for assistant stats
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user