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,68 +1,187 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Minimal view of the `session.usage` WS RPC result or `session.usage` push event —
* only the fields the context widget consumes today. The backend sends many more keys
* (`input`/`output`/`total`/`calls`/`active_subagents`/`credits_lines` …);
* parsing is key-by-key, so unmodelled keys are simply ignored.
*
* Verified backend shape (`tui_gateway/server.py` `_get_usage`): `compressions`
* = `context_compressor.compression_count` (0 when never compressed, absent on
* sessions with no live agent), `context_used` = current window tokens,
* `context_max` = max context length.
*/
/** Complete cumulative `session.usage` snapshot returned by Hermes Agent. */
data class UsageSnapshotResponse(
/** How many times this session has been context-compressed. */
val compressions: Int? = null,
/** Current prompt / context tokens used in the active window. */
val contextUsed: Long? = null,
/** Max context length in tokens. */
val contextMax: Long? = null,
/** Cumulative session total tokens. */
val model: String? = null,
val inputTokens: Long? = null,
val outputTokens: Long? = null,
val reasoningTokens: Long? = null,
val promptTokens: Long? = null,
val completionTokens: Long? = null,
val totalTokens: Long? = null,
/** Rolling tokens-per-second generation speed. */
val apiCalls: Long? = null,
val cacheReadTokens: Long? = null,
val cacheWriteTokens: Long? = null,
val compressions: Int? = null,
val contextUsed: Long? = null,
val contextMax: Long? = null,
val contextPercent: Int? = null,
val contextSource: String? = null,
val contextEstimated: Boolean? = null,
val cacheHitPercent: Int? = null,
val avgLatencySeconds: Double? = null,
val avgTps: Double? = null,
val activeSubagents: Int? = null,
val devCreditsSpentMicros: Long? = null,
val costUsd: Double? = null,
val costStatus: String? = null,
val creditsLines: List<String>? = null,
)
/** Merge an incremental snapshot without erasing values omitted by the backend. */
fun UsageSnapshotResponse.mergeWith(previous: UsageSnapshotResponse?): UsageSnapshotResponse =
if (previous == null) {
this
} else {
copy(
model = model ?: previous.model,
inputTokens = inputTokens ?: previous.inputTokens,
outputTokens = outputTokens ?: previous.outputTokens,
reasoningTokens = reasoningTokens ?: previous.reasoningTokens,
promptTokens = promptTokens ?: previous.promptTokens,
completionTokens = completionTokens ?: previous.completionTokens,
totalTokens = totalTokens ?: previous.totalTokens,
apiCalls = apiCalls ?: previous.apiCalls,
cacheReadTokens = cacheReadTokens ?: previous.cacheReadTokens,
cacheWriteTokens = cacheWriteTokens ?: previous.cacheWriteTokens,
compressions = compressions ?: previous.compressions,
contextUsed = contextUsed ?: previous.contextUsed,
contextMax = contextMax ?: previous.contextMax,
contextPercent = contextPercent ?: previous.contextPercent,
contextSource = contextSource ?: previous.contextSource,
contextEstimated = contextEstimated ?: previous.contextEstimated,
cacheHitPercent = cacheHitPercent ?: previous.cacheHitPercent,
avgLatencySeconds = avgLatencySeconds ?: previous.avgLatencySeconds,
avgTps = avgTps ?: previous.avgTps,
activeSubagents = activeSubagents ?: previous.activeSubagents,
devCreditsSpentMicros = devCreditsSpentMicros ?: previous.devCreditsSpentMicros,
costUsd = costUsd ?: previous.costUsd,
costStatus = costStatus ?: previous.costStatus,
creditsLines = creditsLines ?: previous.creditsLines,
)
}
/**
* Parse the decoded JSON-RPC result of `session.usage` or `session.usage` push event payload.
* Usage attributable to one completed turn, derived from two cumulative snapshots.
* Rolling/context fields are copied from the final snapshot rather than differenced.
*/
data class TurnUsage(
val model: String? = null,
val inputTokens: Long? = null,
val outputTokens: Long? = null,
val reasoningTokens: Long? = null,
val promptTokens: Long? = null,
val completionTokens: Long? = null,
val totalTokens: Long? = null,
val apiCalls: Long? = null,
val cacheReadTokens: Long? = null,
val cacheWriteTokens: Long? = null,
val devCreditsSpentMicros: Long? = null,
val costUsd: Double? = null,
val compressionCountDelta: Int? = null,
val avgTps: Double? = null,
val avgLatencySeconds: Double? = null,
)
private fun Long?.deltaFrom(
baseline: Long?,
final: Long?,
): Long? = if (baseline != null && final != null && final >= baseline) final - baseline else null
private fun Double?.deltaFrom(
baseline: Double?,
final: Double?,
): Double? = if (baseline != null && final != null && final >= baseline) final - baseline else null
/** Derive per-turn cumulative counters without ever clamping counter resets. */
fun UsageSnapshotResponse.deltaFrom(baseline: UsageSnapshotResponse?): TurnUsage =
TurnUsage(
model = model,
inputTokens = inputTokens.deltaFrom(baseline?.inputTokens, inputTokens),
outputTokens = outputTokens.deltaFrom(baseline?.outputTokens, outputTokens),
reasoningTokens = reasoningTokens.deltaFrom(baseline?.reasoningTokens, reasoningTokens),
promptTokens = promptTokens.deltaFrom(baseline?.promptTokens, promptTokens),
completionTokens = completionTokens.deltaFrom(baseline?.completionTokens, completionTokens),
totalTokens = totalTokens.deltaFrom(baseline?.totalTokens, totalTokens),
apiCalls = apiCalls.deltaFrom(baseline?.apiCalls, apiCalls),
cacheReadTokens = cacheReadTokens.deltaFrom(baseline?.cacheReadTokens, cacheReadTokens),
cacheWriteTokens = cacheWriteTokens.deltaFrom(baseline?.cacheWriteTokens, cacheWriteTokens),
devCreditsSpentMicros = devCreditsSpentMicros.deltaFrom(baseline?.devCreditsSpentMicros, devCreditsSpentMicros),
costUsd = costUsd.deltaFrom(baseline?.costUsd, costUsd),
compressionCountDelta =
compressions
?.let { final -> baseline?.compressions?.let { base -> if (final >= base) final - base else null } },
avgTps = avgTps,
avgLatencySeconds = avgLatencySeconds,
)
/**
* Parse the decoded JSON-RPC result of `session.usage`, push events, or message.complete.
*
* Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
* (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject].
* Returns null when the payload is not a map (error/malformed response) — callers keep the last known value.
* Handles direct snapshots and nested `{ "usage": { ... } }` envelopes. JSON numbers may
* arrive as [Double] through [JsonRpcModels.toAny], or as [JsonPrimitive] from an RPC result.
* Unknown and malformed optional fields are ignored; a non-map root returns null.
*/
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
val rawMap =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
fun asMap(value: Any?): Map<*, *>? =
when (value) {
is Map<*, *> -> value
is JsonElement -> value.toAny() as? Map<*, *>
else -> null
} ?: return null
val usageMap = (rawMap["usage"] as? Map<*, *>) ?: rawMap
}
val rawMap = asMap(result) ?: return null
val usageMap = asMap(rawMap["usage"]) ?: rawMap
fun num(key: String): Long? =
when (val value = usageMap[key]) {
is Number -> value.toLong()
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
is String -> value.toLongOrNull()
else -> null
is Byte, is Short, is Int, is Long -> {
(value as Number).toLong()
}
is Float, is Double -> {
(value as Number)
.toDouble()
.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE }
?.toLong()
}
is Number -> {
value
.toDouble()
.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE }
?.toLong()
}
is JsonPrimitive -> {
value.longOrNull
?: value.doubleOrNull
?.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE }
?.toLong()
}
is String -> {
value.toLongOrNull()
?: value
.toDoubleOrNull()
?.takeIf { it.isFinite() && it % 1.0 == 0.0 && it >= Long.MIN_VALUE && it <= Long.MAX_VALUE }
?.toLong()
}
else -> {
null
}
}
fun intNum(key: String): Int? =
when (val value = usageMap[key]) {
is Number -> value.toInt()
is JsonPrimitive -> value.intOrNull
is String -> value.toIntOrNull()
else -> null
}
fun intNum(key: String): Int? = num(key)?.takeIf { it in Int.MIN_VALUE..Int.MAX_VALUE }?.toInt()
fun doubleNum(key: String): Double? =
when (val value = usageMap[key]) {
@@ -70,13 +189,55 @@ fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}?.takeIf { it.isFinite() }
fun string(key: String): String? =
when (val value = usageMap[key]) {
is String -> value
is JsonPrimitive -> value.content
else -> null
}
fun bool(key: String): Boolean? =
when (val value = usageMap[key]) {
is Boolean -> value
is JsonPrimitive -> value.booleanOrNull
is String -> value.toBooleanStrictOrNull()
else -> null
}
fun strings(key: String): List<String>? =
when (val value = usageMap[key]) {
is JsonArray -> value.mapNotNull { (it as? JsonPrimitive)?.content }
is List<*> -> value.filterIsInstance<String>()
is JsonElement -> (value.toAny() as? List<*>)?.filterIsInstance<String>()
else -> null
}
return UsageSnapshotResponse(
model = string("model"),
inputTokens = num("input"),
outputTokens = num("output"),
reasoningTokens = num("reasoning"),
promptTokens = num("prompt"),
completionTokens = num("completion"),
totalTokens = num("total"),
apiCalls = num("calls"),
cacheReadTokens = num("cache_read"),
cacheWriteTokens = num("cache_write"),
compressions = intNum("compressions"),
contextUsed = num("context_used"),
contextMax = num("context_max"),
totalTokens = num("total"),
contextPercent = intNum("context_percent"),
contextSource = string("context_source"),
contextEstimated = bool("context_estimated"),
cacheHitPercent = intNum("cache_hit_pct"),
avgLatencySeconds = doubleNum("avg_latency_s"),
avgTps = doubleNum("avg_tps"),
activeSubagents = intNum("active_subagents"),
devCreditsSpentMicros = num("dev_credits_spent_micros"),
costUsd = doubleNum("cost_usd"),
costStatus = string("cost_status"),
creditsLines = strings("credits_lines"),
)
}
@@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.ModelCapabilities
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
import com.m57.hermescontrol.data.model.parseContextBreakdown
import com.m57.hermescontrol.data.model.parseUsageSnapshot
import com.m57.hermescontrol.data.remote.ApiClient
@@ -159,6 +160,8 @@ data class ChatUiState(
val compressionCount: Int? = null,
/** Rolling output tokens/sec over the last ~10 calls. */
val latestTps: Double? = null,
/** Latest cumulative backend usage snapshot for the current session. */
val sessionUsage: UsageSnapshotResponse? = null,
// Attachment state
val pendingAttachments: List<Attachment> = emptyList(),
/** One-shot composer recovery after an attachment is rejected before send. */
@@ -1258,6 +1261,7 @@ class ChatViewModel(
fullContextTokens = null,
contextBreakdown = null,
compressionCount = null,
sessionUsage = null,
)
}
// A gone-session recovery just landed — announce it now that
@@ -1672,6 +1676,17 @@ class ChatViewModel(
userMessage: ChatMessage? = null,
) {
val dispatchGeneration = sessionGeneration
// Capture before attachment work or prompt submission can advance the
// cumulative counters. The reducer preserves this baseline across
// every assistant segment in a tool loop.
if (!_streamingState.value.turnUsageBaselineCaptured) {
_streamingState.update {
it.copy(
turnUsageBaseline = _uiState.value.sessionUsage,
turnUsageBaselineCaptured = true,
)
}
}
AuthManager.setLastOpenedSessionId(storageSessionId)
val msgToPersist =
userMessage ?: ChatMessage(
@@ -2867,6 +2882,7 @@ class ChatViewModel(
fullContextTokens = null,
contextBreakdown = null,
compressionCount = null,
sessionUsage = null,
pendingAttachments = emptyList(),
composerTextToRestore = null,
reactionKind = null,
@@ -3494,7 +3510,7 @@ class ChatViewModel(
)
coroutineContext.ensureActive()
val snapshot = parseUsageSnapshot(usage)
if (snapshot != null && snapshot.compressions != null) {
if (snapshot != null) {
_uiState.update { current ->
if (!isCurrentContextFetch(
sessionId,
@@ -3505,7 +3521,7 @@ class ChatViewModel(
) {
current
} else {
current.copy(compressionCount = snapshot.compressions)
applyUsageSnapshot(current, snapshot)
}
}
}
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.deltaFrom
import com.m57.hermescontrol.data.model.mergeWith
import com.m57.hermescontrol.data.model.parseUsageSnapshot
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.WsEvent
@@ -11,6 +13,22 @@ import kotlinx.serialization.json.JsonPrimitive
private fun List<ChatMessage>.upsertById(message: ChatMessage): List<ChatMessage> = (this + message).dedupeById()
private fun Long.toIntOrNullSafely(): Int? = takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() }?.toInt()
internal fun applyUsageSnapshot(
state: ChatUiState,
snapshot: com.m57.hermescontrol.data.model.UsageSnapshotResponse,
): ChatUiState {
val validTps = snapshot.avgTps?.takeIf { it.isFinite() && it > 0.0 }
return state.copy(
sessionUsage = snapshot.mergeWith(state.sessionUsage),
compressionCount = snapshot.compressions ?: state.compressionCount,
usedContextTokens = snapshot.contextUsed?.takeIf { it > 0L } ?: state.usedContextTokens,
fullContextTokens = snapshot.contextMax?.takeIf { it > 0L } ?: state.fullContextTokens,
latestTps = validTps ?: state.latestTps,
)
}
/**
* Strips this turn's sealed-orphan prefix from the final message text.
*
@@ -83,7 +101,7 @@ object ChatWsEventReducer {
is WsEvent.VaultCodeExpire -> event.sessionId
else -> null
}
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
return ReducerResult(state = state, streamingState = streamingState)
}
val result = reduceInternal(state, streamingState, event)
@@ -242,8 +260,8 @@ object ChatWsEventReducer {
streamingState.streamingMessage.copy(
isStreaming = false,
finishTimestamp = System.currentTimeMillis(),
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
tps = state.latestTps?.takeIf { it > 0.0 },
tokenCount = null,
tps = null,
)
orphan = finalized
state.copy(
@@ -264,6 +282,13 @@ object ChatWsEventReducer {
// slate — no isReasoning flag, no inherited reasoning text.
isReasoning = false,
reasoningText = "",
turnUsageBaseline =
if (streamingState.turnUsageBaselineCaptured) {
streamingState.turnUsageBaseline
} else {
state.sessionUsage
},
turnUsageBaselineCaptured = true,
)
val newState = preState
val sid = newState.currentSessionId
@@ -375,6 +400,9 @@ object ChatWsEventReducer {
streamingState: StreamingState,
event: WsEvent.MessageComplete,
): ReducerResult {
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
val streaming = streamingState.streamingMessage
// Prefer the authoritative reasoning carried in the message.complete
// payload (the gateway's assembled full trace), then fall back to
@@ -392,20 +420,25 @@ object ChatWsEventReducer {
// exactly once, matching the persisted transcript (sealed orphans +
// stripped answer). Turns whose final text does NOT contain the
// commentary are left untouched (orphans stay, final keeps its text).
val text = stripSealedOrphanPrefix(event.text, state.messages, streamingState.sealedOrphanIds)
val text = stripSealedOrphanPrefix(event.text, usageState.messages, streamingState.sealedOrphanIds)
// Edge: the complete payload was entirely covered by sealed orphans
// (the interim text already rendered as its own bubble(s)) — nothing
// new to show, so don't add an empty final bubble.
// new to show, so don't add an empty final bubble. Usage is applied
// above before this return because the final snapshot is still needed
// as the next turn's cumulative baseline.
if (text.isBlank()) {
return ReducerResult(
state = state.copy(isAgentTyping = false),
streamingState = streamingState.copy(streamingMessage = null),
state = usageState.copy(isAgentTyping = false),
streamingState = StreamingState(),
)
}
val rawUsage = event.rawPayload?.get("usage") as? Map<*, *>
val tpsFromPayload = (rawUsage?.get("avg_tps") as? Number)?.toDouble()
val tps = (tpsFromPayload ?: state.latestTps)?.takeIf { it > 0.0 }
val tokenCount = TokenEstimator.estimate(text).takeIf { it > 0 }
val tps = (turnUsage?.avgTps ?: usageState.latestTps)?.takeIf { it.isFinite() && it > 0.0 }
val tokenCount =
if (turnUsage?.outputTokens != null) {
turnUsage.outputTokens.toIntOrNullSafely()
} else {
TokenEstimator.estimate(text).takeIf { it > 0 }
}
val msg =
streaming?.copy(
content = text,
@@ -440,11 +473,12 @@ object ChatWsEventReducer {
}
return ReducerResult(
state =
state.copy(
messages = state.messages.upsertById(msg),
usageState.copy(
messages = usageState.messages.upsertById(msg),
isAgentTyping = false,
clarifyRequest = null,
),
streamingState = StreamingState(),
effects = effects,
)
}
@@ -459,7 +493,7 @@ object ChatWsEventReducer {
streamingState.streamingMessage
?: return ReducerResult(
state = state.copy(isAgentTyping = false),
streamingState = streamingState,
streamingState = StreamingState(),
)
val reasoning =
if (streamingState.reasoningText.isNotBlank()) {
@@ -493,6 +527,7 @@ object ChatWsEventReducer {
messages = state.messages.upsertById(msg),
isAgentTyping = false,
),
streamingState = StreamingState(),
effects = effects,
)
}
@@ -539,8 +574,8 @@ object ChatWsEventReducer {
isStreaming = false,
reasoningText = reasoning,
finishTimestamp = System.currentTimeMillis(),
tokenCount = TokenEstimator.estimate(streamingState.streamingMessage.content).takeIf { it > 0 },
tps = state.latestTps?.takeIf { it > 0.0 },
tokenCount = null,
tps = null,
)
orphanToPersist = finalized
state.copy(
@@ -820,7 +855,7 @@ object ChatWsEventReducer {
isLoading = false,
errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}",
),
streamingState = streamingState,
streamingState = StreamingState(),
)
// ── BackgroundComplete ────────────────────────────────────────────
@@ -998,20 +1033,7 @@ object ChatWsEventReducer {
val snapshot =
parseUsageSnapshot(event.data)
?: return ReducerResult(state = state, streamingState = streamingState)
var updatedState = state
if (snapshot.compressions != null) {
updatedState = updatedState.copy(compressionCount = snapshot.compressions)
}
if (snapshot.contextUsed != null && snapshot.contextUsed > 0L) {
updatedState = updatedState.copy(usedContextTokens = snapshot.contextUsed)
}
if (snapshot.contextMax != null && snapshot.contextMax > 0L) {
updatedState = updatedState.copy(fullContextTokens = snapshot.contextMax)
}
if (snapshot.avgTps != null && snapshot.avgTps > 0.0) {
updatedState = updatedState.copy(latestTps = snapshot.avgTps)
}
return ReducerResult(state = updatedState, streamingState = streamingState)
return ReducerResult(state = applyUsageSnapshot(state, snapshot), streamingState = streamingState)
}
}
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
data class StreamingState(
val streamingMessage: ChatMessage? = null,
val isThinking: Boolean = false,
@@ -14,4 +16,8 @@ data class StreamingState(
* every fresh StreamingState (message.start / interrupt / session switch).
*/
val sealedOrphanIds: List<String> = emptyList(),
/** Cumulative usage before the current turn; retained across tool-loop message segments. */
val turnUsageBaseline: UsageSnapshotResponse? = null,
/** Distinguishes a captured-but-unavailable baseline from a not-yet-captured one. */
val turnUsageBaselineCaptured: Boolean = false,
)
@@ -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)
}
}