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:
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)))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user