fix(chat): context meter flashes stale window on mid-chat model swap (#817) (#846)

* fix(chat): meter flashes stale window on mid-chat model swap (issue #817)

session.info updates the model label as soon as the backend applies a
swap, but the context meter's denominator kept the old model's window
until the next fetch — the chip briefly rendered the new label over the
old window (e.g. 1M flashing before settling on 262k).

- session.info with a DIFFERENT model now blanks the denominator (chip
  hides while resolving) and immediately re-fires fetchContextUsage()
- fetchContextUsage writes the denominator ONCE, atomically: RPC
  context_max wins, REST model/info is the fallback — a stale pre-swap
  response can never override a fresh one
- regression tests: swap re-fires the fetch and lands the new window;
  an identical session.info leaves the meter untouched

* fix(chat): don't fall back to profile REST window on model swap (issue #817)

After a session-scoped swap the live agent's context_breakdown can
return empty (context_max 0) while the runtime warms up. The refetch
then fell back to GET /api/model/info — PROFILE-scoped — which reports
the profile's main-model window (e.g. 1M), resurrecting the stale
window under the new label. The swap-triggered refetch now trusts only
the RPC's live context_max; the chip stays hidden until the real
window lands.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 23:36:27 +03:00
1 parent 3e9672f0ad
commit 9e741913fc
2 files changed
+144 -20

No files matched your search

@@ -691,22 +691,42 @@ class ChatViewModel(
val model = info["model"] as? String
val provider = info["provider"] as? String
val reasoningEffort = info["reasoning_effort"] as? String
val newModelLabel =
if (model != null && provider != null) {
"$provider/$model"
} else {
model
}
// Issue #817: 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
val modelSwapped =
previousLabel != null && newModelLabel != null && newModelLabel != previousLabel
_uiState.update { state ->
state.copy(
currentSessionModel =
if (model != null && provider != null) {
"$provider/$model"
} else {
model ?: state.currentSessionModel
},
currentSessionModel = newModelLabel ?: state.currentSessionModel,
reasoningLevel =
if (reasoningEffort.isNullOrEmpty()) {
null
} else {
reasoningEffort
},
fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
)
}
if (modelSwapped) {
// Issue #817: 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) }
}
}
}
@@ -2295,23 +2315,25 @@ class ChatViewModel(
* the other. Polled from [syncCurrentSession] via the 30s loop and re-fired
* on model switch (the denominator changes).
*/
fun fetchContextUsage() {
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() }
if (fullResult is NetworkResult.Success) {
val full =
val restFull =
if (fullResult is NetworkResult.Success) {
fullResult.data.effective_context_length
?: fullResult.data.auto_context_length
?: fullResult.data.config_context_length
if (full != null && full > 0L) {
_uiState.update { it.copy(fullContextTokens = full) }
} else {
null
}
}
// 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`
@@ -2325,6 +2347,8 @@ class ChatViewModel(
// 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 {
@@ -2335,14 +2359,8 @@ class ChatViewModel(
)
val ctx = parseContextBreakdown(result)
if (ctx != null) {
_uiState.update { current ->
current.copy(
usedContextTokens =
ctx.contextUsed?.takeIf { it > 0L } ?: current.usedContextTokens,
fullContextTokens =
ctx.contextMax?.takeIf { it > 0L } ?: current.fullContextTokens,
)
}
rpcUsed = ctx.contextUsed?.takeIf { it > 0L }
rpcMax = ctx.contextMax?.takeIf { it > 0L }
}
} catch (_: Exception) {
// Best-effort: RPC error/timeout/disconnect — keep last values.
@@ -2364,6 +2382,18 @@ class ChatViewModel(
// 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) }