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
|
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 {
|
||||||
|
|||||||
Reference in new issue
Block a user