From d16a2ea35cb9d901d0af7750213d0f8e5e73eee7 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:34:05 +0300 Subject: [PATCH] feat(chat): use backend usage deltas for assistant stats --- .../data/model/UsageSnapshotResponse.kt | 247 +++++++++++++++--- .../hermescontrol/ui/chat/ChatViewModel.kt | 20 +- .../ui/chat/ChatWsEventReducer.kt | 84 +++--- .../hermescontrol/ui/chat/StreamingState.kt | 6 + .../data/model/UsageSnapshotResponseTest.kt | 178 +++++++++++++ .../ui/chat/ChatViewModelTest.kt | 26 ++ .../ui/chat/ChatWsEventReducerTest.kt | 243 ++++++++++++++++- 7 files changed, 726 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt index c20e1058..3a30930c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt @@ -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? = 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? = + when (val value = usageMap[key]) { + is JsonArray -> value.mapNotNull { (it as? JsonPrimitive)?.content } + is List<*> -> value.filterIsInstance() + is JsonElement -> (value.toAny() as? List<*>)?.filterIsInstance() + 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"), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index a50a5e91..1c716b70 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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 = 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) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index f368f80c..60135e48 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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.upsertById(message: ChatMessage): List = (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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt index db5e9a48..03ef553d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/StreamingState.kt @@ -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 = 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, ) diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt index ac27f750..c5404be4 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt @@ -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 = + 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) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index c828bca0..35ff224e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -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 { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 27baf301..1cdeac1c 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -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) + } }