diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt index 1dba8c43..1b29cc39 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ContextBreakdownResponse.kt @@ -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"), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt index 9897320f..c20e1058 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/UsageSnapshotResponse.kt @@ -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"), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index 6a1f687e..3d440021 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -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 = @@ -56,6 +58,9 @@ class ChatModelSwitchDelegate( private val fastRejectedModels = ConcurrentHashMap.newKeySet() 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 = 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( "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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 1c23f3b8..3cf0199e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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( diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt index bbd638be..9cb07ae3 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ContextBreakdownResponseTest.kt @@ -84,4 +84,24 @@ class ContextBreakdownResponseTest { assertEquals(262144L, parsed?.contextMax) 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) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt index 341e2bb9..ac27f750 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/UsageSnapshotResponseTest.kt @@ -30,4 +30,24 @@ class UsageSnapshotResponseTest { assertNull(parseUsageSnapshot("oops")) 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) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index 478fe04a..ec66a567 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -30,6 +30,7 @@ class ChatModelSwitchDelegateTest { private val slashCommands = mutableListOf() private val assistantMessages = mutableListOf() private var contextRefetched = 0 + private var modelSwitchInitiated = 0 private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3")) private val fakeResponse = @@ -60,6 +61,7 @@ class ChatModelSwitchDelegateTest { addAssistantMessage = { assistantMessages.add(it) }, handleSlashCommand = { slashCommands.add(it) }, fetchContextUsage = { contextRefetched++ }, + onModelSwitchInitiated = { modelSwitchInitiated++ }, getModelOptionsCall = { NetworkResult.Success(fakeResponse) }, getPinnedModels = { pinnedList }, savePinnedModels = { pinnedList = it.toMutableList() }, @@ -92,7 +94,24 @@ class ChatModelSwitchDelegateTest { assertEquals("openai/gpt-4o", uiState.value.currentSessionModel) assertFalse(uiState.value.showModelPicker) 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(), slashCommands) + assertFalse(uiState.value.showModelPicker) } @Test @@ -139,6 +158,21 @@ class ChatModelSwitchDelegateTest { 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 fun toggleFastMode_sendsConfigSetFast_andUpdatesOnAck() = testScope.runTest { @@ -207,4 +241,53 @@ class ChatModelSwitchDelegateTest { advanceUntilIdle() 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) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 1803c960..14c82d19 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -2916,6 +2916,113 @@ class ChatViewModelTest { 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(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 fun testSessionInfoSameModel_keepsMeterUntouched() = runTest {