From 0bb087c41e864a052e0a2a9358e8d23314f9ad6d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:15:22 +0300 Subject: [PATCH] feat(chat): integrate fast mode toggle into generation controls dropdown (#1093) --- .../ui/chat/ChatModelSwitchDelegate.kt | 73 +++++++++- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 4 + .../hermescontrol/ui/chat/ChatViewModel.kt | 23 ++++ .../ui/chat/components/ChatComposer.kt | 8 ++ .../ui/chat/components/ComposerToolbar.kt | 130 +++++++++++++++--- app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../ui/chat/ChatModelSwitchDelegateTest.kt | 71 +++++++++- .../ui/chat/ChatViewModelTest.kt | 86 ++++++++++++ 11 files changed, 388 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt index b295f6ef..6a1f687e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegate.kt @@ -24,6 +24,10 @@ private data class ActiveModelSwitch( val previousModel: String?, ) +private data class ActiveFastSwitch( + val targetFast: Boolean, +) + /** * Owns model picker loading/caching, in-session model switches, model capabilities, * pinned model toggling, and expensive model switch confirmation dialogs. @@ -48,6 +52,8 @@ class ChatModelSwitchDelegate( private val savePinnedModels: (List) -> Unit = { AuthManager.savePinnedModels(it) }, ) { private val pendingModelSwitchRequests = ConcurrentHashMap() + private val pendingFastSwitchRequests = ConcurrentHashMap() + private val fastRejectedModels = ConcurrentHashMap.newKeySet() private var activeModelSwitchConfirmation: ActiveModelSwitch? = null private var optimisticPreviousModel: String? = null private var cachedModelOptions: List = emptyList() @@ -195,6 +201,7 @@ class ChatModelSwitchDelegate( result: Any?, ) { val pending = pendingModelSwitchRequests.remove(id) + val pendingFast = pendingFastSwitchRequests.remove(id) val map = result as? Map<*, *> ?: return val key = map["key"] as? String if (key == "model") { @@ -209,6 +216,20 @@ class ChatModelSwitchDelegate( it.copy(modelSwitchConfirmMessage = confirmMessage) } } + } else if (key == "fast") { + val rawVal = map["value"] as? String + val confirmedFast = + if (rawVal != null) { + rawVal == "fast" || rawVal == "priority" + } else { + pendingFast?.targetFast ?: uiState.value.fastMode + } + uiState.update { + it.copy( + fastMode = confirmedFast, + isFastModeChanging = false, + ) + } } } @@ -234,6 +255,51 @@ class ChatModelSwitchDelegate( } } + fun toggleFastMode() { + val state = uiState.value + val sessionId = runtimeSessionId() ?: return + if (state.currentModelCapabilities?.fast != true) return + if (state.isFastModeChanging) return + + val target = !state.fastMode + uiState.update { it.copy(isFastModeChanging = true) } + scope.launch(ioDispatcher) { + wsSend( + WsMethods.CONFIG_SET, + mapOf( + "key" to "fast", + "value" to if (target) "fast" else "normal", + "session_id" to sessionId, + ), + ) { id -> + trackRequest(id, WsMethods.CONFIG_SET) + pendingFastSwitchRequests[id] = ActiveFastSwitch(target) + } + } + } + + fun handleConfigSetError( + id: String, + error: Any?, + ) { + val pendingFast = pendingFastSwitchRequests.remove(id) + if (pendingFast != null) { + val errorMsg = + when (error) { + is Map<*, *> -> error["message"] as? String ?: error.toString() + else -> error.toString() + }.lowercase() + val currentModel = uiState.value.currentSessionModel + if (currentModel != null && + (errorMsg.contains("fast mode is not available") || errorMsg.contains("unknown fast mode")) + ) { + fastRejectedModels.add(currentModel) + syncCurrentModelCapabilities() + } + uiState.update { it.copy(isFastModeChanging = false) } + } + } + fun setReasoningLevel(level: String?) { uiState.update { it.copy(reasoningLevel = level) } val sessionId = runtimeSessionId() ?: return @@ -276,7 +342,12 @@ class ChatModelSwitchDelegate( } else { val provider = label.substring(0, idx) val model = label.substring(idx + 1) - cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model) + val baseCaps = cachedModelOptions.find { it.slug == provider }?.capabilities?.get(model) + if (fastRejectedModels.contains(label) && baseCaps?.fast == true) { + baseCaps.copy(fast = false) + } else { + baseCaps + } } } uiState.update { state -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 3223cc33..9067946a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -634,6 +634,10 @@ fun ChatScreen( onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning, supportsReasoning = state.currentModelCapabilities?.reasoning, + fastMode = state.fastMode, + fastSupported = state.currentModelCapabilities?.fast == true, + isFastModeChanging = state.isFastModeChanging, + onToggleFastMode = { viewModel.toggleFastMode() }, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 3e24796e..3077478c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -130,6 +130,9 @@ data class ChatUiState( val currentModelCapabilities: ModelCapabilities? = null, // Reasoning effort level for the current session val reasoningLevel: String? = null, + // Fast mode / Priority processing state for the current session + val fastMode: Boolean = false, + val isFastModeChanging: Boolean = false, val terminalBackend: String? = null, // Context-window meter (issue #756): tokens currently used by the session // prompt (numerator) and the active model's full context window (denominator). @@ -733,6 +736,10 @@ class ChatViewModel( val provider = info["provider"] as? String val reasoningEffort = info["reasoning_effort"] as? String val terminalBackend = info["terminal_backend"] as? String + val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase() + val fastFlag = + (info["fast"] as? Boolean) + ?: (if (serviceTier != null) serviceTier == "priority" else null) val newModelLabel = if (model != null && provider != null) { "$provider/$model" @@ -758,6 +765,8 @@ class ChatViewModel( } else { reasoningEffort }, + fastMode = fastFlag ?: state.fastMode, + isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging, terminalBackend = terminalBackend ?: state.terminalBackend, fullContextTokens = if (modelSwapped) null else state.fullContextTokens, ) @@ -1036,6 +1045,10 @@ class ChatViewModel( val provider = infoMap?.get("provider") as? String val reasoningEffort = infoMap?.get("reasoning_effort") as? String val terminalBackend = infoMap?.get("terminal_backend") as? String + val serviceTier = (infoMap?.get("service_tier") as? String)?.trim()?.lowercase() + val fastFlag = + (infoMap?.get("fast") as? Boolean) + ?: (if (serviceTier != null) serviceTier == "priority" else null) // B8 (Jun 20 2026, kanban t_session_resume): do NOT reload // cached messages here — switchSession() already did so before @@ -1059,6 +1072,8 @@ class ChatViewModel( } else { reasoningEffort }, + fastMode = fastFlag ?: false, + isFastModeChanging = false, terminalBackend = terminalBackend ?: it.terminalBackend, ) } @@ -1198,6 +1213,10 @@ class ChatViewModel( return } + if (method == WsMethods.CONFIG_SET) { + modelSwitchDelegate.handleConfigSetError(id, error) + } + if (method == WsMethods.SESSION_CREATE) { val pending = pendingInitialPrompt pendingInitialPrompt = null @@ -2221,6 +2240,8 @@ class ChatViewModel( fun setReasoningLevel(level: String?) = modelSwitchDelegate.setReasoningLevel(level) + fun toggleFastMode() = modelSwitchDelegate.toggleFastMode() + fun getModelCapabilities( providerSlug: String, modelName: String, @@ -2471,6 +2492,8 @@ class ChatViewModel( currentSessionModel = null, currentModelCapabilities = null, reasoningLevel = null, + fastMode = false, + isFastModeChanging = false, terminalBackend = null, usedContextTokens = null, fullContextTokens = null, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 7e02eb86..0f6c139a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -103,6 +103,10 @@ fun ChatInputBar( onReasoningTap: (String?) -> Unit = {}, canDisableReasoning: Boolean? = null, supportsReasoning: Boolean? = null, + fastMode: Boolean = false, + fastSupported: Boolean = false, + isFastModeChanging: Boolean = false, + onToggleFastMode: () -> Unit = {}, ) { // Allow sending while the agent is mid-turn or awaiting approval: the // gateway's prompt.submit busy-input policy queues it as the next turn @@ -377,6 +381,10 @@ fun ChatInputBar( modifier = Modifier.testTag("chat_composer_toolbar"), canDisableReasoning = canDisableReasoning, supportsReasoning = supportsReasoning, + fastMode = fastMode, + fastSupported = fastSupported, + isFastModeChanging = isFastModeChanging, + onToggleFastMode = onToggleFastMode, ) // Attachment dropdown (anchored to the attach button in ComposerToolbar) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 8e161627..5a0828ae 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -3,16 +3,20 @@ package com.m57.hermescontrol.ui.chat.components import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip @@ -21,6 +25,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -29,8 +35,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R @@ -60,6 +70,10 @@ fun ComposerToolbar( modifier: Modifier = Modifier, canDisableReasoning: Boolean? = null, supportsReasoning: Boolean? = null, + fastMode: Boolean = false, + fastSupported: Boolean = false, + isFastModeChanging: Boolean = false, + onToggleFastMode: () -> Unit = {}, ) { var showReasoningMenu by remember { mutableStateOf(false) } val reasoningDisabledForModel = supportsReasoning == false @@ -109,24 +123,44 @@ fun ComposerToolbar( .testTag("model_chip"), ) - // Reasoning chip with dropdown menu (right side, next to mic) + // Reasoning & Generation controls with dropdown menu (Option 3) Box { FilterChip( - selected = reasoningLevel != null, + selected = reasoningLevel != null || fastMode, onClick = { showReasoningMenu = true }, - enabled = !reasoningDisabledForModel, + enabled = isConnected, label = { - Text( - text = - if (reasoningDisabledForModel) { - "No reasoning" - } else { - buildReasoningLabel(reasoningLevel) - }, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (fastMode) { + Icon( + imageVector = Icons.Filled.Bolt, + contentDescription = stringResource(R.string.chat_fast_mode_label), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = + if (reasoningDisabledForModel) { + if (fastMode) stringResource(R.string.chat_fast_mode_label) else "No reasoning" + } else { + val rLabel = buildReasoningLabel(reasoningLevel) + if (fastMode) { + "${stringResource( + R.string.chat_fast_mode_label, + )} · $rLabel" + } else { + rLabel + } + }, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } }, modifier = Modifier @@ -137,11 +171,74 @@ fun ComposerToolbar( DropdownMenu( expanded = showReasoningMenu, onDismissRequest = { showReasoningMenu = false }, + modifier = Modifier.widthIn(min = 220.dp), ) { + // ── Fast Mode Toggle Item ── + val fastAvailable = isConnected && fastSupported + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Bolt, + contentDescription = null, + tint = + if (fastMode) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp), + ) + }, + text = { + Column { + Text( + text = stringResource(R.string.chat_fast_mode_label), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal, + ) + if (!fastSupported) { + Text( + text = stringResource(R.string.chat_fast_mode_unavailable), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + }, + trailingIcon = { + if (isFastModeChanging) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Switch( + checked = fastMode, + onCheckedChange = null, // MenuItem click owns the trigger + enabled = fastAvailable && !isFastModeChanging, + modifier = + Modifier + .scale(0.85f) + .testTag("fast_mode_switch"), + ) + } + }, + onClick = { + if (fastAvailable && !isFastModeChanging) { + onToggleFastMode() + } + }, + enabled = fastAvailable && !isFastModeChanging, + ) + + HorizontalDivider() + Text( - text = "Reasoning", + text = "REASONING", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), ) if (canDisable == false) { @@ -160,7 +257,6 @@ fun ComposerToolbar( modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), ) } - HorizontalDivider() val allLevels = listOf( "none" to "None", diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 2dce4f60..1ba136d5 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1031,6 +1031,9 @@ ينتهي في: %1$s البحث في القنوات… إرفاق ملف +سريع +الوضع السريع +الوضع السريع غير مدعوم لهذا النموذج حفظ %1$s تبديل النموذج (لهذه المحادثة) مسح القيمة diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ba813670..e48b33cc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -883,4 +883,7 @@ 취소됨 실패 %1$.1f초 + 빠름 + 빠른 모드 + 이 모델에서는 빠른 모드를 사용할 수 없습니다 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ebd07c23..fcbfd2ec 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1022,6 +1022,9 @@ 过期时间:%1$s 搜索渠道… 附加文件 + 快速 + 快速模式 + 此模型不支持快速模式 保存 %1$s 切换模型(当前对话) 清除值 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 995ed704..5ca033e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1158,6 +1158,9 @@ Expires: %1$s Search channels… Attach file + Fast + Fast mode + Fast mode unavailable for this model Save %1$s Switch model (this chat) Clear value diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt index 346af7c8..478fe04a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatModelSwitchDelegateTest.kt @@ -40,7 +40,7 @@ class ChatModelSwitchDelegateTest { slug = "openai", name = "OpenAI", models = listOf("gpt-4o"), - capabilities = mapOf("gpt-4o" to ModelCapabilities(reasoning = true)), + capabilities = mapOf("gpt-4o" to ModelCapabilities(fast = true, reasoning = true)), ), ), ) @@ -138,4 +138,73 @@ class ChatModelSwitchDelegateTest { assertNull(uiState.value.modelSwitchConfirmMessage) assertEquals("anthropic/claude-3", uiState.value.currentSessionModel) } + + @Test + fun toggleFastMode_sendsConfigSetFast_andUpdatesOnAck() = + testScope.runTest { + delegate.preloadModelOptions() + advanceUntilIdle() + + // Switch to model with fast capability + delegate.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + sentMethods.clear() + sentParams.clear() + + assertTrue(uiState.value.currentModelCapabilities?.fast == true) + assertFalse(uiState.value.fastMode) + assertFalse(uiState.value.isFastModeChanging) + + delegate.toggleFastMode() + advanceUntilIdle() + assertTrue(uiState.value.isFastModeChanging) + assertFalse(uiState.value.fastMode) // Still unconfirmed! + assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods) + assertEquals("fast", sentParams.first()["key"]) + assertEquals("fast", sentParams.first()["value"]) + assertEquals("runtime-1", sentParams.first()["session_id"]) + + // Backend acknowledges + delegate.handleConfigSetResult("req-1", mapOf("key" to "fast", "value" to "fast")) + assertTrue(uiState.value.fastMode) + assertFalse(uiState.value.isFastModeChanging) + + // Toggle off + delegate.toggleFastMode() + advanceUntilIdle() + assertTrue(uiState.value.isFastModeChanging) + assertEquals("normal", sentParams.last()["value"]) + + delegate.handleConfigSetResult("req-2", mapOf("key" to "fast", "value" to "normal")) + assertFalse(uiState.value.fastMode) + assertFalse(uiState.value.isFastModeChanging) + } + + @Test + fun toggleFastMode_whenRejectedByBackend_clearsChangingAndLatchesUnavailable() = + testScope.runTest { + delegate.preloadModelOptions() + advanceUntilIdle() + delegate.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + sentMethods.clear() + sentParams.clear() + assertTrue(uiState.value.currentModelCapabilities?.fast == true) + + delegate.toggleFastMode() + advanceUntilIdle() + assertTrue(uiState.value.isFastModeChanging) + + // Backend returns error indicating fast mode not available + delegate.handleConfigSetError("req-1", mapOf("message" to "fast mode is not available for this model")) + assertFalse(uiState.value.isFastModeChanging) + assertFalse(uiState.value.fastMode) + assertEquals(false, uiState.value.currentModelCapabilities?.fast) + + // Further toggle attempts are no-ops + val sentCount = sentMethods.size + delegate.toggleFastMode() + advanceUntilIdle() + assertEquals(sentCount, sentMethods.size) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 8bc8a174..ca845d81 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -2951,6 +2951,92 @@ class ChatViewModelTest { assertEquals("nous/tencent/hy3:free", viewModel.uiState.value.currentSessionModel) } + @Test + fun testSessionInfo_hydratesFastModeState() = + runTest { + stubEmptySessionRests("session-a") + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.fastMode) + + // Fast = true in session.info + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("fast" to true, "service_tier" to "priority"), + ), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.fastMode) + assertFalse(viewModel.uiState.value.isFastModeChanging) + + // Fast = false / normal in session.info + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("fast" to false, "service_tier" to "normal"), + ), + ) + advanceUntilIdle() + assertFalse(viewModel.uiState.value.fastMode) + + // Partial session.info without fast/service_tier does NOT clear state + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("fast" to true), + ), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.fastMode) + + mockEventsFlow.emit( + WsEvent.SessionInfo( + mapOf("terminal_backend" to "tmux"), + ), + ) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.fastMode) // preserved! + } + + @Test + fun testSessionResume_hydratesFastModeState() = + runTest { + stubEmptySessionRests("session-b") + val (viewModel, _) = createViewModelWithSession() + var resumeReqId = "" + every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers { + val reqId = "resume-req-1" + resumeReqId = reqId + arg<((String) -> Unit)?>(2)?.invoke(reqId) + reqId + } + + viewModel.switchSession("session-b") + runCurrent() + + // Simulate SESSION_RESUME response with fast=true in info + mockEventsFlow.emit( + WsEvent.RpcResult( + id = resumeReqId, + result = + mapOf( + "session_id" to "sess-runtime-1", + "resumed" to "session-b", + "info" to + mapOf( + "model" to "gpt-4o", + "provider" to "openai", + "fast" to true, + "service_tier" to "priority", + ), + ), + ), + ) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value.fastMode) + assertFalse(viewModel.uiState.value.isFastModeChanging) + } + @Test fun testResumeNotFound_recoversWithNewSession() = runTest {