From 9e741913fc7aaabed8abd8969161fac51dc8e931 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:36:27 +0300 Subject: [PATCH] fix(chat): context meter flashes stale window on mid-chat model swap (#817) (#846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../hermescontrol/ui/chat/ChatViewModel.kt | 70 ++++++++++---- .../ui/chat/ChatViewModelTest.kt | 94 +++++++++++++++++++ 2 files changed, 144 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index e4accec5..c753300c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 404d3391..721a0127 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -1906,6 +1906,100 @@ class ChatViewModelTest { } } + @Test + fun testSessionInfoModelSwap_blanksStaleMeterAndRefetches() = + runTest { + stubEmptySessionRests("session-a") + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + // Establish the OLD model's label through the real event path. + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("model" to "deepseek-v4-flash", "provider" to "opencode-go"), + ), + ) + advanceUntilIdle() + assertEquals("opencode-go/deepseek-v4-flash", viewModel.uiState.value.currentSessionModel) + + // Fetch the meter so it carries the OLD model's window (1M). + var contextMax = 1_000_000L + var breakdownCalls = 0 + every { HermesWsClient.request(WsMethods.SESSION_CONTEXT_BREAKDOWN, any(), any()) } answers { + breakdownCalls++ + CompletableDeferred( + mapOf("context_max" to contextMax, "context_used" to 42025L), + ) + } + viewModel.fetchContextUsage() + advanceUntilIdle() + assertEquals(1_000_000L, viewModel.uiState.value.fullContextTokens) + + // The swap: the live agent hasn't warmed up yet, so the RPC comes + // back empty — the meter must NOT fall back to the profile-scoped + // REST window (which describes the OLD model). It stays hidden. + contextMax = 0L + val callsBeforeSwap = breakdownCalls + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("model" to "tencent/hy3:free", "provider" to "nous"), + ), + ) + advanceUntilIdle() + + assertEquals("nous/tencent/hy3:free", viewModel.uiState.value.currentSessionModel) + assertTrue("model swap must re-fire the meter fetch", breakdownCalls > callsBeforeSwap) + assertNull( + "cold RPC after a swap must keep the meter hidden, not show the old window", + viewModel.uiState.value.fullContextTokens, + ) + + // The runtime warms up — the next fetch lands the NEW model's window. + contextMax = 262_144L + viewModel.fetchContextUsage() + advanceUntilIdle() + assertEquals(262_144L, viewModel.uiState.value.fullContextTokens) + } + + @Test + fun testSessionInfoSameModel_keepsMeterUntouched() = + runTest { + stubEmptySessionRests("session-a") + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + // Establish label + meter through the real paths. + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("model" to "tencent/hy3:free", "provider" to "nous"), + ), + ) + advanceUntilIdle() + var breakdownCalls = 0 + every { HermesWsClient.request(WsMethods.SESSION_CONTEXT_BREAKDOWN, any(), any()) } answers { + breakdownCalls++ + CompletableDeferred( + mapOf("context_max" to 262_144L), + ) + } + viewModel.fetchContextUsage() + advanceUntilIdle() + assertEquals(262_144L, viewModel.uiState.value.fullContextTokens) + val callsBefore = breakdownCalls + + // Identical model again — no swap, no blank, no refetch. + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("model" to "tencent/hy3:free", "provider" to "nous"), + ), + ) + advanceUntilIdle() + + assertEquals(callsBefore, breakdownCalls) + assertEquals(262_144L, viewModel.uiState.value.fullContextTokens) + assertEquals("nous/tencent/hy3:free", viewModel.uiState.value.currentSessionModel) + } + @Test fun testResumeNotFound_recoversWithNewSession() = runTest {