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

@@ -542,6 +542,7 @@ fun ChatScreen(
ContextUsageChip(
usedTokens = state.usedContextTokens,
fullTokens = state.fullContextTokens,
compressionCount = state.compressionCount,
onClick =
if (state.contextBreakdown != null) {
{ showContextSheet = true }
@@ -14,6 +14,8 @@ import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.data.model.parseContextBreakdown
import com.m57.hermescontrol.data.model.parseUsageSnapshot
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.GatewayFile
import com.m57.hermescontrol.data.remote.GatewayFileClient
@@ -98,7 +100,7 @@ data class ChatUiState(
val currentSessionModel: String? = null,
// Reasoning effort level for the current session
val reasoningLevel: String? = null,
// Context-window meter (issue #XXX): tokens currently used by the session
// Context-window meter (issue #756): tokens currently used by the session
// prompt (numerator) and the active model's full context window (denominator).
// Both null until the first successful fetch.
val usedContextTokens: Long? = null,
@@ -106,6 +108,10 @@ data class ChatUiState(
// Detailed token breakdown for the context meter's detail sheet (null until
// the first successful session-detail fetch).
val contextBreakdown: ContextBreakdown? = null,
// How many times the current session has been context-compressed (null
// until the first successful session.usage fetch) — drives the
// "compressed ×N" badge on the context chip.
val compressionCount: Int? = null,
// Attachment state
val pendingAttachments: List<Attachment> = emptyList(),
// Reaction animation — set when a reaction WS event arrives, auto-clears
@@ -160,9 +166,12 @@ data class SecretPromptUi(
/**
* Token breakdown backing the context meter's detail sheet. All values are
* token counts sourced from `GET /api/sessions/{id}` (`input_tokens`,
* `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`,
* `message_count`) — verified present on the live gateway's `sessions` table.
* cumulative lifetime token counts sourced from `GET /api/sessions/{id}`
* (`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
* `reasoning_tokens`, `message_count`) — verified present on the live
* gateway's `sessions` table. Informational accounting only; the meter's
* live used/full values come from the `session.context_breakdown` RPC
* (issue #756).
*/
data class ContextBreakdown(
val inputTokens: Long,
@@ -598,6 +607,7 @@ class ChatViewModel(
usedContextTokens = null,
fullContextTokens = null,
contextBreakdown = null,
compressionCount = null,
)
}
// Mirror the active session id app-wide so session-scoped
@@ -623,6 +633,7 @@ class ChatViewModel(
usedContextTokens = null,
fullContextTokens = null,
contextBreakdown = null,
compressionCount = null,
)
}
ActiveSessionHolder.set(newId)
@@ -1459,6 +1470,7 @@ class ChatViewModel(
usedContextTokens = null,
fullContextTokens = null,
contextBreakdown = null,
compressionCount = null,
)
}
// Mirror the active session id app-wide (issue #532).
@@ -1631,12 +1643,20 @@ class ChatViewModel(
}
/**
* Refresh the context-window meter for the current session.
* Refresh the context meter: used / full tokens for the current session.
*
* Numerator (`usedContextTokens`) comes from the session record's
* `last_prompt_tokens` (`/api/sessions/{id}`, backend
* `gateway/session.py`). Denominator (`fullContextTokens`) comes from the
* active model's `effective_context_length` (`/api/model/info`, PUBLIC).
* The numerator comes from the `session.context_breakdown` WS RPC — the
* same RPC the Hermes desktop app's status-bar meter uses. It reports the
* live agent's actual prompt occupancy (compressor `last_prompt_tokens`,
* falling back to an estimate of the live system prompt + tools +
* history), so it DROPS after context compression. The previous numerator,
* `GET /api/sessions/{id}` `input_tokens`, is a cumulative lifetime
* counter that never resets on compression (issue #756).
*
* The denominator comes from the RPC's `context_max` (the compressor's
* real context window) when present, else `GET /api/model/info`
* `effective_context_length`. The REST session-detail call is kept only to
* feed the detail sheet's cumulative token accounting.
*
* Both calls are independent and best-effort: a failure on one must not
* wipe the other's already-shown value, and neither blocks the chat. The
@@ -1648,7 +1668,8 @@ class ChatViewModel(
val sessionId = _uiState.value.currentSessionId ?: return
val profile = AuthManager.getSelectedProfileId()
viewModelScope.launch(Dispatchers.IO) {
// Denominator: full context window (cheap, public, rarely changes).
// Denominator fallback: full context window (cheap, public, rarely
// changes). The RPC's context_max below overrides it when present.
val fullResult =
safeApiCall { ApiClient.hermesApi.getModelInfo() }
if (fullResult is NetworkResult.Success) {
@@ -1660,10 +1681,49 @@ class ChatViewModel(
_uiState.update { it.copy(fullContextTokens = full) }
}
}
// Numerator: used context for THIS session. The gateway's sessions
// table persists `input_tokens` (cumulative prompt tokens) — there is
// NO `last_prompt_tokens` column on the REST response, so we use
// `input_tokens` as the used-context numerator.
// Numerator: live context occupancy from the gateway's live agent,
// via the same RPC the desktop meter uses. `context_used` is the
// real current prompt size (drops after compression); `context_max`
// is the compressor's actual window. Any failure keeps the last
// known values — never blank the meter over a transient RPC error.
val rpcSessionId = runtimeSessionId ?: sessionId
try {
val result =
sendRpcAndAwait(
WsMethods.SESSION_CONTEXT_BREAKDOWN,
mapOf("session_id" to rpcSessionId),
)
val ctx = parseContextBreakdown(result)
if (ctx != null) {
_uiState.update { current ->
current.copy(
usedContextTokens =
ctx.contextUsed?.takeIf { it > 0L } ?: current.usedContextTokens,
fullContextTokens =
ctx.contextMax?.takeIf { it > 0L } ?: current.fullContextTokens,
)
}
}
} catch (_: Exception) {
// Best-effort: RPC error/timeout/disconnect — keep last values.
}
// Compression count: how many times this session has been compacted
// (session.usage → compressions). Feeds the "compressed ×N" badge —
// the same usage snapshot the desktop status bar reads.
try {
val usage =
sendRpcAndAwait(
WsMethods.SESSION_USAGE,
mapOf("session_id" to rpcSessionId),
)
val snapshot = parseUsageSnapshot(usage)
if (snapshot != null && snapshot.compressions != null) {
_uiState.update { it.copy(compressionCount = snapshot.compressions) }
}
} catch (_: Exception) {
// Best-effort: keep the last known badge value.
}
// Detail-sheet accounting (cumulative REST counters, informational).
val usedResult =
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
if (usedResult is NetworkResult.Success) {
@@ -1672,7 +1732,6 @@ class ChatViewModel(
if (used != null) {
_uiState.update {
it.copy(
usedContextTokens = used,
contextBreakdown =
ContextBreakdown(
inputTokens = used,
@@ -28,20 +28,26 @@ import kotlin.math.min
* window so the user sees compression pressure building before a turn silently
* compacts.
*
* The denominator (`fullTokens`) comes from `GET /api/model/info` (PUBLIC) and
* is always available. The numerator (`usedTokens`) comes from
* `GET /api/sessions/{id}` (`input_tokens`) — gated, so it may lag or be absent
* on first render. The chip shows as soon as `fullTokens` is known; when
* `usedTokens` is still null it renders `— / <full>` (unknown used) rather than
* hiding entirely, so a missing gated fetch never blanks the meter.
* The denominator (`fullTokens`) comes from the `session.context_breakdown`
* WS RPC's `context_max` (fallback `GET /api/model/info`), and the numerator
* (`usedTokens`) from the same RPC's `context_used` — the live agent's actual
* prompt occupancy, which drops after context compression (issue #756). The
* RPC may be absent on first render (WS still connecting); the chip shows as
* soon as `fullTokens` is known, and when `usedTokens` is still null it
* renders `— / <full>` (unknown used) rather than hiding entirely, so a
* missing fetch never blanks the meter.
*
* @param usedTokens tokens currently in the session prompt (numerator), or null
* @param fullTokens the active model's full context window (denominator)
* @param compressionCount how many times the session has been context-compressed;
* when > 0 a "compressed ×N" badge renders next to the percentage — the badge
* explains a meter drop after auto-compaction or `/compress` (issue #756)
*/
@Composable
fun ContextUsageChip(
usedTokens: Long?,
fullTokens: Long?,
compressionCount: Int? = null,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
@@ -91,12 +97,25 @@ fun ContextUsageChip(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "$pct%",
style = MaterialTheme.typography.labelSmall,
color = barColor,
maxLines = 1,
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (compressionCount != null && compressionCount > 0) {
Text(
text = "compressed ×$compressionCount",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
maxLines = 1,
)
}
Text(
text = "$pct%",
style = MaterialTheme.typography.labelSmall,
color = barColor,
maxLines = 1,
)
}
}
Box(
modifier =