fix(chat): keep context meter aligned after model swaps (#1103)

This commit is contained in:
M57 committed 2026-09-13 19:39:33 +03:00
1 parent 4246ae196d
commit 0a2c782139
8 files changed
+720 -127

No files matched your search

@@ -30,6 +30,7 @@ class ChatModelSwitchDelegateTest {
private val slashCommands = mutableListOf<String>()
private val assistantMessages = mutableListOf<String>()
private var contextRefetched = 0
private var modelSwitchInitiated = 0
private var pinnedList = mutableListOf(PinnedModel("anthropic", "claude-3"))
private val fakeResponse =
@@ -60,6 +61,7 @@ class ChatModelSwitchDelegateTest {
addAssistantMessage = { assistantMessages.add(it) },
handleSlashCommand = { slashCommands.add(it) },
fetchContextUsage = { contextRefetched++ },
onModelSwitchInitiated = { modelSwitchInitiated++ },
getModelOptionsCall = { NetworkResult.Success(fakeResponse) },
getPinnedModels = { pinnedList },
savePinnedModels = { pinnedList = it.toMutableList() },
@@ -92,7 +94,24 @@ class ChatModelSwitchDelegateTest {
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
assertFalse(uiState.value.showModelPicker)
assertEquals(listOf("/model gpt-4o --provider openai --session"), slashCommands)
assertEquals(1, contextRefetched)
assertEquals(1, modelSwitchInitiated)
}
@Test
fun sendSlashModel_withSameModel_doesNotInitiateSwitchOrBlankTokens() {
uiState.value =
uiState.value.copy(
currentSessionModel = "openai/gpt-4o",
fullContextTokens = 128_000L,
showModelPicker = true,
)
val initialInitiated = modelSwitchInitiated
delegate.sendSlashModel("openai", "gpt-4o")
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
assertEquals(128_000L, uiState.value.fullContextTokens)
assertEquals(initialInitiated, modelSwitchInitiated)
assertEquals(emptyList<String>(), slashCommands)
assertFalse(uiState.value.showModelPicker)
}
@Test
@@ -139,6 +158,21 @@ class ChatModelSwitchDelegateTest {
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
}
@Test
fun handleConfigSetError_rollsBackToPreviousModel_onlyWhenMatchingCurrentSequence() =
testScope.runTest {
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
advanceUntilIdle()
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
// Current switch error rolls back
delegate.handleConfigSetError("req-1", "Model unavailable")
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
assertNull(uiState.value.fullContextTokens)
}
@Test
fun toggleFastMode_sendsConfigSetFast_andUpdatesOnAck() =
testScope.runTest {
@@ -207,4 +241,53 @@ class ChatModelSwitchDelegateTest {
advanceUntilIdle()
assertEquals(sentCount, sentMethods.size)
}
@Test
fun handleConfigSetResult_staleSequenceConfirmRequired_isIgnored() =
testScope.runTest {
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
advanceUntilIdle()
// A second switch arrives before first confirms
delegate.sendSlashModel("anthropic", "claude-3-5-sonnet")
advanceUntilIdle()
delegate.handleModelSwitch("/model claude-3-5-sonnet --provider anthropic --session")
advanceUntilIdle()
// First switch response arrives with confirm_required
delegate.handleConfigSetResult(
id = "req-1",
result = mapOf("key" to "model", "confirm_required" to true),
)
// Guarded: sequence 1 != current sequence 2, so confirmation is ignored
assertNull(uiState.value.modelSwitchConfirmMessage)
}
@Test
fun rapidPicks_preservesOriginalConfirmedModelForRollback() =
testScope.runTest {
// Initially confirmed model is anthropic/claude-3
delegate.onModelConfirmed("anthropic/claude-3")
uiState.value = uiState.value.copy(currentSessionModel = "anthropic/claude-3")
// Pick 1: switch to gpt-4o
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
delegate.handleModelSwitch("/model gpt-4o --provider openai --session")
advanceUntilIdle()
assertEquals("openai/gpt-4o", uiState.value.currentSessionModel)
// Pick 2: rapidly pick solar without gpt-4o ever confirming
delegate.sendSlashModel("nous", "solar")
advanceUntilIdle()
delegate.handleModelSwitch("/model solar --provider nous --session")
advanceUntilIdle()
assertEquals("nous/solar", uiState.value.currentSessionModel)
// Error on second switch (req-2) should roll back to confirmed model, not optimistic gpt-4o
delegate.handleConfigSetError("req-2", "Solar failed")
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel)
}
}
@@ -2916,6 +2916,113 @@ class ChatViewModelTest {
assertEquals(262_144L, viewModel.uiState.value.fullContextTokens)
}
@Test
fun testPickerModelSwitch_blanksMeterAndResolvesNewContextWindow_issue1103() =
runTest {
stubEmptySessionRests("session-a")
val (viewModel, _) = createViewModelWithSession()
advanceUntilIdle()
// Profile default model is 900k
coEvery { ApiClient.hermesApi.getModelInfo() } returns
retrofit2.Response.success(
com.m57.hermescontrol.data.model.ModelInfoResponse(
model = "gpt-5.6-luna-900k",
provider = "openai-codex",
effective_context_length = 900_000L,
),
)
// Initial session.info for default model
mockEventsFlow.emit(
WsEvent.SessionInfo(
mapOf("model" to "gpt-5.6-luna-900k", "provider" to "openai-codex"),
),
)
advanceUntilIdle()
assertEquals("openai-codex/gpt-5.6-luna-900k", viewModel.uiState.value.currentSessionModel)
var breakdownResult: Any =
mapOf("context_max" to 900_000L, "context_used" to 10_000L)
every { HermesWsClient.request(WsMethods.SESSION_CONTEXT_BREAKDOWN, any(), any()) } answers {
CompletableDeferred<Any?>(breakdownResult)
}
viewModel.fetchContextUsage()
advanceUntilIdle()
assertEquals(900_000L, viewModel.uiState.value.fullContextTokens)
// User picks Solar 200k via model picker (sendSlashModel)
viewModel.sendSlashModel("nous", "upstage/solar-pro4:free")
advanceUntilIdle()
assertEquals("nous/upstage/solar-pro4:free", viewModel.uiState.value.currentSessionModel)
assertNull("Picker model switch must immediately blank meter", viewModel.uiState.value.fullContextTokens)
// Backend replies with session.info confirming Solar
// Breakdown RPC returns JsonObject (from wire) with 524288 context_max
breakdownResult =
kotlinx.serialization.json.buildJsonObject {
put("context_max", kotlinx.serialization.json.JsonPrimitive(524288))
put("context_used", kotlinx.serialization.json.JsonPrimitive(20000))
}
mockEventsFlow.emit(
WsEvent.SessionInfo(
mapOf("model" to "upstage/solar-pro4:free", "provider" to "nous"),
),
)
advanceUntilIdle()
// Verified: meter resolves to Solar's 524288 window, NOT profile's 900k
assertEquals(524288L, viewModel.uiState.value.fullContextTokens)
// Periodic background sync with skipRestFallback=false must NOT overwrite with profile's 900k
viewModel.fetchContextUsage(skipRestFallback = false)
advanceUntilIdle()
assertEquals(524288L, viewModel.uiState.value.fullContextTokens)
}
@Test
fun testIsMatchingRpcModel_namespacedAndProviderMatching() =
runTest {
stubEmptySessionRests("session-a")
val (viewModel, _) = createViewModelWithSession()
// Current session model has provider prefix, rpc model retains internal namespace
assertTrue(
viewModel.isMatchingRpcModel(
"nous/upstage/solar-pro4:free",
"upstage/solar-pro4:free",
),
)
// Exact match
assertTrue(
viewModel.isMatchingRpcModel(
"upstage/solar-pro4:free",
"upstage/solar-pro4:free",
),
)
// Provider prefix with non-namespaced model
assertTrue(
viewModel.isMatchingRpcModel(
"openai/gpt-4o",
"gpt-4o",
),
)
// Never blindly strip model's own namespace: rpcModel missing internal namespace must not match
assertFalse(
viewModel.isMatchingRpcModel(
"nous/upstage/solar-pro4:free",
"solar-pro4:free",
),
)
// Mismatch
assertFalse(
viewModel.isMatchingRpcModel(
"nous/upstage/solar-pro4:free",
"claude-3",
),
)
}
@Test
fun testSessionInfoSameModel_keepsMeterUntouched() =
runTest {