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:
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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ class ChatModelSwitchDelegateTest {
|
||||
private val slashCommands = mutableListOf<String>()
|
||||
private val assistantMessages = mutableListOf<String>()
|
||||
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<String>(), 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)
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
fun testSessionInfoSameModel_keepsMeterUntouched() =
|
||||
runTest {
|
||||
|
||||
Reference in new issue
Block a user