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..7a69978b 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 @@ -1803,6 +1807,8 @@ class ChatViewModel( if (text.isNotBlank()) "\n\n$text" else "" } + if (dispatchGeneration != sessionGeneration) return@launch + // While a turn is actively streaming and this is a plain text prompt // (no attachments — session.redirect carries text only), steer the // in-flight turn via session.redirect instead of queueing a fresh @@ -1811,6 +1817,7 @@ class ChatViewModel( if (dispatchGeneration == sessionGeneration) { ActiveSessionHolder.set(agentSessionId, storageSessionId) } + captureTurnUsageBaselineIfNeeded() if (wasStreaming && attachments.isEmpty()) { wsClient.sendRedirect( agentSessionId, @@ -1844,6 +1851,20 @@ class ChatViewModel( } } + private fun captureTurnUsageBaselineIfNeeded() { + if (_streamingState.value.turnUsageBaselineCaptured) return + _streamingState.update { + if (it.turnUsageBaselineCaptured) { + it + } else { + it.copy( + turnUsageBaseline = _uiState.value.sessionUsage, + turnUsageBaselineCaptured = true, + ) + } + } + } + /** Snapshot one URI to private cache while enforcing the outbound frame limit. */ private suspend fun prepareAttachment(attachment: Attachment): PrepareAttachmentResult { var encodedFile: File? = null @@ -2867,6 +2888,7 @@ class ChatViewModel( fullContextTokens = null, contextBreakdown = null, compressionCount = null, + sessionUsage = null, pendingAttachments = emptyList(), composerTextToRestore = null, reactionKind = null, @@ -3494,7 +3516,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 +3527,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..5b2f047b 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,24 @@ 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() + +private fun Double?.validTpsOrNull(): Double? = takeIf { it != null && it.isFinite() && it > 0.0 } + +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 +103,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 +262,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 +284,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 +402,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 +422,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.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull() + val tokenCount = + if (turnUsage?.outputTokens != null) { + turnUsage.outputTokens.toIntOrNullSafely() + } else { + TokenEstimator.estimate(text).takeIf { it > 0 } + } val msg = streaming?.copy( content = text, @@ -440,11 +475,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 +495,7 @@ object ChatWsEventReducer { streamingState.streamingMessage ?: return ReducerResult( state = state.copy(isAgentTyping = false), - streamingState = streamingState, + streamingState = StreamingState(), ) val reasoning = if (streamingState.reasoningText.isNotBlank()) { @@ -493,6 +529,7 @@ object ChatWsEventReducer { messages = state.messages.upsertById(msg), isAgentTyping = false, ), + streamingState = StreamingState(), effects = effects, ) } @@ -539,8 +576,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 +857,7 @@ object ChatWsEventReducer { isLoading = false, errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}", ), - streamingState = streamingState, + streamingState = StreamingState(), ) // ── BackgroundComplete ──────────────────────────────────────────── @@ -998,20 +1035,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..b6846926 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 @@ -75,6 +75,7 @@ class ChatViewModelTest { /** Counter used to generate unique WS request IDs. */ private var reqCount = 0 + private val sentRequestMethods = mutableListOf>() @Test fun mergeTranscriptWithLive_collapsesDuplicateIdsKeepingLatestMessage() { @@ -118,6 +119,7 @@ class ChatViewModelTest { fun setUp() { Dispatchers.setMain(testDispatcher) reqCount = 0 + sentRequestMethods.clear() mockkStatic(Log::class) every { Log.d(any(), any()) } returns 0 @@ -185,6 +187,7 @@ class ChatViewModelTest { every { HermesWsClient.send(any(), any(), any()) } answers { reqCount++ val id = "req-id-$reqCount" + sentRequestMethods += arg(0) to id arg<((String) -> Unit)?>(2)?.invoke(id) id } @@ -1883,7 +1886,7 @@ class ChatViewModelTest { @Test fun sendMessage_oversizedDuringSnapshotLeavesNoPersistedGhostMessage() = runTest { - val (viewModel, _) = createViewModelWithSession() + val (viewModel, sessionId) = createViewModelWithSession() val persistedBeforeSend = fakeRepo.dao.count() val uriString = "content://unknown/growing-file" val mockUri = mockk() @@ -1920,6 +1923,7 @@ class ChatViewModelTest { viewModel.sendMessage("Inspect changing file") advanceUntilIdle() + assertFalse(viewModel.streamingState.value.turnUsageBaselineCaptured) assertEquals(persistedBeforeSend, fakeRepo.dao.count()) assertFalse( fakeRepo.dao @@ -1931,6 +1935,24 @@ class ChatViewModelTest { viewModel.uiState.value.messages .any { it.content == "Inspect changing file" }, ) + + mockEventsFlow.emit( + WsEvent.SessionUsage( + data = mapOf("usage" to mapOf("output" to 1300L)), + sessionId = sessionId, + ), + ) + advanceUntilIdle() + viewModel.removeAttachment(0) + viewModel.sendMessage("Retry without attachment") + advanceUntilIdle() + + assertEquals( + 1300L, + viewModel.streamingState.value.turnUsageBaseline + ?.outputTokens, + ) + assertTrue(viewModel.streamingState.value.turnUsageBaselineCaptured) } @Test @@ -2091,7 +2113,7 @@ class ChatViewModelTest { viewModel.sendMessage("Send in the old session") advanceUntilIdle() - verify { + verify(exactly = 0) { HermesWsClient.sendMessage( oldSessionId, match { it.contains("Send in the old session") }, @@ -2099,17 +2121,44 @@ class ChatViewModelTest { any(), ) } - mockEventsFlow.emit( - WsEvent.RpcError( - promptRequestId, - JsonRpcError(code = 4001, message = "old session rejected prompt"), - ), - ) - advanceUntilIdle() + assertFalse(viewModel.streamingState.value.turnUsageBaselineCaptured) val state = viewModel.uiState.value assertEquals("session-456", state.currentSessionId) assertNull(state.errorMessage) + + val resumeRequestId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit( + WsEvent.RpcResult( + resumeRequestId, + mapOf("session_id" to "session-456"), + ), + ) + advanceUntilIdle() + mockEventsFlow.emit( + WsEvent.SessionUsage( + data = mapOf("usage" to mapOf("output" to 9000L)), + sessionId = "session-456", + ), + ) + advanceUntilIdle() + viewModel.sendMessage("Send in the new session") + advanceUntilIdle() + + assertEquals( + 9000L, + viewModel.streamingState.value.turnUsageBaseline + ?.outputTokens, + ) + assertTrue(viewModel.streamingState.value.turnUsageBaselineCaptured) + verify { + HermesWsClient.sendMessage( + "session-456", + match { it.contains("Send in the new session") }, + any(), + any(), + ) + } } @Test @@ -4178,6 +4227,33 @@ 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") + advanceUntilIdle() + + 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..7faa6810 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,8 +1,10 @@ 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 +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -979,7 +981,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 +999,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 +1097,269 @@ 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_invalidFinalTpsFallsBackToLatestValidTps() { + fun complete( + finalTps: Double?, + latestTps: Double?, + ): Double? { + val state = + ChatUiState( + currentSessionId = "session-1", + latestTps = latestTps, + sessionUsage = UsageSnapshotResponse(outputTokens = 1000), + ) + val start = + ChatWsEventReducer.reduce( + state, + StreamingState(), + WsEvent.MessageStart("session-1"), + "session-1", + ) + val usage = mutableMapOf("output" to 1250L) + if (finalTps != null) usage["avg_tps"] = finalTps + return ChatWsEventReducer + .reduce( + state, + start.streamingState, + WsEvent.MessageComplete("reply", "session-1", rawPayload = mapOf("usage" to usage)), + "session-1", + ).state.messages + .single() + .tps + } + + assertEquals(42.5, complete(42.5, 30.0) ?: 0.0, 0.001) + assertEquals(37.5, complete(0.0, 37.5) ?: 0.0, 0.001) + assertEquals(37.5, complete(-2.0, 37.5) ?: 0.0, 0.001) + assertEquals(37.5, complete(null, 37.5) ?: 0.0, 0.001) + assertNull(complete(-2.0, null)) + assertNull(complete(0.0, -1.0)) + } + + @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) + } }