From e014d2a0e79c80ad641823ec3b479b243d0c2b32 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:46:49 +0300 Subject: [PATCH] fix(chat): context meter reflects compression via session.context_breakdown RPC (#759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat): context meter reflects compression via session.context_breakdown RPC The chat context meter read GET /api/sessions/{id} input_tokens — a cumulative lifetime counter that compression never resets, so the meter only ever grew and sat red after the first compaction. Mirror the desktop: read live occupancy from the session.context_breakdown WS RPC (context_used = compressor last_prompt_tokens, falling back to an estimate of the live system prompt + tools + compacted history). The compressor zeroes last_prompt_tokens when it runs, so the meter now drops right after auto-compaction or /compress, exactly like desktop/web. REST getSessionDetail is kept solely to feed the detail sheet's cumulative token accounting. RPC failures keep the last known values; the meter never blanks. Fixes #756. * feat(chat): compressed xN badge on the context widget The context chip now shows how many times the current session has been context-compressed, sourced from the session.usage WS RPC (compressions = context_compressor.compression_count) — the same usage snapshot the desktop status bar reads. The badge explains a meter drop after auto-compaction or /compress: the widget no longer just shows the number, it shows the story. - UsageSnapshotResponse model + parseUsageSnapshot (Number-safe like the breakdown parser — WS parser decodes JSON numbers as Double) - compressionCount added to ChatUiState, reset on session switch/create/branch - session.usage fires alongside session.context_breakdown in the 5s poll; failures keep the last known badge value (best-effort, never blank) - 3 parser tests; full local suite 615 tests green --------- Co-authored-by: M57 --- .../data/model/ContextBreakdownResponse.kt | 49 ++++++++++ .../data/model/SessionDetailResponse.kt | 14 +-- .../data/model/UsageSnapshotResponse.kt | 30 +++++++ .../m57/hermescontrol/data/ws/WsMethods.kt | 6 ++ .../m57/hermescontrol/ui/chat/ChatScreen.kt | 1 + .../hermescontrol/ui/chat/ChatViewModel.kt | 89 +++++++++++++++---- .../ui/chat/components/ContextUsageChip.kt | 43 ++++++--- .../model/ContextBreakdownResponseTest.kt | 87 ++++++++++++++++++ .../data/model/UsageSnapshotResponseTest.kt | 33 +++++++ 9 files changed, 320 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt new file mode 100644 index 00000000..1dba8c43 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt @@ -0,0 +1,49 @@ +package com.m57.hermescontrol.data.model + +/** + * Live context-window occupancy from the `session.context_breakdown` WS RPC — + * the same RPC the Hermes desktop app's status-bar meter uses. + * + * Unlike the cumulative `input_tokens` REST counter, [contextUsed] is the + * live agent's actual prompt occupancy: the context compressor's + * `last_prompt_tokens` (the last prompt actually sent to the model), falling + * back to an estimate of the live system prompt + tools + (compacted) + * history. It therefore DROPS after context compression, which the cumulative + * counter never does (issue #756). + * + * All counts are nullable — the backend omits/zeroes them when there is no + * live agent or the engine doesn't track per-window occupancy. + */ +data class ContextBreakdownResponse( + /** Actual current prompt size in tokens, or null when unknown. */ + val contextUsed: Long?, + /** The active model's full context window in tokens, or null. */ + val contextMax: Long?, + /** Clamped 0-100 fill percentage, or null. */ + val contextPercent: Int?, + /** Rough estimate of system prompt + tools + history, or null. */ + val estimatedTotal: Long?, + val model: String?, +) + +/** + * Parse the decoded JSON-RPC result of `session.context_breakdown`. + * + * The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`), + * so every count is read through [Number]. Returns null when the payload is + * not a map (error/malformed response) — callers treat that as unknown and + * keep the last known meter values. + */ +fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? { + val map = result as? Map<*, *> ?: return null + + fun num(key: String): Long? = (map[key] as? Number)?.toLong() + + return ContextBreakdownResponse( + contextUsed = num("context_used"), + contextMax = num("context_max"), + contextPercent = (map["context_percent"] as? Number)?.toInt(), + estimatedTotal = num("estimated_total"), + model = map["model"] as? String, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionDetailResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionDetailResponse.kt index 3dc4a833..d84fc67d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionDetailResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionDetailResponse.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable /** * Response from `GET /api/sessions/{id}` (gated — rides the app's existing - * cookie auth). Only the fields the chat screen needs for the context meter + * cookie auth). Only the fields the chat screen's context detail sheet needs * are modelled; the backend serializes many more and [kotlinx.serialization] * ignores the rest. * @@ -13,15 +13,19 @@ import kotlinx.serialization.Serializable * `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, * `message_count`, etc. — but it does NOT have a `last_prompt_tokens` column * (that field lives only on the in-memory `SessionEntry` in the gateway - * process, never persisted to the REST response). So the *used* context window - * is sourced from `input_tokens` (cumulative prompt tokens for the session), - * paired with [ModelInfoResponse.effective_context_length] as the denominator. + * process, never persisted to the REST response). + * + * NOTE (issue #756): this endpoint is NO LONGER the chat meter's numerator + * source — `input_tokens` is a cumulative lifetime counter that never drops + * after context compression. The meter reads live occupancy from the + * `session.context_breakdown` WS RPC ([ContextBreakdownResponse]); this + * response feeds only the detail sheet's cumulative token accounting. */ @Serializable data class SessionDetailResponse( val session_id: String? = null, val session_key: String? = null, - /** Cumulative prompt tokens for the session — the *used* context window. */ + /** Cumulative prompt tokens for the session (lifetime accounting). */ val input_tokens: Long? = null, val output_tokens: Long? = null, val cache_read_tokens: Long? = null, 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 new file mode 100644 index 00000000..9acd8ccf --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt @@ -0,0 +1,30 @@ +package com.m57.hermescontrol.data.model + +/** + * Minimal view of the `session.usage` WS RPC result — 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). + */ +data class UsageSnapshotResponse( + /** How many times this session has been context-compressed. */ + val compressions: Int?, +) + +/** + * Parse the decoded JSON-RPC result of `session.usage`. + * + * The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`), + * so every count is read through [Number]. Returns null when the payload is + * not a map (error/malformed response) — callers keep the last known value. + */ +fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? { + val map = result as? Map<*, *> ?: return null + return UsageSnapshotResponse( + compressions = (map["compressions"] as? Number)?.toInt(), + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index e8e3a1fe..b809b2f5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -15,6 +15,12 @@ object WsMethods { const val SESSION_TITLE = "session.title" const val SESSION_BRANCH = "session.branch" + /** Live context-window occupancy for the chat meter (desktop-mirror). */ + const val SESSION_CONTEXT_BREAKDOWN = "session.context_breakdown" + + /** Lifetime usage snapshot (calls, token totals, compression count). */ + const val SESSION_USAGE = "session.usage" + // ── Interaction ─────────────────────────────────────────────────────── const val PROMPT_SUBMIT = "prompt.submit" const val CLARIFY_RESPOND = "clarify.respond" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 0e42e774..e9839df4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -542,6 +542,7 @@ fun ChatScreen( ContextUsageChip( usedTokens = state.usedContextTokens, fullTokens = state.fullContextTokens, + compressionCount = state.compressionCount, onClick = if (state.contextBreakdown != null) { { showContextSheet = true } 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 e1c7f02c..dd7074f1 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 @@ -14,6 +14,8 @@ import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.SessionMessage +import com.m57.hermescontrol.data.model.parseContextBreakdown +import com.m57.hermescontrol.data.model.parseUsageSnapshot import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.GatewayFile import com.m57.hermescontrol.data.remote.GatewayFileClient @@ -98,7 +100,7 @@ data class ChatUiState( val currentSessionModel: String? = null, // Reasoning effort level for the current session val reasoningLevel: String? = null, - // Context-window meter (issue #XXX): tokens currently used by the session + // Context-window meter (issue #756): tokens currently used by the session // prompt (numerator) and the active model's full context window (denominator). // Both null until the first successful fetch. val usedContextTokens: Long? = null, @@ -106,6 +108,10 @@ data class ChatUiState( // Detailed token breakdown for the context meter's detail sheet (null until // the first successful session-detail fetch). val contextBreakdown: ContextBreakdown? = null, + // How many times the current session has been context-compressed (null + // until the first successful session.usage fetch) — drives the + // "compressed ×N" badge on the context chip. + val compressionCount: Int? = null, // Attachment state val pendingAttachments: List = emptyList(), // Reaction animation — set when a reaction WS event arrives, auto-clears @@ -160,9 +166,12 @@ data class SecretPromptUi( /** * Token breakdown backing the context meter's detail sheet. All values are - * token counts sourced from `GET /api/sessions/{id}` (`input_tokens`, - * `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`, - * `message_count`) — verified present on the live gateway's `sessions` table. + * cumulative lifetime token counts sourced from `GET /api/sessions/{id}` + * (`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, + * `reasoning_tokens`, `message_count`) — verified present on the live + * gateway's `sessions` table. Informational accounting only; the meter's + * live used/full values come from the `session.context_breakdown` RPC + * (issue #756). */ data class ContextBreakdown( val inputTokens: Long, @@ -598,6 +607,7 @@ class ChatViewModel( usedContextTokens = null, fullContextTokens = null, contextBreakdown = null, + compressionCount = null, ) } // Mirror the active session id app-wide so session-scoped @@ -623,6 +633,7 @@ class ChatViewModel( usedContextTokens = null, fullContextTokens = null, contextBreakdown = null, + compressionCount = null, ) } ActiveSessionHolder.set(newId) @@ -1459,6 +1470,7 @@ class ChatViewModel( usedContextTokens = null, fullContextTokens = null, contextBreakdown = null, + compressionCount = null, ) } // Mirror the active session id app-wide (issue #532). @@ -1631,12 +1643,20 @@ class ChatViewModel( } /** - * Refresh the context-window meter for the current session. + * Refresh the context meter: used / full tokens for the current session. * - * Numerator (`usedContextTokens`) comes from the session record's - * `last_prompt_tokens` (`/api/sessions/{id}`, backend - * `gateway/session.py`). Denominator (`fullContextTokens`) comes from the - * active model's `effective_context_length` (`/api/model/info`, PUBLIC). + * The numerator comes from the `session.context_breakdown` WS RPC — the + * same RPC the Hermes desktop app's status-bar meter uses. It reports the + * live agent's actual prompt occupancy (compressor `last_prompt_tokens`, + * falling back to an estimate of the live system prompt + tools + + * history), so it DROPS after context compression. The previous numerator, + * `GET /api/sessions/{id}` `input_tokens`, is a cumulative lifetime + * counter that never resets on compression (issue #756). + * + * The denominator comes from the RPC's `context_max` (the compressor's + * real context window) when present, else `GET /api/model/info` + * `effective_context_length`. The REST session-detail call is kept only to + * feed the detail sheet's cumulative token accounting. * * Both calls are independent and best-effort: a failure on one must not * wipe the other's already-shown value, and neither blocks the chat. The @@ -1648,7 +1668,8 @@ class ChatViewModel( val sessionId = _uiState.value.currentSessionId ?: return val profile = AuthManager.getSelectedProfileId() viewModelScope.launch(Dispatchers.IO) { - // Denominator: full context window (cheap, public, rarely changes). + // Denominator fallback: full context window (cheap, public, rarely + // changes). The RPC's context_max below overrides it when present. val fullResult = safeApiCall { ApiClient.hermesApi.getModelInfo() } if (fullResult is NetworkResult.Success) { @@ -1660,10 +1681,49 @@ class ChatViewModel( _uiState.update { it.copy(fullContextTokens = full) } } } - // Numerator: used context for THIS session. The gateway's sessions - // table persists `input_tokens` (cumulative prompt tokens) — there is - // NO `last_prompt_tokens` column on the REST response, so we use - // `input_tokens` as the used-context numerator. + // Numerator: live context occupancy from the gateway's live agent, + // via the same RPC the desktop meter uses. `context_used` is the + // real current prompt size (drops after compression); `context_max` + // is the compressor's actual window. Any failure keeps the last + // known values — never blank the meter over a transient RPC error. + val rpcSessionId = runtimeSessionId ?: sessionId + try { + val result = + sendRpcAndAwait( + WsMethods.SESSION_CONTEXT_BREAKDOWN, + mapOf("session_id" to rpcSessionId), + ) + val ctx = parseContextBreakdown(result) + if (ctx != null) { + _uiState.update { current -> + current.copy( + usedContextTokens = + ctx.contextUsed?.takeIf { it > 0L } ?: current.usedContextTokens, + fullContextTokens = + ctx.contextMax?.takeIf { it > 0L } ?: current.fullContextTokens, + ) + } + } + } catch (_: Exception) { + // Best-effort: RPC error/timeout/disconnect — keep last values. + } + // Compression count: how many times this session has been compacted + // (session.usage → compressions). Feeds the "compressed ×N" badge — + // the same usage snapshot the desktop status bar reads. + try { + val usage = + sendRpcAndAwait( + WsMethods.SESSION_USAGE, + mapOf("session_id" to rpcSessionId), + ) + val snapshot = parseUsageSnapshot(usage) + if (snapshot != null && snapshot.compressions != null) { + _uiState.update { it.copy(compressionCount = snapshot.compressions) } + } + } catch (_: Exception) { + // Best-effort: keep the last known badge value. + } + // Detail-sheet accounting (cumulative REST counters, informational). val usedResult = safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) } if (usedResult is NetworkResult.Success) { @@ -1672,7 +1732,6 @@ class ChatViewModel( if (used != null) { _uiState.update { it.copy( - usedContextTokens = used, contextBreakdown = ContextBreakdown( inputTokens = used, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt index 6f1ada15..35185393 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt @@ -28,20 +28,26 @@ import kotlin.math.min * window so the user sees compression pressure building before a turn silently * compacts. * - * The denominator (`fullTokens`) comes from `GET /api/model/info` (PUBLIC) and - * is always available. The numerator (`usedTokens`) comes from - * `GET /api/sessions/{id}` (`input_tokens`) — gated, so it may lag or be absent - * on first render. The chip shows as soon as `fullTokens` is known; when - * `usedTokens` is still null it renders `— / ` (unknown used) rather than - * hiding entirely, so a missing gated fetch never blanks the meter. + * The denominator (`fullTokens`) comes from the `session.context_breakdown` + * WS RPC's `context_max` (fallback `GET /api/model/info`), and the numerator + * (`usedTokens`) from the same RPC's `context_used` — the live agent's actual + * prompt occupancy, which drops after context compression (issue #756). The + * RPC may be absent on first render (WS still connecting); the chip shows as + * soon as `fullTokens` is known, and when `usedTokens` is still null it + * renders `— / ` (unknown used) rather than hiding entirely, so a + * missing fetch never blanks the meter. * * @param usedTokens tokens currently in the session prompt (numerator), or null * @param fullTokens the active model's full context window (denominator) + * @param compressionCount how many times the session has been context-compressed; + * when > 0 a "compressed ×N" badge renders next to the percentage — the badge + * explains a meter drop after auto-compaction or `/compress` (issue #756) */ @Composable fun ContextUsageChip( usedTokens: Long?, fullTokens: Long?, + compressionCount: Int? = null, onClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { @@ -91,12 +97,25 @@ fun ContextUsageChip( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = "$pct%", - style = MaterialTheme.typography.labelSmall, - color = barColor, - maxLines = 1, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (compressionCount != null && compressionCount > 0) { + Text( + text = "compressed ×$compressionCount", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + maxLines = 1, + ) + } + Text( + text = "$pct%", + style = MaterialTheme.typography.labelSmall, + color = barColor, + maxLines = 1, + ) + } } Box( modifier = diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt new file mode 100644 index 00000000..bbd638be --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt @@ -0,0 +1,87 @@ +package com.m57.hermescontrol.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Parser tests for the `session.context_breakdown` WS RPC result. + * + * The WS event parser (`JsonRpcModels.toAny`) decodes JSON numbers as Double, + * so the parser must read every count through [Number] — these tests use + * Double literals to mirror the real wire shape. + */ +class ContextBreakdownResponseTest { + @Test + fun `full payload parses all fields`() { + val result = + mapOf( + "context_used" to 123456.0, + "context_max" to 262144.0, + "context_percent" to 47.0, + "estimated_total" to 120000.0, + "model" to "deepseek-v4-flash", + ) + + val parsed = parseContextBreakdown(result) + + assertEquals(123456L, parsed?.contextUsed) + assertEquals(262144L, parsed?.contextMax) + assertEquals(47, parsed?.contextPercent) + assertEquals(120000L, parsed?.estimatedTotal) + assertEquals("deepseek-v4-flash", parsed?.model) + } + + @Test + fun `missing optional keys parse to null`() { + val result = mapOf("context_used" to 5000.0) + + val parsed = parseContextBreakdown(result) + + assertEquals(5000L, parsed?.contextUsed) + assertNull(parsed?.contextMax) + assertNull(parsed?.contextPercent) + assertNull(parsed?.estimatedTotal) + assertNull(parsed?.model) + } + + @Test + fun `zero and negative counts survive parsing`() { + val result = + mapOf( + "context_used" to 0.0, + "context_max" to -1.0, + ) + + val parsed = parseContextBreakdown(result) + + // The >0 policy lives in the consumer (ChatViewModel), not the parser — + // the parser reports the wire value faithfully. + assertEquals(0L, parsed?.contextUsed) + assertEquals(-1L, parsed?.contextMax) + } + + @Test + fun `non-map result returns null`() { + assertNull(parseContextBreakdown(null)) + assertNull(parseContextBreakdown("oops")) + assertNull(parseContextBreakdown(listOf(1.0, 2.0))) + assertNull(parseContextBreakdown(42.0)) + } + + @Test + fun `integer-typed numbers parse too`() { + val result = + mapOf( + "context_used" to 999L, + "context_max" to 262144, + "context_percent" to 0, + ) + + val parsed = parseContextBreakdown(result) + + assertEquals(999L, parsed?.contextUsed) + assertEquals(262144L, parsed?.contextMax) + assertEquals(0, parsed?.contextPercent) + } +} 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 new file mode 100644 index 00000000..341e2bb9 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt @@ -0,0 +1,33 @@ +package com.m57.hermescontrol.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Parser tests for the `session.usage` WS RPC result. + * + * The WS event parser (`JsonRpcModels.toAny`) decodes JSON numbers as Double, + * so the parser reads counts through [Number] — tests use Double literals to + * mirror the real wire shape. + */ +class UsageSnapshotResponseTest { + @Test + fun `compressions parses from Double and Int`() { + assertEquals(2, parseUsageSnapshot(mapOf("compressions" to 2.0))?.compressions) + assertEquals(0, parseUsageSnapshot(mapOf("compressions" to 0))?.compressions) + } + + @Test + fun `missing compressions key parses to null`() { + val parsed = parseUsageSnapshot(mapOf("calls" to 12.0, "total" to 5000.0)) + assertNull(parsed?.compressions) + } + + @Test + fun `non-map result returns null`() { + assertNull(parseUsageSnapshot(null)) + assertNull(parseUsageSnapshot("oops")) + assertNull(parseUsageSnapshot(listOf(1.0))) + } +}