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