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
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user