fix(chat): context meter reflects compression via session.context_breakdown RPC (#759)

* 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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-01 19:46:49 +03:00
1 parent ed48e10ca6
commit e014d2a0e7
9 files changed
+320 -32

No files matched your search

@@ -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)
}
}
@@ -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)))
}
}