feat(#919): adopt session.usage WS push event for real-time context & compression updates (#927)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-17 23:31:07 +03:00
1 parent d6892cde7f
commit acd82c0085
6 files changed
+141 -9

No files matched your search

@@ -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(),
)
}
@@ -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)
@@ -161,6 +161,16 @@ sealed class WsEvent {
val data: Map<String, Any?>?,
) : 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<String, Any?>?,
val sessionId: String? = null,
) : WsEvent()
// ── RPC responses ────────────────────────────────────────────────────
data class RpcResult(
@@ -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)
}
}
/**