Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
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
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal view of the `session.usage` WS RPC result — only the fields the
|
* Minimal view of the `session.usage` WS RPC result or `session.usage` push event —
|
||||||
* context widget consumes today. The backend sends many more keys
|
* only the fields the context widget consumes today. The backend sends many more keys
|
||||||
* (`input`/`output`/`total`/`calls`/`active_subagents`/`credits_lines` …);
|
* (`input`/`output`/`total`/`calls`/`active_subagents`/`credits_lines` …);
|
||||||
* parsing is key-by-key, so unmodelled keys are simply ignored.
|
* parsing is key-by-key, so unmodelled keys are simply ignored.
|
||||||
*
|
*
|
||||||
* Verified backend shape (`tui_gateway/server.py` `_get_usage`): `compressions`
|
* Verified backend shape (`tui_gateway/server.py` `_get_usage`): `compressions`
|
||||||
* = `context_compressor.compression_count` (0 when never compressed, absent on
|
* = `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(
|
data class UsageSnapshotResponse(
|
||||||
/** How many times this session has been context-compressed. */
|
/** 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`),
|
* Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
|
||||||
* so every count is read through [Number]. Returns null when the payload is
|
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
|
||||||
* not a map (error/malformed response) — callers keep the last known value.
|
* (`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? {
|
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
|
||||||
val map = result as? Map<*, *> ?: return null
|
val map = result as? Map<*, *> ?: return null
|
||||||
|
val usageMap = (map["usage"] as? Map<*, *>) ?: map
|
||||||
return UsageSnapshotResponse(
|
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)
|
WsEvent.SessionUpdated(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"session.usage" -> {
|
||||||
|
WsEvent.SessionUsage(payload, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
"reaction" -> {
|
"reaction" -> {
|
||||||
val kind = payload?.get("kind") as? String ?: ""
|
val kind = payload?.get("kind") as? String ?: ""
|
||||||
WsEvent.ReactionEvent(kind)
|
WsEvent.ReactionEvent(kind)
|
||||||
|
|||||||
@@ -161,6 +161,16 @@ sealed class WsEvent {
|
|||||||
val data: Map<String, Any?>?,
|
val data: Map<String, Any?>?,
|
||||||
) : WsEvent()
|
) : 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 ────────────────────────────────────────────────────
|
// ── RPC responses ────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class RpcResult(
|
data class RpcResult(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
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.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||||
@@ -72,6 +73,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.ToolGenerating -> event.sessionId
|
is WsEvent.ToolGenerating -> event.sessionId
|
||||||
is WsEvent.SubagentEvent -> event.sessionId
|
is WsEvent.SubagentEvent -> event.sessionId
|
||||||
is WsEvent.ReviewSummary -> event.sessionId
|
is WsEvent.ReviewSummary -> event.sessionId
|
||||||
|
is WsEvent.SessionUsage -> event.sessionId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
||||||
@@ -129,6 +131,8 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
|
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
|
||||||
|
|
||||||
|
is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.GatewayError -> onGatewayError(state, streamingState, event)
|
is WsEvent.GatewayError -> onGatewayError(state, streamingState, event)
|
||||||
@@ -834,6 +838,27 @@ object ChatWsEventReducer {
|
|||||||
streamingState = streamingState,
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -634,4 +634,38 @@ class EventParserTest {
|
|||||||
assertEquals("sub-456", subagentEvent.payload?.get("subagent_id"))
|
assertEquals("sub-456", subagentEvent.payload?.get("subagent_id"))
|
||||||
assertEquals("analyzing docs...", subagentEvent.payload?.get("text"))
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -869,4 +869,51 @@ class ChatWsEventReducerTest {
|
|||||||
assertEquals(listOf(line1, line2, summary), allAssistant.map { it.content })
|
assertEquals(listOf(line1, line2, summary), allAssistant.map { it.content })
|
||||||
assertEquals(null, result.streamingState.streamingMessage)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user