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

@@ -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<Any?>(
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<Any?>(
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 {