feat(chat): integrate fast mode toggle into generation controls dropdown (#1093)

This commit is contained in:
M57
2026-09-12 15:15:22 +03:00
committed by GitHub
parent 5dc3896237
commit 0bb087c41e
11 changed files with 388 additions and 19 deletions
@@ -24,6 +24,10 @@ private data class ActiveModelSwitch(
val previousModel: String?, val previousModel: String?,
) )
private data class ActiveFastSwitch(
val targetFast: Boolean,
)
/** /**
* Owns model picker loading/caching, in-session model switches, model capabilities, * Owns model picker loading/caching, in-session model switches, model capabilities,
* pinned model toggling, and expensive model switch confirmation dialogs. * pinned model toggling, and expensive model switch confirmation dialogs.
@@ -48,6 +52,8 @@ class ChatModelSwitchDelegate(
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) }, private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
) { ) {
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>() private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>()
private val fastRejectedModels = ConcurrentHashMap.newKeySet<String>()
private var activeModelSwitchConfirmation: ActiveModelSwitch? = null private var activeModelSwitchConfirmation: ActiveModelSwitch? = null
private var optimisticPreviousModel: String? = null private var optimisticPreviousModel: String? = null
private var cachedModelOptions: List<ModelProvider> = emptyList() private var cachedModelOptions: List<ModelProvider> = emptyList()
@@ -195,6 +201,7 @@ class ChatModelSwitchDelegate(
result: Any?, result: Any?,
) { ) {
val pending = pendingModelSwitchRequests.remove(id) val pending = pendingModelSwitchRequests.remove(id)
val pendingFast = pendingFastSwitchRequests.remove(id)
val map = result as? Map<*, *> ?: return val map = result as? Map<*, *> ?: return
val key = map["key"] as? String val key = map["key"] as? String
if (key == "model") { if (key == "model") {
@@ -209,6 +216,20 @@ class ChatModelSwitchDelegate(
it.copy(modelSwitchConfirmMessage = confirmMessage) 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?) { fun setReasoningLevel(level: String?) {
uiState.update { it.copy(reasoningLevel = level) } uiState.update { it.copy(reasoningLevel = level) }
val sessionId = runtimeSessionId() ?: return val sessionId = runtimeSessionId() ?: return
@@ -276,7 +342,12 @@ class ChatModelSwitchDelegate(
} else { } else {
val provider = label.substring(0, idx) val provider = label.substring(0, idx)
val model = label.substring(idx + 1) 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 -> uiState.update { state ->
@@ -634,6 +634,10 @@ fun ChatScreen(
onReasoningTap = { level -> viewModel.setReasoningLevel(level) }, onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning, canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
supportsReasoning = state.currentModelCapabilities?.reasoning, supportsReasoning = state.currentModelCapabilities?.reasoning,
fastMode = state.fastMode,
fastSupported = state.currentModelCapabilities?.fast == true,
isFastModeChanging = state.isFastModeChanging,
onToggleFastMode = { viewModel.toggleFastMode() },
) )
} }
@@ -130,6 +130,9 @@ data class ChatUiState(
val currentModelCapabilities: ModelCapabilities? = null, val currentModelCapabilities: ModelCapabilities? = null,
// Reasoning effort level for the current session // Reasoning effort level for the current session
val reasoningLevel: String? = null, 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, val terminalBackend: String? = null,
// Context-window meter (issue #756): tokens currently used by the session // Context-window meter (issue #756): tokens currently used by the session
// prompt (numerator) and the active model's full context window (denominator). // prompt (numerator) and the active model's full context window (denominator).
@@ -733,6 +736,10 @@ class ChatViewModel(
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 terminalBackend = info["terminal_backend"] 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 = val newModelLabel =
if (model != null && provider != null) { if (model != null && provider != null) {
"$provider/$model" "$provider/$model"
@@ -758,6 +765,8 @@ class ChatViewModel(
} else { } else {
reasoningEffort reasoningEffort
}, },
fastMode = fastFlag ?: state.fastMode,
isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging,
terminalBackend = terminalBackend ?: state.terminalBackend, terminalBackend = terminalBackend ?: state.terminalBackend,
fullContextTokens = if (modelSwapped) null else state.fullContextTokens, fullContextTokens = if (modelSwapped) null else state.fullContextTokens,
) )
@@ -1036,6 +1045,10 @@ class ChatViewModel(
val provider = infoMap?.get("provider") as? String val provider = infoMap?.get("provider") as? String
val reasoningEffort = infoMap?.get("reasoning_effort") as? String val reasoningEffort = infoMap?.get("reasoning_effort") as? String
val terminalBackend = infoMap?.get("terminal_backend") 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 // B8 (Jun 20 2026, kanban t_session_resume): do NOT reload
// cached messages here — switchSession() already did so before // cached messages here — switchSession() already did so before
@@ -1059,6 +1072,8 @@ class ChatViewModel(
} else { } else {
reasoningEffort reasoningEffort
}, },
fastMode = fastFlag ?: false,
isFastModeChanging = false,
terminalBackend = terminalBackend ?: it.terminalBackend, terminalBackend = terminalBackend ?: it.terminalBackend,
) )
} }
@@ -1198,6 +1213,10 @@ class ChatViewModel(
return return
} }
if (method == WsMethods.CONFIG_SET) {
modelSwitchDelegate.handleConfigSetError(id, error)
}
if (method == WsMethods.SESSION_CREATE) { if (method == WsMethods.SESSION_CREATE) {
val pending = pendingInitialPrompt val pending = pendingInitialPrompt
pendingInitialPrompt = null pendingInitialPrompt = null
@@ -2221,6 +2240,8 @@ class ChatViewModel(
fun setReasoningLevel(level: String?) = modelSwitchDelegate.setReasoningLevel(level) fun setReasoningLevel(level: String?) = modelSwitchDelegate.setReasoningLevel(level)
fun toggleFastMode() = modelSwitchDelegate.toggleFastMode()
fun getModelCapabilities( fun getModelCapabilities(
providerSlug: String, providerSlug: String,
modelName: String, modelName: String,
@@ -2471,6 +2492,8 @@ class ChatViewModel(
currentSessionModel = null, currentSessionModel = null,
currentModelCapabilities = null, currentModelCapabilities = null,
reasoningLevel = null, reasoningLevel = null,
fastMode = false,
isFastModeChanging = false,
terminalBackend = null, terminalBackend = null,
usedContextTokens = null, usedContextTokens = null,
fullContextTokens = null, fullContextTokens = null,
@@ -103,6 +103,10 @@ fun ChatInputBar(
onReasoningTap: (String?) -> Unit = {}, onReasoningTap: (String?) -> Unit = {},
canDisableReasoning: Boolean? = null, canDisableReasoning: Boolean? = null,
supportsReasoning: 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 // 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 // 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"), modifier = Modifier.testTag("chat_composer_toolbar"),
canDisableReasoning = canDisableReasoning, canDisableReasoning = canDisableReasoning,
supportsReasoning = supportsReasoning, supportsReasoning = supportsReasoning,
fastMode = fastMode,
fastSupported = fastSupported,
isFastModeChanging = isFastModeChanging,
onToggleFastMode = onToggleFastMode,
) )
// Attachment dropdown (anchored to the attach button in ComposerToolbar) // Attachment dropdown (anchored to the attach button in ComposerToolbar)
@@ -3,16 +3,20 @@ package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile 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.Mic
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
@@ -21,6 +25,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -29,8 +35,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
@@ -60,6 +70,10 @@ fun ComposerToolbar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
canDisableReasoning: Boolean? = null, canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null, supportsReasoning: Boolean? = null,
fastMode: Boolean = false,
fastSupported: Boolean = false,
isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {},
) { ) {
var showReasoningMenu by remember { mutableStateOf(false) } var showReasoningMenu by remember { mutableStateOf(false) }
val reasoningDisabledForModel = supportsReasoning == false val reasoningDisabledForModel = supportsReasoning == false
@@ -109,24 +123,44 @@ fun ComposerToolbar(
.testTag("model_chip"), .testTag("model_chip"),
) )
// Reasoning chip with dropdown menu (right side, next to mic) // Reasoning & Generation controls with dropdown menu (Option 3)
Box { Box {
FilterChip( FilterChip(
selected = reasoningLevel != null, selected = reasoningLevel != null || fastMode,
onClick = { showReasoningMenu = true }, onClick = { showReasoningMenu = true },
enabled = !reasoningDisabledForModel, enabled = isConnected,
label = { label = {
Text( Row(
text = verticalAlignment = Alignment.CenterVertically,
if (reasoningDisabledForModel) { horizontalArrangement = Arrangement.spacedBy(4.dp),
"No reasoning" ) {
} else { if (fastMode) {
buildReasoningLabel(reasoningLevel) Icon(
}, imageVector = Icons.Filled.Bolt,
style = MaterialTheme.typography.bodySmall, contentDescription = stringResource(R.string.chat_fast_mode_label),
maxLines = 1, tint = MaterialTheme.colorScheme.primary,
overflow = TextOverflow.Ellipsis, 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 =
Modifier Modifier
@@ -137,11 +171,74 @@ fun ComposerToolbar(
DropdownMenu( DropdownMenu(
expanded = showReasoningMenu, expanded = showReasoningMenu,
onDismissRequest = { showReasoningMenu = false }, 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(
text = "Reasoning", text = "REASONING",
style = MaterialTheme.typography.labelSmall, 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), modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
) )
if (canDisable == false) { if (canDisable == false) {
@@ -160,7 +257,6 @@ fun ComposerToolbar(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
) )
} }
HorizontalDivider()
val allLevels = val allLevels =
listOf( listOf(
"none" to "None", "none" to "None",
+3
View File
@@ -1031,6 +1031,9 @@
<string name="channels_expiry_format">ينتهي في: %1$s</string> <string name="channels_expiry_format">ينتهي في: %1$s</string>
<string name="channels_search_placeholder">البحث في القنوات…</string> <string name="channels_search_placeholder">البحث في القنوات…</string>
<string name="chat_attach_file">إرفاق ملف</string> <string name="chat_attach_file">إرفاق ملف</string>
<string name="chat_fast_mode_label">سريع</string>
<string name="chat_fast_mode_desc">الوضع السريع</string>
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
<string name="chat_save_attachment">حفظ %1$s</string> <string name="chat_save_attachment">حفظ %1$s</string>
<string name="chat_switch_model_title">تبديل النموذج (لهذه المحادثة)</string> <string name="chat_switch_model_title">تبديل النموذج (لهذه المحادثة)</string>
<string name="config_clear_value">مسح القيمة</string> <string name="config_clear_value">مسح القيمة</string>
+3
View File
@@ -883,4 +883,7 @@
<string name="subagent_status_cancelled">취소됨</string> <string name="subagent_status_cancelled">취소됨</string>
<string name="subagent_status_failed">실패</string> <string name="subagent_status_failed">실패</string>
<string name="subagent_duration">%1$.1f초</string> <string name="subagent_duration">%1$.1f초</string>
<string name="chat_fast_mode_label">빠름</string>
<string name="chat_fast_mode_desc">빠른 모드</string>
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
</resources> </resources>
+3
View File
@@ -1022,6 +1022,9 @@
<string name="channels_expiry_format">过期时间:%1$s</string> <string name="channels_expiry_format">过期时间:%1$s</string>
<string name="channels_search_placeholder">搜索渠道…</string> <string name="channels_search_placeholder">搜索渠道…</string>
<string name="chat_attach_file">附加文件</string> <string name="chat_attach_file">附加文件</string>
<string name="chat_fast_mode_label">快速</string>
<string name="chat_fast_mode_desc">快速模式</string>
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
<string name="chat_save_attachment">保存 %1$s</string> <string name="chat_save_attachment">保存 %1$s</string>
<string name="chat_switch_model_title">切换模型(当前对话)</string> <string name="chat_switch_model_title">切换模型(当前对话)</string>
<string name="config_clear_value">清除值</string> <string name="config_clear_value">清除值</string>
+3
View File
@@ -1158,6 +1158,9 @@
<string name="channels_expiry_format">Expires: %1$s</string> <string name="channels_expiry_format">Expires: %1$s</string>
<string name="channels_search_placeholder">Search channels…</string> <string name="channels_search_placeholder">Search channels…</string>
<string name="chat_attach_file">Attach file</string> <string name="chat_attach_file">Attach file</string>
<string name="chat_fast_mode_label">Fast</string>
<string name="chat_fast_mode_desc">Fast mode</string>
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
<string name="chat_save_attachment">Save %1$s</string> <string name="chat_save_attachment">Save %1$s</string>
<string name="chat_switch_model_title">Switch model (this chat)</string> <string name="chat_switch_model_title">Switch model (this chat)</string>
<string name="config_clear_value">Clear value</string> <string name="config_clear_value">Clear value</string>
@@ -40,7 +40,7 @@ class ChatModelSwitchDelegateTest {
slug = "openai", slug = "openai",
name = "OpenAI", name = "OpenAI",
models = listOf("gpt-4o"), 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) assertNull(uiState.value.modelSwitchConfirmMessage)
assertEquals("anthropic/claude-3", uiState.value.currentSessionModel) 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)
}
} }
@@ -2951,6 +2951,92 @@ class ChatViewModelTest {
assertEquals("nous/tencent/hy3:free", viewModel.uiState.value.currentSessionModel) 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 @Test
fun testResumeNotFound_recoversWithNewSession() = fun testResumeNotFound_recoversWithNewSession() =
runTest { runTest {