* 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
@@ -691,22 +691,42 @@ class ChatViewModel(
|
|||||||
val model = info["model"] as? String
|
val model = info["model"] as? String
|
||||||
val provider = info["provider"] as? String
|
val provider = info["provider"] as? String
|
||||||
val reasoningEffort = info["reasoning_effort"] 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 ->
|
_uiState.update { state ->
|
||||||
state.copy(
|
state.copy(
|
||||||
currentSessionModel =
|
currentSessionModel = newModelLabel ?: state.currentSessionModel,
|
||||||
if (model != null && provider != null) {
|
|
||||||
"$provider/$model"
|
|
||||||
} else {
|
|
||||||
model ?: state.currentSessionModel
|
|
||||||
},
|
|
||||||
reasoningLevel =
|
reasoningLevel =
|
||||||
if (reasoningEffort.isNullOrEmpty()) {
|
if (reasoningEffort.isNullOrEmpty()) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
reasoningEffort
|
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
|
* the other. Polled from [syncCurrentSession] via the 30s loop and re-fired
|
||||||
* on model switch (the denominator changes).
|
* on model switch (the denominator changes).
|
||||||
*/
|
*/
|
||||||
fun fetchContextUsage() {
|
fun fetchContextUsage(skipRestFallback: Boolean = false) {
|
||||||
val sessionId = _uiState.value.currentSessionId ?: return
|
val sessionId = _uiState.value.currentSessionId ?: return
|
||||||
val profile = AuthManager.activeProfileId.value
|
val profile = AuthManager.activeProfileId.value
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
// Denominator fallback: full context window (cheap, public, rarely
|
// Denominator fallback: full context window (cheap, public, rarely
|
||||||
// changes). The RPC's context_max below overrides it when present.
|
// 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 =
|
val fullResult =
|
||||||
safeApiCall { ApiClient.hermesApi.getModelInfo() }
|
safeApiCall { ApiClient.hermesApi.getModelInfo() }
|
||||||
if (fullResult is NetworkResult.Success) {
|
val restFull =
|
||||||
val full =
|
if (fullResult is NetworkResult.Success) {
|
||||||
fullResult.data.effective_context_length
|
fullResult.data.effective_context_length
|
||||||
?: fullResult.data.auto_context_length
|
?: fullResult.data.auto_context_length
|
||||||
?: fullResult.data.config_context_length
|
?: fullResult.data.config_context_length
|
||||||
if (full != null && full > 0L) {
|
} else {
|
||||||
_uiState.update { it.copy(fullContextTokens = full) }
|
null
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Numerator: live context occupancy from the gateway's live agent,
|
// Numerator: live context occupancy from the gateway's live agent,
|
||||||
// via the same RPC the desktop meter uses. `context_used` is the
|
// via the same RPC the desktop meter uses. `context_used` is the
|
||||||
// real current prompt size (drops after compression); `context_max`
|
// 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
|
// resume result lands, so skip the RPCs until resume confirms the
|
||||||
// runtime id. The REST parts below stay live (they key on the
|
// runtime id. The REST parts below stay live (they key on the
|
||||||
// storage id).
|
// storage id).
|
||||||
|
var rpcUsed: Long? = null
|
||||||
|
var rpcMax: Long? = null
|
||||||
val rpcSessionId = runtimeSessionId
|
val rpcSessionId = runtimeSessionId
|
||||||
if (rpcSessionId != null) {
|
if (rpcSessionId != null) {
|
||||||
try {
|
try {
|
||||||
@@ -2335,14 +2359,8 @@ class ChatViewModel(
|
|||||||
)
|
)
|
||||||
val ctx = parseContextBreakdown(result)
|
val ctx = parseContextBreakdown(result)
|
||||||
if (ctx != null) {
|
if (ctx != null) {
|
||||||
_uiState.update { current ->
|
rpcUsed = ctx.contextUsed?.takeIf { it > 0L }
|
||||||
current.copy(
|
rpcMax = ctx.contextMax?.takeIf { it > 0L }
|
||||||
usedContextTokens =
|
|
||||||
ctx.contextUsed?.takeIf { it > 0L } ?: current.usedContextTokens,
|
|
||||||
fullContextTokens =
|
|
||||||
ctx.contextMax?.takeIf { it > 0L } ?: current.fullContextTokens,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Best-effort: RPC error/timeout/disconnect — keep last values.
|
// Best-effort: RPC error/timeout/disconnect — keep last values.
|
||||||
@@ -2364,6 +2382,18 @@ class ChatViewModel(
|
|||||||
// Best-effort: keep the last known badge value.
|
// 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).
|
// Detail-sheet accounting (cumulative REST counters, informational).
|
||||||
val usedResult =
|
val usedResult =
|
||||||
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
||||||
|
|||||||
@@ -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
|
@Test
|
||||||
fun testResumeNotFound_recoversWithNewSession() =
|
fun testResumeNotFound_recoversWithNewSession() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
Reference in new issue
Block a user