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
+314
-26
No files matched your search
@@ -0,0 +1,49 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live context-window occupancy from the `session.context_breakdown` WS RPC —
|
||||||
|
* the same RPC the Hermes desktop app's status-bar meter uses.
|
||||||
|
*
|
||||||
|
* Unlike the cumulative `input_tokens` REST counter, [contextUsed] is the
|
||||||
|
* live agent's actual prompt occupancy: the context compressor's
|
||||||
|
* `last_prompt_tokens` (the last prompt actually sent to the model), falling
|
||||||
|
* back to an estimate of the live system prompt + tools + (compacted)
|
||||||
|
* history. It therefore DROPS after context compression, which the cumulative
|
||||||
|
* counter never does (issue #756).
|
||||||
|
*
|
||||||
|
* All counts are nullable — the backend omits/zeroes them when there is no
|
||||||
|
* live agent or the engine doesn't track per-window occupancy.
|
||||||
|
*/
|
||||||
|
data class ContextBreakdownResponse(
|
||||||
|
/** Actual current prompt size in tokens, or null when unknown. */
|
||||||
|
val contextUsed: Long?,
|
||||||
|
/** The active model's full context window in tokens, or null. */
|
||||||
|
val contextMax: Long?,
|
||||||
|
/** Clamped 0-100 fill percentage, or null. */
|
||||||
|
val contextPercent: Int?,
|
||||||
|
/** Rough estimate of system prompt + tools + history, or null. */
|
||||||
|
val estimatedTotal: Long?,
|
||||||
|
val model: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the decoded JSON-RPC result of `session.context_breakdown`.
|
||||||
|
*
|
||||||
|
* The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`),
|
||||||
|
* so every count is read through [Number]. Returns null when the payload is
|
||||||
|
* not a map (error/malformed response) — callers treat that as unknown and
|
||||||
|
* keep the last known meter values.
|
||||||
|
*/
|
||||||
|
fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? {
|
||||||
|
val map = result as? Map<*, *> ?: return null
|
||||||
|
|
||||||
|
fun num(key: String): Long? = (map[key] as? Number)?.toLong()
|
||||||
|
|
||||||
|
return ContextBreakdownResponse(
|
||||||
|
contextUsed = num("context_used"),
|
||||||
|
contextMax = num("context_max"),
|
||||||
|
contextPercent = (map["context_percent"] as? Number)?.toInt(),
|
||||||
|
estimatedTotal = num("estimated_total"),
|
||||||
|
model = map["model"] as? String,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Response from `GET /api/sessions/{id}` (gated — rides the app's existing
|
* Response from `GET /api/sessions/{id}` (gated — rides the app's existing
|
||||||
* cookie auth). Only the fields the chat screen needs for the context meter
|
* cookie auth). Only the fields the chat screen's context detail sheet needs
|
||||||
* are modelled; the backend serializes many more and [kotlinx.serialization]
|
* are modelled; the backend serializes many more and [kotlinx.serialization]
|
||||||
* ignores the rest.
|
* ignores the rest.
|
||||||
*
|
*
|
||||||
@@ -13,15 +13,19 @@ import kotlinx.serialization.Serializable
|
|||||||
* `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`,
|
* `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`,
|
||||||
* `message_count`, etc. — but it does NOT have a `last_prompt_tokens` column
|
* `message_count`, etc. — but it does NOT have a `last_prompt_tokens` column
|
||||||
* (that field lives only on the in-memory `SessionEntry` in the gateway
|
* (that field lives only on the in-memory `SessionEntry` in the gateway
|
||||||
* process, never persisted to the REST response). So the *used* context window
|
* process, never persisted to the REST response).
|
||||||
* is sourced from `input_tokens` (cumulative prompt tokens for the session),
|
*
|
||||||
* paired with [ModelInfoResponse.effective_context_length] as the denominator.
|
* NOTE (issue #756): this endpoint is NO LONGER the chat meter's numerator
|
||||||
|
* source — `input_tokens` is a cumulative lifetime counter that never drops
|
||||||
|
* after context compression. The meter reads live occupancy from the
|
||||||
|
* `session.context_breakdown` WS RPC ([ContextBreakdownResponse]); this
|
||||||
|
* response feeds only the detail sheet's cumulative token accounting.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionDetailResponse(
|
data class SessionDetailResponse(
|
||||||
val session_id: String? = null,
|
val session_id: String? = null,
|
||||||
val session_key: String? = null,
|
val session_key: String? = null,
|
||||||
/** Cumulative prompt tokens for the session — the *used* context window. */
|
/** Cumulative prompt tokens for the session (lifetime accounting). */
|
||||||
val input_tokens: Long? = null,
|
val input_tokens: Long? = null,
|
||||||
val output_tokens: Long? = null,
|
val output_tokens: Long? = null,
|
||||||
val cache_read_tokens: Long? = null,
|
val cache_read_tokens: Long? = null,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal view of the `session.usage` WS RPC result — only the fields the
|
||||||
|
* context widget consumes today. The backend sends many more keys
|
||||||
|
* (`input`/`output`/`total`/`calls`/`active_subagents`/`credits_lines` …);
|
||||||
|
* parsing is key-by-key, so unmodelled keys are simply ignored.
|
||||||
|
*
|
||||||
|
* Verified backend shape (`tui_gateway/server.py` `_get_usage`): `compressions`
|
||||||
|
* = `context_compressor.compression_count` (0 when never compressed, absent on
|
||||||
|
* sessions with no live agent).
|
||||||
|
*/
|
||||||
|
data class UsageSnapshotResponse(
|
||||||
|
/** How many times this session has been context-compressed. */
|
||||||
|
val compressions: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the decoded JSON-RPC result of `session.usage`.
|
||||||
|
*
|
||||||
|
* The WS event parser decodes JSON numbers as [Double] (`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? {
|
||||||
|
val map = result as? Map<*, *> ?: return null
|
||||||
|
return UsageSnapshotResponse(
|
||||||
|
compressions = (map["compressions"] as? Number)?.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ object WsMethods {
|
|||||||
const val SESSION_TITLE = "session.title"
|
const val SESSION_TITLE = "session.title"
|
||||||
const val SESSION_BRANCH = "session.branch"
|
const val SESSION_BRANCH = "session.branch"
|
||||||
|
|
||||||
|
/** Live context-window occupancy for the chat meter (desktop-mirror). */
|
||||||
|
const val SESSION_CONTEXT_BREAKDOWN = "session.context_breakdown"
|
||||||
|
|
||||||
|
/** Lifetime usage snapshot (calls, token totals, compression count). */
|
||||||
|
const val SESSION_USAGE = "session.usage"
|
||||||
|
|
||||||
// ── Interaction ───────────────────────────────────────────────────────
|
// ── Interaction ───────────────────────────────────────────────────────
|
||||||
const val PROMPT_SUBMIT = "prompt.submit"
|
const val PROMPT_SUBMIT = "prompt.submit"
|
||||||
const val CLARIFY_RESPOND = "clarify.respond"
|
const val CLARIFY_RESPOND = "clarify.respond"
|
||||||
|
|||||||
@@ -542,6 +542,7 @@ fun ChatScreen(
|
|||||||
ContextUsageChip(
|
ContextUsageChip(
|
||||||
usedTokens = state.usedContextTokens,
|
usedTokens = state.usedContextTokens,
|
||||||
fullTokens = state.fullContextTokens,
|
fullTokens = state.fullContextTokens,
|
||||||
|
compressionCount = state.compressionCount,
|
||||||
onClick =
|
onClick =
|
||||||
if (state.contextBreakdown != null) {
|
if (state.contextBreakdown != null) {
|
||||||
{ showContextSheet = true }
|
{ 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.ModelProvider
|
||||||
import com.m57.hermescontrol.data.model.PinnedModel
|
import com.m57.hermescontrol.data.model.PinnedModel
|
||||||
import com.m57.hermescontrol.data.model.SessionMessage
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.GatewayFile
|
import com.m57.hermescontrol.data.remote.GatewayFile
|
||||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||||
@@ -98,7 +100,7 @@ data class ChatUiState(
|
|||||||
val currentSessionModel: String? = null,
|
val currentSessionModel: String? = null,
|
||||||
// Reasoning effort level for the current session
|
// Reasoning effort level for the current session
|
||||||
val reasoningLevel: String? = null,
|
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).
|
// prompt (numerator) and the active model's full context window (denominator).
|
||||||
// Both null until the first successful fetch.
|
// Both null until the first successful fetch.
|
||||||
val usedContextTokens: Long? = null,
|
val usedContextTokens: Long? = null,
|
||||||
@@ -106,6 +108,10 @@ data class ChatUiState(
|
|||||||
// Detailed token breakdown for the context meter's detail sheet (null until
|
// Detailed token breakdown for the context meter's detail sheet (null until
|
||||||
// the first successful session-detail fetch).
|
// the first successful session-detail fetch).
|
||||||
val contextBreakdown: ContextBreakdown? = null,
|
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
|
// Attachment state
|
||||||
val pendingAttachments: List<Attachment> = emptyList(),
|
val pendingAttachments: List<Attachment> = emptyList(),
|
||||||
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
// 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 breakdown backing the context meter's detail sheet. All values are
|
||||||
* token counts sourced from `GET /api/sessions/{id}` (`input_tokens`,
|
* cumulative lifetime token counts sourced from `GET /api/sessions/{id}`
|
||||||
* `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`,
|
* (`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
|
||||||
* `message_count`) — verified present on the live gateway's `sessions` table.
|
* `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(
|
data class ContextBreakdown(
|
||||||
val inputTokens: Long,
|
val inputTokens: Long,
|
||||||
@@ -598,6 +607,7 @@ class ChatViewModel(
|
|||||||
usedContextTokens = null,
|
usedContextTokens = null,
|
||||||
fullContextTokens = null,
|
fullContextTokens = null,
|
||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
|
compressionCount = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Mirror the active session id app-wide so session-scoped
|
// Mirror the active session id app-wide so session-scoped
|
||||||
@@ -623,6 +633,7 @@ class ChatViewModel(
|
|||||||
usedContextTokens = null,
|
usedContextTokens = null,
|
||||||
fullContextTokens = null,
|
fullContextTokens = null,
|
||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
|
compressionCount = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ActiveSessionHolder.set(newId)
|
ActiveSessionHolder.set(newId)
|
||||||
@@ -1459,6 +1470,7 @@ class ChatViewModel(
|
|||||||
usedContextTokens = null,
|
usedContextTokens = null,
|
||||||
fullContextTokens = null,
|
fullContextTokens = null,
|
||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
|
compressionCount = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Mirror the active session id app-wide (issue #532).
|
// 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
|
* The numerator comes from the `session.context_breakdown` WS RPC — the
|
||||||
* `last_prompt_tokens` (`/api/sessions/{id}`, backend
|
* same RPC the Hermes desktop app's status-bar meter uses. It reports the
|
||||||
* `gateway/session.py`). Denominator (`fullContextTokens`) comes from the
|
* live agent's actual prompt occupancy (compressor `last_prompt_tokens`,
|
||||||
* active model's `effective_context_length` (`/api/model/info`, PUBLIC).
|
* 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
|
* 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
|
* 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 sessionId = _uiState.value.currentSessionId ?: return
|
||||||
val profile = AuthManager.getSelectedProfileId()
|
val profile = AuthManager.getSelectedProfileId()
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
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 =
|
val fullResult =
|
||||||
safeApiCall { ApiClient.hermesApi.getModelInfo() }
|
safeApiCall { ApiClient.hermesApi.getModelInfo() }
|
||||||
if (fullResult is NetworkResult.Success) {
|
if (fullResult is NetworkResult.Success) {
|
||||||
@@ -1660,10 +1681,49 @@ class ChatViewModel(
|
|||||||
_uiState.update { it.copy(fullContextTokens = full) }
|
_uiState.update { it.copy(fullContextTokens = full) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Numerator: used context for THIS session. The gateway's sessions
|
// Numerator: live context occupancy from the gateway's live agent,
|
||||||
// table persists `input_tokens` (cumulative prompt tokens) — there is
|
// via the same RPC the desktop meter uses. `context_used` is the
|
||||||
// NO `last_prompt_tokens` column on the REST response, so we use
|
// real current prompt size (drops after compression); `context_max`
|
||||||
// `input_tokens` as the used-context numerator.
|
// 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 =
|
val usedResult =
|
||||||
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
||||||
if (usedResult is NetworkResult.Success) {
|
if (usedResult is NetworkResult.Success) {
|
||||||
@@ -1672,7 +1732,6 @@ class ChatViewModel(
|
|||||||
if (used != null) {
|
if (used != null) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
usedContextTokens = used,
|
|
||||||
contextBreakdown =
|
contextBreakdown =
|
||||||
ContextBreakdown(
|
ContextBreakdown(
|
||||||
inputTokens = used,
|
inputTokens = used,
|
||||||
|
|||||||
@@ -28,20 +28,26 @@ import kotlin.math.min
|
|||||||
* window so the user sees compression pressure building before a turn silently
|
* window so the user sees compression pressure building before a turn silently
|
||||||
* compacts.
|
* compacts.
|
||||||
*
|
*
|
||||||
* The denominator (`fullTokens`) comes from `GET /api/model/info` (PUBLIC) and
|
* The denominator (`fullTokens`) comes from the `session.context_breakdown`
|
||||||
* is always available. The numerator (`usedTokens`) comes from
|
* WS RPC's `context_max` (fallback `GET /api/model/info`), and the numerator
|
||||||
* `GET /api/sessions/{id}` (`input_tokens`) — gated, so it may lag or be absent
|
* (`usedTokens`) from the same RPC's `context_used` — the live agent's actual
|
||||||
* on first render. The chip shows as soon as `fullTokens` is known; when
|
* prompt occupancy, which drops after context compression (issue #756). The
|
||||||
* `usedTokens` is still null it renders `— / <full>` (unknown used) rather than
|
* RPC may be absent on first render (WS still connecting); the chip shows as
|
||||||
* hiding entirely, so a missing gated fetch never blanks the meter.
|
* 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 usedTokens tokens currently in the session prompt (numerator), or null
|
||||||
* @param fullTokens the active model's full context window (denominator)
|
* @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
|
@Composable
|
||||||
fun ContextUsageChip(
|
fun ContextUsageChip(
|
||||||
usedTokens: Long?,
|
usedTokens: Long?,
|
||||||
fullTokens: Long?,
|
fullTokens: Long?,
|
||||||
|
compressionCount: Int? = null,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -91,6 +97,18 @@ fun ContextUsageChip(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
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(
|
||||||
text = "$pct%",
|
text = "$pct%",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -98,6 +116,7 @@ fun ContextUsageChip(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -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