fix(chat): keep context meter aligned after model swaps (#1103)
This commit is contained in:
1 parent
4246ae196d
commit
0a2c782139
8 files changed
+720
-127
No files matched your search
@@ -84,4 +84,24 @@ class ContextBreakdownResponseTest {
|
||||
assertEquals(262144L, parsed?.contextMax)
|
||||
assertEquals(0, parsed?.contextPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JsonObject and JsonPrimitive payloads parse successfully`() {
|
||||
val json =
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("context_used", kotlinx.serialization.json.JsonPrimitive(52428))
|
||||
put("context_max", kotlinx.serialization.json.JsonPrimitive(524288))
|
||||
put("context_percent", kotlinx.serialization.json.JsonPrimitive(10))
|
||||
put("estimated_total", kotlinx.serialization.json.JsonPrimitive(52428))
|
||||
put("model", kotlinx.serialization.json.JsonPrimitive("upstage/solar-pro4:free"))
|
||||
}
|
||||
|
||||
val parsed = parseContextBreakdown(json)
|
||||
|
||||
assertEquals(52428L, parsed?.contextUsed)
|
||||
assertEquals(524288L, parsed?.contextMax)
|
||||
assertEquals(10, parsed?.contextPercent)
|
||||
assertEquals(52428L, parsed?.estimatedTotal)
|
||||
assertEquals("upstage/solar-pro4:free", parsed?.model)
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,24 @@ class UsageSnapshotResponseTest {
|
||||
assertNull(parseUsageSnapshot("oops"))
|
||||
assertNull(parseUsageSnapshot(listOf(1.0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JsonObject and JsonPrimitive payloads parse successfully`() {
|
||||
val json =
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("compressions", kotlinx.serialization.json.JsonPrimitive(3))
|
||||
put("context_used", kotlinx.serialization.json.JsonPrimitive(12000))
|
||||
put("context_max", kotlinx.serialization.json.JsonPrimitive(200000))
|
||||
put("total", kotlinx.serialization.json.JsonPrimitive(50000))
|
||||
put("avg_tps", kotlinx.serialization.json.JsonPrimitive(45.5))
|
||||
}
|
||||
|
||||
val parsed = parseUsageSnapshot(json)
|
||||
|
||||
assertEquals(3, parsed?.compressions)
|
||||
assertEquals(12000L, parsed?.contextUsed)
|
||||
assertEquals(200000L, parsed?.contextMax)
|
||||
assertEquals(50000L, parsed?.totalTokens)
|
||||
assertEquals(45.5, parsed?.avgTps ?: 0.0, 0.001)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user