Merge pull request #1106 from Hy4ri/fix/issue-1103-context-bar-model-switch

fix(chat): keep context meter aligned after model swaps (#1103)
This commit is contained in:
M57 authored and GitHub committed 2026-09-13 19:51:06 +03:00
commit 7d5deced7e
8 files changed
+720 -127

No files matched your search

@@ -1,5 +1,11 @@
package com.m57.hermescontrol.data.model 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 — * Live context-window occupancy from the `session.context_breakdown` WS RPC —
* the same RPC the Hermes desktop app's status-bar meter uses. * 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`. * Parse the decoded JSON-RPC result of `session.context_breakdown`.
* *
* The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`), * 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 * not a map (error/malformed response) — callers treat that as unknown and
* keep the last known meter values. * keep the last known meter values.
*/ */
fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? { 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( return ContextBreakdownResponse(
contextUsed = num("context_used"), contextUsed = num("context_used"),
contextMax = num("context_max"), contextMax = num("context_max"),
contextPercent = (map["context_percent"] as? Number)?.toInt(), contextPercent = intNum("context_percent"),
estimatedTotal = num("estimated_total"), estimatedTotal = num("estimated_total"),
model = map["model"] as? String, model = str("model"),
) )
} }
@@ -1,5 +1,12 @@
package com.m57.hermescontrol.data.model 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 — * 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 * 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": { ... } }` * Handles both direct usage dict (from RPC response) and nested `{ "usage": { ... } }`
* (from push event payload). The WS event parser decodes JSON numbers as [Double] * (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. * Returns null when the payload is not a map (error/malformed response) — callers keep the last known value.
*/ */
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? { fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
val map = result as? Map<*, *> ?: return null val rawMap =
val usageMap = (map["usage"] as? Map<*, *>) ?: map 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( return UsageSnapshotResponse(
compressions = (usageMap["compressions"] as? Number)?.toInt(), compressions = intNum("compressions"),
contextUsed = (usageMap["context_used"] as? Number)?.toLong(), contextUsed = num("context_used"),
contextMax = (usageMap["context_max"] as? Number)?.toLong(), contextMax = num("context_max"),
totalTokens = (usageMap["total"] as? Number)?.toLong(), totalTokens = num("total"),
avgTps = (usageMap["avg_tps"] as? Number)?.toDouble(), avgTps = doubleNum("avg_tps"),
) )
} }
@@ -22,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap
private data class ActiveModelSwitch( private data class ActiveModelSwitch(
val spec: String, val spec: String,
val previousModel: String?, val previousModel: String?,
val sequence: Long,
) )
private data class ActiveFastSwitch( private data class ActiveFastSwitch(
@@ -44,6 +45,7 @@ class ChatModelSwitchDelegate(
private val addAssistantMessage: (text: String) -> Unit, private val addAssistantMessage: (text: String) -> Unit,
private val handleSlashCommand: (command: String) -> Unit, private val handleSlashCommand: (command: String) -> Unit,
private val fetchContextUsage: () -> Unit, private val fetchContextUsage: () -> Unit,
private val onModelSwitchInitiated: () -> Unit = {},
private val getModelOptionsCall: suspend ( private val getModelOptionsCall: suspend (
refresh: Boolean, refresh: Boolean,
) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> = ) -> NetworkResult<com.m57.hermescontrol.data.model.ModelOptionsResponse> =
@@ -56,6 +58,9 @@ class ChatModelSwitchDelegate(
private val fastRejectedModels = ConcurrentHashMap.newKeySet<String>() private val fastRejectedModels = ConcurrentHashMap.newKeySet<String>()
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
private var optimisticPreviousModel: String? = 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() private var cachedModelOptions: List<ModelProvider> = emptyList()
fun isModelPickerCommand(command: String): Boolean { fun isModelPickerCommand(command: String): Boolean {
@@ -142,20 +147,37 @@ class ChatModelSwitchDelegate(
uiState.update { it.copy(modelPickerPinned = currentPinned) } uiState.update { it.copy(modelPickerPinned = currentPinned) }
} }
fun consumeOptimisticPreviousModel(): String? = optimisticPreviousModel.also { optimisticPreviousModel = null }
fun sendSlashModel( fun sendSlashModel(
provider: String, provider: String,
model: 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 { uiState.update {
it.copy( it.copy(
showModelPicker = false, showModelPicker = false,
modelPickerLoading = false, modelPickerLoading = false,
currentSessionModel = "$provider/$model", currentSessionModel = targetModelLabel,
fullContextTokens = null,
) )
} }
onModelSwitchInitiated()
syncCurrentModelCapabilities() syncCurrentModelCapabilities()
fetchContextUsage()
handleSlashCommand("/model $model --provider $provider --session") handleSlashCommand("/model $model --provider $provider --session")
} }
@@ -174,8 +196,15 @@ class ChatModelSwitchDelegate(
} else { } else {
command.trim() command.trim()
} }
val previousModel = optimisticPreviousModel ?: uiState.value.currentSessionModel val previousModel = lastConfirmedModel ?: optimisticPreviousModel ?: uiState.value.currentSessionModel
optimisticPreviousModel = null // 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 = val params =
mutableMapOf<String, Any>( mutableMapOf<String, Any>(
"key" to "model", "key" to "model",
@@ -191,7 +220,7 @@ class ChatModelSwitchDelegate(
params, params,
) { id -> ) { id ->
trackRequest(id, WsMethods.CONFIG_SET) 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") { if (key == "model") {
val confirmRequired = map["confirm_required"] as? Boolean ?: false val confirmRequired = map["confirm_required"] as? Boolean ?: false
if (confirmRequired) { if (confirmRequired) {
val confirmMessage = if (pending != null && pending.sequence == modelSwitchSequence) {
(map["confirm_message"] as? String) val confirmMessage =
?: (map["warning"] as? String) (map["confirm_message"] as? String)
?: "This model requires confirmation to switch. Continue?" ?: (map["warning"] as? String)
activeModelSwitchConfirmation = pending ?: "This model requires confirmation to switch. Continue?"
uiState.update { activeModelSwitchConfirmation = pending
it.copy(modelSwitchConfirmMessage = confirmMessage) uiState.update {
it.copy(modelSwitchConfirmMessage = confirmMessage)
}
} }
} }
} else if (key == "fast") { } else if (key == "fast") {
@@ -236,13 +267,18 @@ class ChatModelSwitchDelegate(
fun dismissModelSwitchConfirm() { fun dismissModelSwitchConfirm() {
val pending = activeModelSwitchConfirmation val pending = activeModelSwitchConfirmation
activeModelSwitchConfirmation = null activeModelSwitchConfirmation = null
optimisticPreviousModel = null
unconfirmedTargetModel = null
val revertModel = lastConfirmedModel ?: pending?.previousModel
uiState.update { uiState.update {
it.copy( it.copy(
modelSwitchConfirmMessage = null, modelSwitchConfirmMessage = null,
currentSessionModel = pending?.previousModel ?: it.currentSessionModel, currentSessionModel = revertModel ?: it.currentSessionModel,
fullContextTokens = null,
) )
} }
syncCurrentModelCapabilities() syncCurrentModelCapabilities()
onModelSwitchInitiated()
fetchContextUsage() fetchContextUsage()
} }
@@ -282,6 +318,33 @@ class ChatModelSwitchDelegate(
id: String, id: String,
error: Any?, 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) val pendingFast = pendingFastSwitchRequests.remove(id)
if (pendingFast != null) { if (pendingFast != null) {
val errorMsg = 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?) { fun setReasoningLevel(level: String?) {
uiState.update { it.copy(reasoningLevel = level) } uiState.update { it.copy(reasoningLevel = level) }
val sessionId = runtimeSessionId() ?: return 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.WsMethods
import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.common.ActionProgressController import com.m57.hermescontrol.ui.common.ActionProgressController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -394,6 +396,18 @@ class ChatViewModel(
/** Tracks the auto-clear coroutine for reaction animations. */ /** Tracks the auto-clear coroutine for reaction animations. */
private var reactionClearJob: Job? = null 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 private val wsClient = HermesWsClient
// ── Session persistence ────────────────────────────────────────────── // ── Session persistence ──────────────────────────────────────────────
@@ -445,6 +459,7 @@ class ChatViewModel(
addAssistantMessage = { text -> addAssistantMessage(text) }, addAssistantMessage = { text -> addAssistantMessage(text) },
handleSlashCommand = { cmd -> handleSlashCommand(cmd) }, handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
fetchContextUsage = { fetchContextUsage() }, fetchContextUsage = { fetchContextUsage() },
onModelSwitchInitiated = { onModelSwitchInitiated() },
) )
private val credentialPromptsDelegate = private val credentialPromptsDelegate =
@@ -796,16 +811,25 @@ class ChatViewModel(
} else { } else {
model 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. // still belongs to the old model until the next fetch.
// Blank it so the chip hides instead of flashing a stale // Check against both lastConfirmedSessionModel and optimisticPreviousModel
// window under the new label, and re-fire the fetch so the // so optimistic model updates in sendSlashModel don't defeat swap detection.
// new window lands immediately (no 30s poll wait). Only a val optimisticPrevious = modelSwitchDelegate.consumeOptimisticPreviousModel()
// change from a known label counts — the first SessionInfo val previousModel =
// of a session just sets the label. lastConfirmedSessionModel
val previousLabel = _uiState.value.currentSessionModel ?: optimisticPrevious
?: _uiState.value.currentSessionModel
val modelSwapped = 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 -> _uiState.update { state ->
state.copy( state.copy(
currentSessionModel = newModelLabel ?: state.currentSessionModel, currentSessionModel = newModelLabel ?: state.currentSessionModel,
@@ -823,12 +847,19 @@ class ChatViewModel(
} }
modelSwitchDelegate.syncCurrentModelCapabilities() modelSwitchDelegate.syncCurrentModelCapabilities()
if (modelSwapped) { 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 // PROFILE-scoped and may describe the old model (e.g. a
// session-scoped swap) — the meter must not fall back to // session-scoped swap) — the meter must not fall back to
// it. Wait for the RPC's live context_max instead; the // it. Wait for the RPC's live context_max instead; the
// chip stays hidden until the real window lands. // chip stays hidden until the real window lands.
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) } viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
} else if ((initialHydration || meterEmpty) && newModelLabel != null &&
contextUsageJob?.isActive != true
) {
viewModelScope.launch { fetchContextUsage() }
} }
// Session.info can carry `pending_approval` (reconnect // Session.info can carry `pending_approval` (reconnect
// reconciliation) — surface it unless already on screen. // reconciliation) — surface it unless already on screen.
@@ -1151,6 +1182,23 @@ class ChatViewModel(
terminalBackend = terminalBackend ?: it.terminalBackend, 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() modelSwitchDelegate.syncCurrentModelCapabilities()
// Mirror the active runtime session id app-wide (issue #532). // Mirror the active runtime session id app-wide (issue #532).
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId) ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
@@ -2558,6 +2606,11 @@ class ChatViewModel(
): Long { ): Long {
val generation = ++sessionGeneration val generation = ++sessionGeneration
cancelResumeRetry() cancelResumeRetry()
contextUsageJob?.cancel()
contextUsageJob = null
modelGeneration++
lastConfirmedSessionModel = null
modelSwitchDelegate.reset()
resumedGeneration = -1L resumedGeneration = -1L
hydratedGeneration = -1L hydratedGeneration = -1L
runtimeSessionId = null 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. * Refresh the context meter: used / full tokens for the current session.
* *
@@ -3068,105 +3172,211 @@ class ChatViewModel(
fun fetchContextUsage(skipRestFallback: Boolean = false) { fun fetchContextUsage(skipRestFallback: Boolean = false) {
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = _uiState.value.currentSessionId ?: return
val profile = AuthManager.activeProfileId.value val profile = AuthManager.activeProfileId.value
viewModelScope.launch(ioDispatcher) { val targetSessionGeneration = sessionGeneration
// Denominator fallback: full context window (cheap, public, rarely val targetModelGeneration = modelGeneration
// changes). The RPC's context_max below overrides it when present. val targetRequestSequence = ++contextFetchSequence
// Kept as a local (not a state write) so both sources resolve val isSwitchPending = modelSwitchDelegate.isSwitchPending()
// before ONE atomic update below (issue #817 — two independent
// writes let a stale pre-swap value override a fresh one mid-swap). contextUsageJob?.cancel()
val fullResult = contextUsageJob =
safeApiCall { ApiClient.hermesApi.getModelInfo() } viewModelScope.launch(ioDispatcher) {
val restFull = // Denominator fallback: full context window (cheap, public, rarely
if (fullResult is NetworkResult.Success) { // changes). The RPC's context_max below overrides it when present.
fullResult.data.effective_context_length // Kept as a local (not a state write) so both sources resolve
?: fullResult.data.auto_context_length // before ONE atomic update below (issue #817 — two independent
?: fullResult.data.config_context_length // writes let a stale pre-swap value override a fresh one mid-swap).
} else { val fullResult =
null safeApiCall { ApiClient.hermesApi.getModelInfo() }
coroutineContext.ensureActive()
if (!isCurrentContextFetch(
sessionId,
targetSessionGeneration,
targetModelGeneration,
targetRequestSequence,
)
) {
return@launch
} }
// Numerator: live context occupancy from the gateway's live agent, val restFull =
// via the same RPC the desktop meter uses. `context_used` is the if (fullResult is NetworkResult.Success && !isSwitchPending) {
// real current prompt size (drops after compression); `context_max` val info = fullResult.data
// is the compressor's actual window. Any failure keeps the last val currentModel = _uiState.value.currentSessionModel
// known values — never blank the meter over a transient RPC error. // Issue #1103: only use profile REST model/info as fallback if it
// // matches the current session model identity. If the session was switched to
// These RPCs resolve the session against the gateway's LIVE runtime // another model (e.g. Solar, Gemini), the profile-level model/info describes
// registry (_sess_nowait) — the storage session id 4001s "session // a different model and must never poison the session's context window.
// not found" until session.resume has registered it. ChatScreen's if (isMatchingModel(currentModel, info)) {
// sync effect fires this immediately on session switch, before the info.effective_context_length
// resume result lands, so skip the RPCs until resume confirms the ?: info.auto_context_length
// runtime id. The REST parts below stay live (they key on the ?: info.config_context_length
// storage id). } else {
var rpcUsed: Long? = null null
var rpcMax: Long? = null }
val rpcSessionId = runtimeSessionId } else {
if (rpcSessionId != null) { 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 }
} }
} catch (_: Exception) { // Numerator: live context occupancy from the gateway's live agent,
// Best-effort: RPC error/timeout/disconnect — keep last values. // via the same RPC the desktop meter uses. `context_used` is the
} // real current prompt size (drops after compression); `context_max`
// Compression count: how many times this session has been compacted // is the compressor's actual window. Any failure keeps the last
// (session.usage → compressions). Feeds the "compressed ×N" badge — // known values — never blank the meter over a transient RPC error.
// the same usage snapshot the desktop status bar reads. //
try { // These RPCs resolve the session against the gateway's LIVE runtime
val usage = // registry (_sess_nowait) — the storage session id 4001s "session
sendRpcAndAwait( // not found" until session.resume has registered it. ChatScreen's
WsMethods.SESSION_USAGE, // sync effect fires this immediately on session switch, before the
mapOf("session_id" to rpcSessionId), // 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) { return@launch
_uiState.update { it.copy(compressionCount = snapshot.compressions) } }
// 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.
} }
} coroutineContext.ensureActive()
// Issue #817: single atomic denominator write. The RPC's live if (!isCurrentContextFetch(
// context_max wins, REST model/info is the fallback, and a stale sessionId,
// value from a pre-swap fetch can never overwrite a fresh one — targetSessionGeneration,
// the meter always shows ONE coherent window. targetModelGeneration,
_uiState.update { current -> targetRequestSequence,
val fallbackFull = )
if (skipRestFallback) current.fullContextTokens else restFull ?: current.fullContextTokens ) {
current.copy( return@launch
usedContextTokens = rpcUsed ?: current.usedContextTokens, }
fullContextTokens = rpcMax ?: fallbackFull, // 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 —
// Detail-sheet accounting (cumulative REST counters, informational). // the meter always shows ONE coherent window.
val usedResult = _uiState.update { current ->
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) } if (!isCurrentContextFetch(
if (usedResult is NetworkResult.Success) { sessionId,
val d = usedResult.data targetSessionGeneration,
val used = d.input_tokens targetModelGeneration,
if (used != null) { targetRequestSequence,
_uiState.update { )
it.copy( ) {
contextBreakdown = current
ContextBreakdown( } else {
inputTokens = used, val fallbackFull =
outputTokens = d.output_tokens ?: 0L, if (skipRestFallback) current.fullContextTokens else restFull ?: current.fullContextTokens
cacheReadTokens = d.cache_read_tokens ?: 0L, current.copy(
cacheWriteTokens = d.cache_write_tokens ?: 0L, usedContextTokens = rpcUsed ?: current.usedContextTokens,
reasoningTokens = d.reasoning_tokens ?: 0L, fullContextTokens = rpcMax ?: fallbackFull,
messageCount = d.message_count ?: 0,
),
) )
} }
} }
// 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( private suspend fun fetchServerMessageCount(
@@ -84,4 +84,24 @@ class ContextBreakdownResponseTest {
assertEquals(262144L, parsed?.contextMax) assertEquals(262144L, parsed?.contextMax)
assertEquals(0, parsed?.contextPercent) assertEquals(0, parsed?.contextPercent)
} }
@Test
fun `JsonObject and JsonPrimitive payloads parse successfully`() {
val json =
kotlinx.serialization.json.buildJsonObject {
put("context_used", kotlinx.serialization.json.JsonPrimitive(52428))
put("context_max", kotlinx.serialization.json.JsonPrimitive(524288))
put("context_percent", kotlinx.serialization.json.JsonPrimitive(10))
put("estimated_total", kotlinx.serialization.json.JsonPrimitive(52428))
put("model", kotlinx.serialization.json.JsonPrimitive("upstage/solar-pro4:free"))
}
val parsed = parseContextBreakdown(json)
assertEquals(52428L, parsed?.contextUsed)
assertEquals(524288L, parsed?.contextMax)
assertEquals(10, parsed?.contextPercent)
assertEquals(52428L, parsed?.estimatedTotal)
assertEquals("upstage/solar-pro4:free", parsed?.model)
}
} }
@@ -30,4 +30,24 @@ class UsageSnapshotResponseTest {
assertNull(parseUsageSnapshot("oops")) assertNull(parseUsageSnapshot("oops"))
assertNull(parseUsageSnapshot(listOf(1.0))) assertNull(parseUsageSnapshot(listOf(1.0)))
} }
@Test
fun `JsonObject and JsonPrimitive payloads parse successfully`() {
val json =
kotlinx.serialization.json.buildJsonObject {
put("compressions", kotlinx.serialization.json.JsonPrimitive(3))
put("context_used", kotlinx.serialization.json.JsonPrimitive(12000))
put("context_max", kotlinx.serialization.json.JsonPrimitive(200000))
put("total", kotlinx.serialization.json.JsonPrimitive(50000))
put("avg_tps", kotlinx.serialization.json.JsonPrimitive(45.5))
}
val parsed = parseUsageSnapshot(json)
assertEquals(3, parsed?.compressions)
assertEquals(12000L, parsed?.contextUsed)
assertEquals(200000L, parsed?.contextMax)
assertEquals(50000L, parsed?.totalTokens)
assertEquals(45.5, parsed?.avgTps ?: 0.0, 0.001)
}
} }
@@ -30,6 +30,7 @@ class ChatModelSwitchDelegateTest {
private val slashCommands = mutableListOf<String>() private val slashCommands = mutableListOf<String>()
private val assistantMessages = mutableListOf<String>() private val assistantMessages = mutableListOf<String>()
private var contextRefetched = 0 private var contextRefetched = 0
private var modelSwitchInitiated = 0
private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3")) private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3"))
private val fakeResponse = private val fakeResponse =
@@ -60,6 +61,7 @@ class ChatModelSwitchDelegateTest {
addAssistantMessage = { assistantMessages.add(it) }, addAssistantMessage = { assistantMessages.add(it) },
handleSlashCommand = { slashCommands.add(it) }, handleSlashCommand = { slashCommands.add(it) },
fetchContextUsage = { contextRefetched++ }, fetchContextUsage = { contextRefetched++ },
onModelSwitchInitiated = { modelSwitchInitiated++ },
getModelOptionsCall = { NetworkResult.Success(fakeResponse) }, getModelOptionsCall = { NetworkResult.Success(fakeResponse) },
getPinnedModels = { pinnedList }, getPinnedModels = { pinnedList },
savePinnedModels = { pinnedList = it.toMutableList() }, savePinnedModels = { pinnedList = it.toMutableList() },
@@ -92,7 +94,24 @@ class ChatModelSwitchDelegateTest {
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel) assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
assertFalse(uiState.value.showModelPicker) assertFalse(uiState.value.showModelPicker)
assertEquals(listOf("/model gpt-4o --provider openai --session"), slashCommands) assertEquals(listOf("/model gpt-4o --provider openai --session"), slashCommands)
assertEquals(1, contextRefetched) assertEquals(1, modelSwitchInitiated)
}
@Test
fun sendSlashModel_withSameModel_doesNotInitiateSwitchOrBlankTokens() {
uiState.value =
uiState.value.copy(
currentSessionModel = "openai/gpt-4o",
fullContextTokens = 128_000L,
showModelPicker = true,
)
val initialInitiated = modelSwitchInitiated
delegate.sendSlashModel("openai", "gpt-4o")
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
assertEquals(128_000L, uiState.value.fullContextTokens)
assertEquals(initialInitiated, modelSwitchInitiated)
assertEquals(emptyList<String>(), slashCommands)
assertFalse(uiState.value.showModelPicker)
} }
@Test @Test
@@ -139,6 +158,21 @@ class ChatModelSwitchDelegateTest {
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel) assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
} }
@Test
fun handleConfigSetError_rollsBackToPreviousModel_onlyWhenMatchingCurrentSequence() =
testScope.runTest {
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
advanceUntilIdle()
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
// Current switch error rolls back
delegate.handleConfigSetError("req-1", "Model unavailable")
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
assertNull(uiState.value.fullContextTokens)
}
@Test @Test
fun toggleFastMode_sendsConfigSetFast_andUpdatesOnAck() = fun toggleFastMode_sendsConfigSetFast_andUpdatesOnAck() =
testScope.runTest { testScope.runTest {
@@ -207,4 +241,53 @@ class ChatModelSwitchDelegateTest {
advanceUntilIdle() advanceUntilIdle()
assertEquals(sentCount, sentMethods.size) assertEquals(sentCount, sentMethods.size)
} }
@Test
fun handleConfigSetResult_staleSequenceConfirmRequired_isIgnored() =
testScope.runTest {
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
advanceUntilIdle()
// A second switch arrives before first confirms
delegate.sendSlashModel("anthropic", "claude-3-5-sonnet")
advanceUntilIdle()
delegate.handleModelSwitch("/model claude-3-5-sonnet --provider anthropic --session")
advanceUntilIdle()
// First switch response arrives with confirm_required
delegate.handleConfigSetResult(
id = "req-1",
result = mapOf("key" to "model", "confirm_required" to true),
)
// Guarded: sequence 1 != current sequence 2, so confirmation is ignored
assertNull(uiState.value.modelSwitchConfirmMessage)
}
@Test
fun rapidPicks_preservesOriginalConfirmedModelForRollback() =
testScope.runTest {
// Initially confirmed model is anthropic/claude-3
delegate.onModelConfirmed("anthropic/claude-3")
uiState.value = uiState.value.copy(currentSessionModel = "anthropic/claude-3")
// Pick 1: switch to gpt-4o
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
advanceUntilIdle()
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
// Pick 2: rapidly pick solar without gpt-4o ever confirming
delegate.sendSlashModel("nous", "solar")
advanceUntilIdle()
delegate.handleModelSwitch("/model solar --provider nous --session")
advanceUntilIdle()
assertEquals("nous/solar", uiState.value.currentSessionModel)
// Error on second switch (req-2) should roll back to confirmed model, not optimistic gpt-4o
delegate.handleConfigSetError("req-2", "Solar failed")
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
}
} }
@@ -2916,6 +2916,113 @@ class ChatViewModelTest {
assertEquals(262_144L, viewModel.uiState.value.fullContextTokens) assertEquals(262_144L, viewModel.uiState.value.fullContextTokens)
} }
@Test
fun testPickerModelSwitch_blanksMeterAndResolvesNewContextWindow_issue1103() =
runTest {
stubEmptySessionRests("session-a")
val (viewModel, _) = createViewModelWithSession()
advanceUntilIdle()
// Profile default model is 900k
coEvery { ApiClient.hermesApi.getModelInfo() } returns
retrofit2.Response.success(
com.m57.hermescontrol.data.model.ModelInfoResponse(
model = "gpt-5.6-luna-900k",
provider = "openai-codex",
effective_context_length = 900_000L,
),
)
// Initial session.info for default model
mockEventsFlow.emit(
WsEvent.SessionInfo(
mapOf("model" to "gpt-5.6-luna-900k", "provider" to "openai-codex"),
),
)
advanceUntilIdle()
assertEquals("openai-codex/gpt-5.6-luna-900k", viewModel.uiState.value.currentSessionModel)
var breakdownResult: Any =
mapOf("context_max" to 900_000L, "context_used" to 10_000L)
every { HermesWsClient.request(WsMethods.SESSION_CONTEXT_BREAKDOWN, any(), any()) } answers {
CompletableDeferred<Any?>(breakdownResult)
}
viewModel.fetchContextUsage()
advanceUntilIdle()
assertEquals(900_000L, viewModel.uiState.value.fullContextTokens)
// User picks Solar 200k via model picker (sendSlashModel)
viewModel.sendSlashModel("nous", "upstage/solar-pro4:free")
advanceUntilIdle()
assertEquals("nous/upstage/solar-pro4:free", viewModel.uiState.value.currentSessionModel)
assertNull("Picker model switch must immediately blank meter", viewModel.uiState.value.fullContextTokens)
// Backend replies with session.info confirming Solar
// Breakdown RPC returns JsonObject (from wire) with 524288 context_max
breakdownResult =
kotlinx.serialization.json.buildJsonObject {
put("context_max", kotlinx.serialization.json.JsonPrimitive(524288))
put("context_used", kotlinx.serialization.json.JsonPrimitive(20000))
}
mockEventsFlow.emit(
WsEvent.SessionInfo(
mapOf("model" to "upstage/solar-pro4:free", "provider" to "nous"),
),
)
advanceUntilIdle()
// Verified: meter resolves to Solar's 524288 window, NOT profile's 900k
assertEquals(524288L, viewModel.uiState.value.fullContextTokens)
// Periodic background sync with skipRestFallback=false must NOT overwrite with profile's 900k
viewModel.fetchContextUsage(skipRestFallback = false)
advanceUntilIdle()
assertEquals(524288L, viewModel.uiState.value.fullContextTokens)
}
@Test
fun testIsMatchingRpcModel_namespacedAndProviderMatching() =
runTest {
stubEmptySessionRests("session-a")
val (viewModel, _) = createViewModelWithSession()
// Current session model has provider prefix, rpc model retains internal namespace
assertTrue(
viewModel.isMatchingRpcModel(
"nous/upstage/solar-pro4:free",
"upstage/solar-pro4:free",
),
)
// Exact match
assertTrue(
viewModel.isMatchingRpcModel(
"upstage/solar-pro4:free",
"upstage/solar-pro4:free",
),
)
// Provider prefix with non-namespaced model
assertTrue(
viewModel.isMatchingRpcModel(
"openai/gpt-4o",
"gpt-4o",
),
)
// Never blindly strip model's own namespace: rpcModel missing internal namespace must not match
assertFalse(
viewModel.isMatchingRpcModel(
"nous/upstage/solar-pro4:free",
"solar-pro4:free",
),
)
// Mismatch
assertFalse(
viewModel.isMatchingRpcModel(
"nous/upstage/solar-pro4:free",
"claude-3",
),
)
}
@Test @Test
fun testSessionInfoSameModel_keepsMeterUntouched() = fun testSessionInfoSameModel_keepsMeterUntouched() =
runTest { runTest {