fix(chat): keep context meter aligned after model swaps (#1103)

This commit is contained in:
M57 committed 2026-09-13 19:39:33 +03:00
1 parent 4246ae196d
commit 0a2c782139
8 files changed
+720 -127

No files matched your search

@@ -1,5 +1,11 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Live context-window occupancy from the `session.context_breakdown` WS RPC —
* the same RPC the Hermes desktop app's status-bar meter uses.
@@ -30,20 +36,47 @@ data class ContextBreakdownResponse(
* 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
* so every count is read through [Number]. Also supports raw [JsonObject] from
* [HermesWsClient.request]. 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
val rawMap =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
} ?: return null
fun num(key: String): Long? = (map[key] as? Number)?.toLong()
fun num(key: String): Long? =
when (val value = rawMap[key]) {
is Number -> value.toLong()
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
is String -> value.toLongOrNull()
else -> null
}
fun intNum(key: String): Int? =
when (val value = rawMap[key]) {
is Number -> value.toInt()
is JsonPrimitive -> value.intOrNull
is String -> value.toIntOrNull()
else -> null
}
fun str(key: String): String? =
when (val value = rawMap[key]) {
is String -> value
is JsonPrimitive -> value.content
else -> value?.toString()
}
return ContextBreakdownResponse(
contextUsed = num("context_used"),
contextMax = num("context_max"),
contextPercent = (map["context_percent"] as? Number)?.toInt(),
contextPercent = intNum("context_percent"),
estimatedTotal = num("estimated_total"),
model = map["model"] as? String,
model = str("model"),
)
}
@@ -1,5 +1,12 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Minimal view of the `session.usage` WS RPC result or `session.usage` push event —
* only the fields the context widget consumes today. The backend sends many more keys
@@ -29,17 +36,47 @@ data class UsageSnapshotResponse(
*
* Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
* (from push event payload). The WS event parser decodes JSON numbers as [Double]
* (`JsonRpcModels.toAny`), so every count is read through [Number].
* (`JsonRpcModels.toAny`), so every count is read through [Number]. Also supports raw [JsonObject].
* 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
val usageMap = (map["usage"] as? Map<*, *>) ?: map
val rawMap =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
} ?: return null
val usageMap = (rawMap["usage"] as? Map<*, *>) ?: rawMap
fun num(key: String): Long? =
when (val value = usageMap[key]) {
is Number -> value.toLong()
is JsonPrimitive -> value.longOrNull ?: value.intOrNull?.toLong()
is String -> value.toLongOrNull()
else -> null
}
fun intNum(key: String): Int? =
when (val value = usageMap[key]) {
is Number -> value.toInt()
is JsonPrimitive -> value.intOrNull
is String -> value.toIntOrNull()
else -> null
}
fun doubleNum(key: String): Double? =
when (val value = usageMap[key]) {
is Number -> value.toDouble()
is JsonPrimitive -> value.doubleOrNull ?: value.longOrNull?.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}
return UsageSnapshotResponse(
compressions = (usageMap["compressions"] as? Number)?.toInt(),
contextUsed = (usageMap["context_used"] as? Number)?.toLong(),
contextMax = (usageMap["context_max"] as? Number)?.toLong(),
totalTokens = (usageMap["total"] as? Number)?.toLong(),
avgTps = (usageMap["avg_tps"] as? Number)?.toDouble(),
compressions = intNum("compressions"),
contextUsed = num("context_used"),
contextMax = num("context_max"),
totalTokens = num("total"),
avgTps = doubleNum("avg_tps"),
)
}
@@ -22,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap
private data class ActiveModelSwitch(
val spec: String,
val previousModel: String?,
val sequence: Long,
)
private data class ActiveFastSwitch(
@@ -44,6 +45,7 @@ class ChatModelSwitchDelegate(
private val addAssistantMessage: (text: String) -> Unit,
private val handleSlashCommand: (command: String) -> Unit,
private val fetchContextUsage: () -> Unit,
private val onModelSwitchInitiated: () -> Unit = {},
private val getModelOptionsCall: suspend (
refresh: Boolean,
) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> =
@@ -56,6 +58,9 @@ class ChatModelSwitchDelegate(
private val fastRejectedModels = ConcurrentHashMap.newKeySet<String>()
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
private var optimisticPreviousModel: String? = null
private var lastConfirmedModel: String? = null
private var unconfirmedTargetModel: String? = null
private var modelSwitchSequence: Long = 0L
private var cachedModelOptions: List<ModelProvider> = emptyList()
fun isModelPickerCommand(command: String): Boolean {
@@ -142,20 +147,37 @@ class ChatModelSwitchDelegate(
uiState.update { it.copy(modelPickerPinned = currentPinned) }
}
fun consumeOptimisticPreviousModel(): String? = optimisticPreviousModel.also { optimisticPreviousModel = null }
fun sendSlashModel(
provider: String,
model: String,
) {
optimisticPreviousModel = uiState.value.currentSessionModel
val targetModelLabel = "$provider/$model"
val isDifferent = !targetModelLabel.equals(uiState.value.currentSessionModel, ignoreCase = true)
if (!isDifferent) {
uiState.update {
it.copy(
showModelPicker = false,
modelPickerLoading = false,
)
}
return
}
if (optimisticPreviousModel == null) {
optimisticPreviousModel = uiState.value.currentSessionModel
}
unconfirmedTargetModel = targetModelLabel
uiState.update {
it.copy(
showModelPicker = false,
modelPickerLoading = false,
currentSessionModel = "$provider/$model",
currentSessionModel = targetModelLabel,
fullContextTokens = null,
)
}
onModelSwitchInitiated()
syncCurrentModelCapabilities()
fetchContextUsage()
handleSlashCommand("/model $model --provider $provider --session")
}
@@ -174,8 +196,15 @@ class ChatModelSwitchDelegate(
} else {
command.trim()
}
val previousModel = optimisticPreviousModel ?: uiState.value.currentSessionModel
optimisticPreviousModel = null
val previousModel = lastConfirmedModel ?: optimisticPreviousModel ?: uiState.value.currentSessionModel
// Retain optimisticPreviousModel for consumeOptimisticPreviousModel() when SessionInfo arrives.
if (optimisticPreviousModel == null && !spec.equals(uiState.value.currentSessionModel, ignoreCase = true)) {
optimisticPreviousModel = uiState.value.currentSessionModel
uiState.update { it.copy(fullContextTokens = null) }
onModelSwitchInitiated()
}
unconfirmedTargetModel = spec
val switchSeq = ++modelSwitchSequence
val params =
mutableMapOf<String, Any>(
"key" to "model",
@@ -191,7 +220,7 @@ class ChatModelSwitchDelegate(
params,
) { id ->
trackRequest(id, WsMethods.CONFIG_SET)
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel)
pendingModelSwitchRequests[id] = ActiveModelSwitch(spec, previousModel, switchSeq)
}
}
}
@@ -207,13 +236,15 @@ class ChatModelSwitchDelegate(
if (key == "model") {
val confirmRequired = map["confirm_required"] as? Boolean ?: false
if (confirmRequired) {
val confirmMessage =
(map["confirm_message"] as? String)
?: (map["warning"] as? String)
?: "This model requires confirmation to switch. Continue?"
activeModelSwitchConfirmation = pending
uiState.update {
it.copy(modelSwitchConfirmMessage = confirmMessage)
if (pending != null && pending.sequence == modelSwitchSequence) {
val confirmMessage =
(map["confirm_message"] as? String)
?: (map["warning"] as? String)
?: "This model requires confirmation to switch. Continue?"
activeModelSwitchConfirmation = pending
uiState.update {
it.copy(modelSwitchConfirmMessage = confirmMessage)
}
}
}
} else if (key == "fast") {
@@ -236,13 +267,18 @@ class ChatModelSwitchDelegate(
fun dismissModelSwitchConfirm() {
val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null
optimisticPreviousModel = null
unconfirmedTargetModel = null
val revertModel = lastConfirmedModel ?: pending?.previousModel
uiState.update {
it.copy(
modelSwitchConfirmMessage = null,
currentSessionModel = pending?.previousModel ?: it.currentSessionModel,
currentSessionModel = revertModel ?: it.currentSessionModel,
fullContextTokens = null,
)
}
syncCurrentModelCapabilities()
onModelSwitchInitiated()
fetchContextUsage()
}
@@ -282,6 +318,33 @@ class ChatModelSwitchDelegate(
id: String,
error: Any?,
) {
val pendingModel = pendingModelSwitchRequests.remove(id)
if (pendingModel != null && pendingModel.sequence == modelSwitchSequence) {
val revertModel = lastConfirmedModel ?: pendingModel.previousModel
optimisticPreviousModel = null
unconfirmedTargetModel = null
activeModelSwitchConfirmation = null
if (revertModel != null) {
uiState.update {
it.copy(
currentSessionModel = revertModel,
fullContextTokens = null,
modelSwitchConfirmMessage = null,
)
}
syncCurrentModelCapabilities()
onModelSwitchInitiated()
fetchContextUsage()
} else {
uiState.update {
it.copy(
fullContextTokens = null,
modelSwitchConfirmMessage = null,
)
}
onModelSwitchInitiated()
}
}
val pendingFast = pendingFastSwitchRequests.remove(id)
if (pendingFast != null) {
val errorMsg =
@@ -300,6 +363,26 @@ class ChatModelSwitchDelegate(
}
}
fun isSwitchPending(): Boolean =
optimisticPreviousModel != null ||
unconfirmedTargetModel != null ||
activeModelSwitchConfirmation != null
fun onModelConfirmed(modelLabel: String) {
lastConfirmedModel = modelLabel
optimisticPreviousModel = null
unconfirmedTargetModel = null
}
fun reset() {
pendingModelSwitchRequests.clear()
pendingFastSwitchRequests.clear()
activeModelSwitchConfirmation = null
optimisticPreviousModel = null
lastConfirmedModel = null
unconfirmedTargetModel = null
}
fun setReasoningLevel(level: String?) {
uiState.update { it.copy(reasoningLevel = level) }
val sessionId = runtimeSessionId() ?: return
@@ -28,9 +28,11 @@ import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ActionProgressController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -394,6 +396,18 @@ class ChatViewModel(
/** Tracks the auto-clear coroutine for reaction animations. */
private var reactionClearJob: Job? = null
/** Last confirmed model label pushed by SessionInfo or SessionResume from backend (issue #1103). */
private var lastConfirmedSessionModel: String? = null
/** Model generation counter to prevent stale context writes across switches (issue #1103). */
private var modelGeneration: Long = 0L
/** Request sequence counter for fetchContextUsage calls. */
private var contextFetchSequence: Long = 0L
/** Tracks the latest context-usage poll or refetch job. */
private var contextUsageJob: Job? = null
private val wsClient = HermesWsClient
// ── Session persistence ──────────────────────────────────────────────
@@ -445,6 +459,7 @@ class ChatViewModel(
addAssistantMessage = { text -> addAssistantMessage(text) },
handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
fetchContextUsage = { fetchContextUsage() },
onModelSwitchInitiated = { onModelSwitchInitiated() },
)
private val credentialPromptsDelegate =
@@ -796,16 +811,25 @@ class ChatViewModel(
} else {
model
}
// Issue #817: on a REAL model swap the meter's denominator
// Issue #817 & #1103: on a REAL model swap the meter's denominator
// still belongs to the old model until the next fetch.
// Blank it so the chip hides instead of flashing a stale
// window under the new label, and re-fire the fetch so the
// new window lands immediately (no 30s poll wait). Only a
// change from a known label counts — the first SessionInfo
// of a session just sets the label.
val previousLabel = _uiState.value.currentSessionModel
// Check against both lastConfirmedSessionModel and optimisticPreviousModel
// so optimistic model updates in sendSlashModel don't defeat swap detection.
val optimisticPrevious = modelSwitchDelegate.consumeOptimisticPreviousModel()
val previousModel =
lastConfirmedSessionModel
?: optimisticPrevious
?: _uiState.value.currentSessionModel
val modelSwapped =
previousLabel != null && newModelLabel != null && newModelLabel != previousLabel
previousModel != null &&
newModelLabel != null &&
!newModelLabel.equals(previousModel, ignoreCase = true)
val initialHydration = previousModel == null && newModelLabel != null
val meterEmpty = _uiState.value.fullContextTokens == null
lastConfirmedSessionModel = newModelLabel ?: lastConfirmedSessionModel
if (newModelLabel != null) {
modelSwitchDelegate.onModelConfirmed(newModelLabel)
}
_uiState.update { state ->
state.copy(
currentSessionModel = newModelLabel ?: state.currentSessionModel,
@@ -823,12 +847,19 @@ class ChatViewModel(
}
modelSwitchDelegate.syncCurrentModelCapabilities()
if (modelSwapped) {
// Issue #817: after a swap the REST model/info window is
modelGeneration++
contextUsageJob?.cancel()
contextUsageJob = null
// Issue #817 & #1103: after a swap the REST model/info window is
// PROFILE-scoped and may describe the old model (e.g. a
// session-scoped swap) — the meter must not fall back to
// it. Wait for the RPC's live context_max instead; the
// chip stays hidden until the real window lands.
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
} else if ((initialHydration || meterEmpty) && newModelLabel != null &&
contextUsageJob?.isActive != true
) {
viewModelScope.launch { fetchContextUsage() }
}
// Session.info can carry `pending_approval` (reconnect
// reconciliation) — surface it unless already on screen.
@@ -1151,6 +1182,23 @@ class ChatViewModel(
terminalBackend = terminalBackend ?: it.terminalBackend,
)
}
val resumedModelLabel =
if (model != null && provider != null) {
"$provider/$model"
} else {
model
}
if (resumedModelLabel != null) {
if (lastConfirmedSessionModel != null &&
!resumedModelLabel.equals(lastConfirmedSessionModel, ignoreCase = true)
) {
modelGeneration++
contextUsageJob?.cancel()
contextUsageJob = null
_uiState.update { it.copy(fullContextTokens = null) }
}
lastConfirmedSessionModel = resumedModelLabel
}
modelSwitchDelegate.syncCurrentModelCapabilities()
// Mirror the active runtime session id app-wide (issue #532).
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
@@ -2558,6 +2606,11 @@ class ChatViewModel(
): Long {
val generation = ++sessionGeneration
cancelResumeRetry()
contextUsageJob?.cancel()
contextUsageJob = null
modelGeneration++
lastConfirmedSessionModel = null
modelSwitchDelegate.reset()
resumedGeneration = -1L
hydratedGeneration = -1L
runtimeSessionId = null
@@ -3043,6 +3096,57 @@ class ChatViewModel(
}
}
internal fun onModelSwitchInitiated() {
modelGeneration++
contextUsageJob?.cancel()
contextUsageJob = null
_uiState.update { it.copy(fullContextTokens = null) }
}
private fun isCurrentContextFetch(
sessionId: String,
targetSessionGeneration: Long,
targetModelGeneration: Long,
targetRequestSequence: Long,
): Boolean =
targetSessionGeneration == sessionGeneration &&
targetModelGeneration == modelGeneration &&
targetRequestSequence == contextFetchSequence &&
_uiState.value.currentSessionId == sessionId
internal fun isMatchingModel(
currentModel: String?,
info: com.m57.hermescontrol.data.model.ModelInfoResponse?,
): Boolean {
if (currentModel.isNullOrBlank() || info == null || info.model.isNullOrBlank()) {
return false
}
val restModel = info.model
val restProvider = info.provider
return if (currentModel.contains('/')) {
val curProvider = currentModel.substringBefore('/')
val curModel = currentModel.substringAfter('/')
if (!restProvider.isNullOrBlank()) {
curProvider.equals(restProvider, ignoreCase = true) &&
curModel.equals(restModel, ignoreCase = true)
} else {
curModel.equals(restModel, ignoreCase = true)
}
} else {
currentModel.equals(restModel, ignoreCase = true)
}
}
internal fun isMatchingRpcModel(
currentModel: String?,
rpcModel: String?,
): Boolean {
if (currentModel.isNullOrBlank() || rpcModel.isNullOrBlank()) return false
if (currentModel.equals(rpcModel, ignoreCase = true)) return true
return currentModel.contains('/') &&
currentModel.substringAfter('/').equals(rpcModel, ignoreCase = true)
}
/**
* Refresh the context meter: used / full tokens for the current session.
*
@@ -3068,105 +3172,211 @@ class ChatViewModel(
fun fetchContextUsage(skipRestFallback: Boolean = false) {
val sessionId = _uiState.value.currentSessionId ?: return
val profile = AuthManager.activeProfileId.value
viewModelScope.launch(ioDispatcher) {
// Denominator fallback: full context window (cheap, public, rarely
// changes). The RPC's context_max below overrides it when present.
// Kept as a local (not a state write) so both sources resolve
// before ONE atomic update below (issue #817 — two independent
// writes let a stale pre-swap value override a fresh one mid-swap).
val fullResult =
safeApiCall { ApiClient.hermesApi.getModelInfo() }
val restFull =
if (fullResult is NetworkResult.Success) {
fullResult.data.effective_context_length
?: fullResult.data.auto_context_length
?: fullResult.data.config_context_length
} else {
null
val targetSessionGeneration = sessionGeneration
val targetModelGeneration = modelGeneration
val targetRequestSequence = ++contextFetchSequence
val isSwitchPending = modelSwitchDelegate.isSwitchPending()
contextUsageJob?.cancel()
contextUsageJob =
viewModelScope.launch(ioDispatcher) {
// Denominator fallback: full context window (cheap, public, rarely
// changes). The RPC's context_max below overrides it when present.
// Kept as a local (not a state write) so both sources resolve
// before ONE atomic update below (issue #817 — two independent
// writes let a stale pre-swap value override a fresh one mid-swap).
val fullResult =
safeApiCall { ApiClient.hermesApi.getModelInfo() }
coroutineContext.ensureActive()
if (!isCurrentContextFetch(
sessionId,
targetSessionGeneration,
targetModelGeneration,
targetRequestSequence,
)
) {
return@launch
}
// 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.
//
// These RPCs resolve the session against the gateway's LIVE runtime
// registry (_sess_nowait) — the storage session id 4001s "session
// not found" until session.resume has registered it. ChatScreen's
// sync effect fires this immediately on session switch, before the
// resume result lands, so skip the RPCs until resume confirms the
// runtime id. The REST parts below stay live (they key on the
// storage id).
var rpcUsed: Long? = null
var rpcMax: Long? = null
val rpcSessionId = runtimeSessionId
if (rpcSessionId != null) {
try {
val result =
sendRpcAndAwait(
WsMethods.SESSION_CONTEXT_BREAKDOWN,
mapOf("session_id" to rpcSessionId),
)
val ctx = parseContextBreakdown(result)
if (ctx != null) {
rpcUsed = ctx.contextUsed?.takeIf { it > 0L }
rpcMax = ctx.contextMax?.takeIf { it > 0L }
val restFull =
if (fullResult is NetworkResult.Success && !isSwitchPending) {
val info = fullResult.data
val currentModel = _uiState.value.currentSessionModel
// Issue #1103: only use profile REST model/info as fallback if it
// matches the current session model identity. If the session was switched to
// another model (e.g. Solar, Gemini), the profile-level model/info describes
// a different model and must never poison the session's context window.
if (isMatchingModel(currentModel, info)) {
info.effective_context_length
?: info.auto_context_length
?: info.config_context_length
} else {
null
}
} else {
null
}
} 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),
// 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.
//
// These RPCs resolve the session against the gateway's LIVE runtime
// registry (_sess_nowait) — the storage session id 4001s "session
// not found" until session.resume has registered it. ChatScreen's
// sync effect fires this immediately on session switch, before the
// resume result lands, so skip the RPCs until resume confirms the
// runtime id. The REST parts below stay live (they key on the
// storage id).
var rpcUsed: Long? = null
var rpcMax: Long? = null
val rpcSessionId = runtimeSessionId
if (rpcSessionId != null) {
try {
val result =
sendRpcAndAwait(
WsMethods.SESSION_CONTEXT_BREAKDOWN,
mapOf("session_id" to rpcSessionId),
)
coroutineContext.ensureActive()
val ctx = parseContextBreakdown(result)
if (ctx != null) {
val currentModel = _uiState.value.currentSessionModel
val modelMatches =
if (ctx.model != null) {
isMatchingRpcModel(currentModel, ctx.model)
} else {
!isSwitchPending
}
if (modelMatches) {
rpcUsed = ctx.contextUsed?.takeIf { it > 0L }
rpcMax = ctx.contextMax?.takeIf { it > 0L }
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Best-effort: RPC error/timeout/disconnect — keep last values.
}
if (!isCurrentContextFetch(
sessionId,
targetSessionGeneration,
targetModelGeneration,
targetRequestSequence,
)
val snapshot = parseUsageSnapshot(usage)
if (snapshot != null && snapshot.compressions != null) {
_uiState.update { it.copy(compressionCount = snapshot.compressions) }
) {
return@launch
}
// 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.
if (!isSwitchPending) {
try {
val usage =
sendRpcAndAwait(
WsMethods.SESSION_USAGE,
mapOf("session_id" to rpcSessionId),
)
coroutineContext.ensureActive()
val snapshot = parseUsageSnapshot(usage)
if (snapshot != null && snapshot.compressions != null) {
_uiState.update { current ->
if (!isCurrentContextFetch(
sessionId,
targetSessionGeneration,
targetModelGeneration,
targetRequestSequence,
)
) {
current
} else {
current.copy(compressionCount = snapshot.compressions)
}
}
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Best-effort: keep the last known badge value.
}
}
} catch (_: Exception) {
// Best-effort: keep the last known badge value.
}
}
// Issue #817: single atomic denominator write. The RPC's live
// context_max wins, REST model/info is the fallback, and a stale
// value from a pre-swap fetch can never overwrite a fresh one —
// the meter always shows ONE coherent window.
_uiState.update { current ->
val fallbackFull =
if (skipRestFallback) current.fullContextTokens else restFull ?: current.fullContextTokens
current.copy(
usedContextTokens = rpcUsed ?: current.usedContextTokens,
fullContextTokens = rpcMax ?: fallbackFull,
)
}
// Detail-sheet accounting (cumulative REST counters, informational).
val usedResult =
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
if (usedResult is NetworkResult.Success) {
val d = usedResult.data
val used = d.input_tokens
if (used != null) {
_uiState.update {
it.copy(
contextBreakdown =
ContextBreakdown(
inputTokens = used,
outputTokens = d.output_tokens ?: 0L,
cacheReadTokens = d.cache_read_tokens ?: 0L,
cacheWriteTokens = d.cache_write_tokens ?: 0L,
reasoningTokens = d.reasoning_tokens ?: 0L,
messageCount = d.message_count ?: 0,
),
coroutineContext.ensureActive()
if (!isCurrentContextFetch(
sessionId,
targetSessionGeneration,
targetModelGeneration,
targetRequestSequence,
)
) {
return@launch
}
// Issue #817 & #1103: single atomic denominator write. The RPC's live
// context_max wins, REST model/info is the fallback, and a stale
// value from a pre-swap fetch can never overwrite a fresh one —
// the meter always shows ONE coherent window.
_uiState.update { current ->
if (!isCurrentContextFetch(
sessionId,
targetSessionGeneration,
targetModelGeneration,
targetRequestSequence,
)
) {
current
} else {
val fallbackFull =
if (skipRestFallback) current.fullContextTokens else restFull ?: current.fullContextTokens
current.copy(
usedContextTokens = rpcUsed ?: current.usedContextTokens,
fullContextTokens = rpcMax ?: fallbackFull,
)
}
}
// Detail-sheet accounting (cumulative REST counters, informational).
coroutineContext.ensureActive()
if (!isCurrentContextFetch(
sessionId,
targetSessionGeneration,
targetModelGeneration,
targetRequestSequence,
)
) {
return@launch
}
val usedResult =
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
coroutineContext.ensureActive()
if (usedResult is NetworkResult.Success) {
val d = usedResult.data
val used = d.input_tokens
if (used != null) {
_uiState.update { current ->
if (!isCurrentContextFetch(
sessionId,
targetSessionGeneration,
targetModelGeneration,
targetRequestSequence,
)
) {
current
} else {
current.copy(
contextBreakdown =
ContextBreakdown(
inputTokens = used,
outputTokens = d.output_tokens ?: 0L,
cacheReadTokens = d.cache_read_tokens ?: 0L,
cacheWriteTokens = d.cache_write_tokens ?: 0L,
reasoningTokens = d.reasoning_tokens ?: 0L,
messageCount = d.message_count ?: 0,
),
)
}
}
}
}
}
}
}
private suspend fun fetchServerMessageCount(