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 9acd8ccf..4250dc1f 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,30 +1,42 @@ 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 + * 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). + * sessions with no live agent), `context_used` = current window tokens, + * `context_max` = max context length. */ data class UsageSnapshotResponse( /** How many times this session has been context-compressed. */ - val compressions: Int?, + 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 totalTokens: Long? = null, ) /** - * Parse the decoded JSON-RPC result of `session.usage`. + * Parse the decoded JSON-RPC result of `session.usage` or `session.usage` push event payload. * - * 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. + * 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]. + * 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 + val usageMap = (map["usage"] as? Map<*, *>) ?: map return UsageSnapshotResponse( - compressions = (map["compressions"] as? Number)?.toInt(), + compressions = (usageMap["compressions"] as? Number)?.toInt(), + contextUsed = (usageMap["context_used"] as? Number)?.toLong(), + contextMax = (usageMap["context_max"] as? Number)?.toLong(), + totalTokens = (usageMap["total"] as? Number)?.toLong(), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index bee20d60..453c8d58 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -167,6 +167,10 @@ object EventParser { WsEvent.SessionUpdated(payload) } + "session.usage" -> { + WsEvent.SessionUsage(payload, sessionId) + } + "reaction" -> { val kind = payload?.get("kind") as? String ?: "" WsEvent.ReactionEvent(kind) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 77b7b4c2..b04890cf 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -161,6 +161,16 @@ sealed class WsEvent { val data: Map?, ) : WsEvent() + /** + * Live token & compression usage snapshot emitted during a turn (issue #919). + * Carried in `session.usage` push events (`tui_gateway/server.py` `_start_usage_ticker`). + * Payload: `{ "usage": { "compressions": Int, "context_used": Long, "context_max": Long, ... } }` + */ + data class SessionUsage( + val data: Map?, + val sessionId: String? = null, + ) : WsEvent() + // ── RPC responses ──────────────────────────────────────────────────── data class RpcResult( 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 6d44c45c..7e1cd2d3 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,6 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.data.model.parseUsageSnapshot import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.toJsonElement @@ -72,6 +73,7 @@ object ChatWsEventReducer { is WsEvent.ToolGenerating -> event.sessionId is WsEvent.SubagentEvent -> event.sessionId is WsEvent.ReviewSummary -> event.sessionId + is WsEvent.SessionUsage -> event.sessionId else -> null } if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) { @@ -129,6 +131,8 @@ object ChatWsEventReducer { is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event) + is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event) + is WsEvent.RpcError -> onRpcError(state, streamingState, event) is WsEvent.GatewayError -> onGatewayError(state, streamingState, event) @@ -834,6 +838,27 @@ object ChatWsEventReducer { streamingState = streamingState, ) } + + private fun onSessionUsage( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.SessionUsage, + ): ReducerResult { + 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) + } + return ReducerResult(state = updatedState, streamingState = streamingState) + } } /** diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 15984950..0fe7305d 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -634,4 +634,38 @@ class EventParserTest { assertEquals("sub-456", subagentEvent.payload?.get("subagent_id")) assertEquals("analyzing docs...", subagentEvent.payload?.get("text")) } + + @Test + fun testParseSessionUsage_returnsSessionUsageEvent() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "session.usage", + "session_id" to "sess-123", + "payload" to + mapOf( + "usage" to + mapOf( + "compressions" to 3, + "context_used" to 12500, + "context_max" to 128000, + "total" to 45000, + ), + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.SessionUsage) + val sessionUsage = event as WsEvent.SessionUsage + assertEquals("sess-123", sessionUsage.sessionId) + val usage = sessionUsage.data?.get("usage") as? Map<*, *> + assertEquals(3, (usage?.get("compressions") as? Number)?.toInt()) + assertEquals(12500, (usage?.get("context_used") as? Number)?.toInt()) + } } 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 cfc351d5..43f43064 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 @@ -869,4 +869,51 @@ class ChatWsEventReducerTest { assertEquals(listOf(line1, line2, summary), allAssistant.map { it.content }) assertEquals(null, result.streamingState.streamingMessage) } + + @Test + fun testSessionUsage_updatesCompressionCountAndContextTokens() { + val state = ChatUiState(currentSessionId = "session-1") + val usagePayload = + mapOf( + "usage" to + mapOf( + "compressions" to 2, + "context_used" to 8500L, + "context_max" to 128000L, + "total" to 25000L, + ), + ) + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = WsEvent.SessionUsage(data = usagePayload, sessionId = "session-1"), + currentSessionId = "session-1", + ) + assertEquals(2, result.state.compressionCount) + assertEquals(8500L, result.state.usedContextTokens) + assertEquals(128000L, result.state.fullContextTokens) + } + + @Test + fun testSessionUsage_differentSession_isIgnored() { + val state = ChatUiState(currentSessionId = "session-1", compressionCount = 1) + val usagePayload = + mapOf( + "usage" to + mapOf( + "compressions" to 5, + "context_used" to 99999L, + ), + ) + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = WsEvent.SessionUsage(data = usagePayload, sessionId = "session-other"), + currentSessionId = "session-1", + ) + assertEquals(1, result.state.compressionCount) + assertEquals(null, result.state.usedContextTokens) + } }