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 {