diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 2e3548e8..79b1a768 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -25,6 +25,8 @@ object WsMethods { const val COMMANDS_CATALOG = "commands.catalog" const val COMMAND_DISPATCH = "command.dispatch" const val COMMAND_RESOLVE = "command.resolve" + const val SLASH_EXEC = "slash.exec" + const val CONFIG_SET = "config.set" // ── Attachments ─────────────────────────────────────────────────────── 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 03435407..0c5a89fb 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 @@ -135,6 +135,7 @@ import com.m57.hermescontrol.ui.common.CredentialWarningBanner import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.model.components.ModelPickerDialog import com.m57.hermescontrol.ui.providers.ProvidersScreen import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -552,6 +553,20 @@ fun ChatScreen( }, ) } + + // In-session model picker (issue #589) — opens on "/model". + if (state.showModelPicker) { + ModelPickerDialog( + providers = state.modelPickerProviders, + title = "Switch model (this chat)", + isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(), + pinnedModels = state.modelPickerPinned, + onSelect = { provider, model -> + viewModel.sendSlashModel(provider, model) + }, + onDismiss = { viewModel.closeModelPicker() }, + ) + } } } 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 fb176dde..4a0be060 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 @@ -10,6 +10,8 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.SessionMessage import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult @@ -78,6 +80,15 @@ data class ChatUiState( val typingEffectDelayMs: Int = 30, // Commands catalog val commandCatalog: CommandCatalog = CommandCatalog(), + // In-session model picker (issue #589) — surfaced when the user types /model + // (or taps the top-bar model chip). Mirror of the global model screen's + // picker, but the selection hot-swaps the CURRENT session via the slash path. + val showModelPicker: Boolean = false, + val modelPickerProviders: List = emptyList(), + val modelPickerPinned: List = emptyList(), + val modelPickerLoading: Boolean = false, + // Current session's active model label (provider/model), shown in the chip + val currentSessionModel: String? = null, // Attachment state val pendingAttachments: List = emptyList(), // Reaction animation — set when a reaction WS event arrives, auto-clears @@ -135,6 +146,7 @@ class ChatViewModel( ChatPersistenceRepository( HermesDatabase.get(application).chatMessageDao(), ), + searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, ) : AndroidViewModel(application) { constructor(application: Application) : this(application, startCleanup = true) @@ -164,9 +176,17 @@ class ChatViewModel( ChatSearchDelegate( scope = viewModelScope, uiState = _uiState, + dispatcher = searchDispatcher, ) private val attachmentsDelegate = ChatAttachmentsDelegate(uiState = _uiState) + /** + * Model options cached from GET /api/model/options so the in-session model + * picker (issue #589) opens instantly when the user types /model or taps the + * top-bar chip. Preloaded at GatewayReady; refreshed on open if empty. + */ + private var cachedModelOptions: List = emptyList() + private val streamingController = ChatStreamingController( scope = viewModelScope, @@ -278,6 +298,7 @@ class ChatViewModel( addSystemMessage("Connected to Hermes") loadSessions() fetchCommandCatalog() + preloadModelOptions() val currentId = _uiState.value.currentSessionId if (currentId != null) { viewModelScope.launch(Dispatchers.IO) { @@ -653,6 +674,12 @@ class ChatViewModel( val trimmed = text.trim() if (trimmed.startsWith("/", ignoreCase = true)) { + // Issue #589: a bare "/model" (no argument) opens the picker instead + // of requiring the user to hand-type the provider/model. + if (isModelPickerCommand(trimmed)) { + openModelPicker() + return + } handleSlashCommand(trimmed) return } @@ -841,6 +868,10 @@ class ChatViewModel( branchSession(command) } + is SlashResult.ModelSwitch -> { + handleModelSwitch(command) + } + is SlashResult.RpcDispatch -> { dispatchViaRpc(command) } @@ -889,6 +920,51 @@ class ChatViewModel( } } + /** + * Hot-swap the current session's model via the backend's model-switch + * mechanism (issue #589). + * + * The TUI gateway's `prompt.submit` does NOT parse slash commands (it would + * make the LLM treat "/model ..." as a chat message), and `command.dispatch` + * only knows quick/plugin/bundle/skill commands (4018s on /model). The + * correct RPC is `config.set` with `key="model"` — the gateway (server.py + * `config.set`, L10253) routes `key=="model"` straight to `_apply_model_switch` + * using the same `_sessions.get(session_id)` lookup that the working + * `command.dispatch` uses. + * + * IMPORTANT: `config.set` key=model passes the value DIRECTLY to + * `parse_model_flags` (it does NOT strip a leading "/model" like slash.exec / + * prompt.submit do). So we strip the "/model" prefix here and send the bare + * spec `parse_model_flags` understands: + * ` --provider --session` + * (matching the TUI client's `modelValueForConfigSet`). + */ + private fun handleModelSwitch(command: String) { + val sessionId = runtimeSessionId + if (sessionId == null) { + addAssistantMessage("No active session. Use `/new` to create one.") + return + } + // Strip a leading "/model" (and any following whitespace) — config.set + // key=model expects the bare spec, not a slash command. Match the + // dispatcher's case-insensitive "/model" detection so a typed "/MODEL" + // (or any casing) doesn't forward the literal slash prefix to the + // backend, where parse_model_flags wouldn't recognize it. + val spec = + if (command.startsWith("/model", ignoreCase = true)) { + command.substring(6).trim() + } else { + command.trim() + } + viewModelScope.launch(Dispatchers.IO) { + wsClient.send( + WsMethods.CONFIG_SET, + mapOf("key" to "model", "value" to spec, "session_id" to sessionId), + onSent = { id -> trackRequest(id, WsMethods.CONFIG_SET) }, + ) + } + } + /** * Submits [text] as a prompt to the current session via WS, without * adding a duplicate user message. Used by [handleDispatchResult] when @@ -994,6 +1070,118 @@ class ChatViewModel( null } + // ── In-session model picker (issue #589) ───────────────────────────── + + /** + * Whether [command] should open the in-session model picker instead of being + * dispatched as a normal slash command. True for a bare `/model` (with no + * trailing model argument) — the picker supplies the argument interactively. + * A fully-typed `/model provider/model` is forwarded straight to the backend. + */ + private fun isModelPickerCommand(command: String): Boolean { + val trimmed = command.trim() + if (!trimmed.startsWith("/", ignoreCase = true)) return false + val body = trimmed.removePrefix("/").trimStart() + // Must be exactly "model" with no argument (or just whitespace). + return body.equals("model", ignoreCase = true) || + body.startsWith("model ", ignoreCase = true) && + body.substringAfter("model").trim().isEmpty() + } + + /** Preload model options so the picker opens instantly (no spinner on /model). */ + private fun preloadModelOptions() { + viewModelScope.launch(Dispatchers.IO) { + val result = + safeApiCall { + ApiClient.hermesApi.getModelOptions(refresh = false) + } + if (result is NetworkResult.Success) { + cachedModelOptions = result.data.providers.orEmpty() + _uiState.update { it.copy(modelPickerPinned = AuthManager.getPinnedModels()) } + } + } + } + + /** + * Open the in-session model picker. Uses the preloaded options if available + * (instant open); otherwise shows a loading state and fetches them. The + * `/model` slash command is the supported session hot-swap mechanism per the + * backend contract (issue #589). + */ + fun openModelPicker() { + val hasCached = cachedModelOptions.isNotEmpty() + _uiState.update { + it.copy( + showModelPicker = true, + modelPickerProviders = if (hasCached) cachedModelOptions else emptyList(), + modelPickerPinned = AuthManager.getPinnedModels(), + modelPickerLoading = !hasCached, + ) + } + if (!hasCached) { + refreshModelOptions() + } + } + + /** Re-fetch options (pull-to-refresh style) when the picker is already open. */ + fun refreshModelOptions() { + _uiState.update { it.copy(modelPickerLoading = true) } + viewModelScope.launch(Dispatchers.IO) { + val result = + safeApiCall { + ApiClient.hermesApi.getModelOptions(refresh = true) + } + when (result) { + is NetworkResult.Success -> { + cachedModelOptions = result.data.providers.orEmpty() + _uiState.update { + it.copy( + modelPickerProviders = cachedModelOptions, + modelPickerLoading = false, + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + modelPickerLoading = false, + errorMessage = "Failed to load models: ${result.error.message}", + ) + } + } + } + } + } + + fun closeModelPicker() { + _uiState.update { it.copy(showModelPicker = false, modelPickerLoading = false) } + } + + /** + * Hot-swap the CURRENT session's model via the /model slash command. + * + * Builds the backend-valid command form `/model --provider + * --session`. The `--session` flag keeps the switch scoped to this chat + * only (it writes a per-session override and never touches the global + * model config), per the backend model-switch contract. + */ + fun sendSlashModel( + provider: String, + model: String, + ) { + _uiState.update { + it.copy( + showModelPicker = false, + modelPickerLoading = false, + // Optimistic: reflect the chosen model in the top-bar chip until + // the next session sync confirms the backend hot-swap. + currentSessionModel = "$provider/$model", + ) + } + handleSlashCommand("/model $model --provider $provider --session") + } + fun switchSession(sessionId: String) { if (sessionId == _uiState.value.currentSessionId) return diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index 0a0cf508..9fa3344c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -7,7 +7,14 @@ package com.m57.hermescontrol.ui.chat * Pure logic — no I/O, no Android dependencies. * * Only commands that MUST be handled client-side (immediate UX) are here. - * Everything else is forwarded to the backend via [SlashResult.RpcDispatch]. + * `/fork` and `/model` are NOT sent via [SlashResult.RpcDispatch] (which maps + * to the `command.dispatch` RPC — that RPC only knows quick/plugin/bundle/skill + * commands and 4018s on everything else). They are real backend commands that + * get their own results: `/fork` goes via the `session.branch` RPC and `/model` + * via the `config.set` RPC (key="model" → gateway `_apply_model_switch`; the + * TUI gateway's `prompt.submit` does NOT parse slash commands, so sending it as + * a normal prompt makes the LLM treat it as text). Everything else is forwarded + * to the backend via [SlashResult.RpcDispatch]. */ class SlashCommandDispatcher { fun dispatch(command: String): SlashResult { @@ -18,6 +25,7 @@ class SlashCommandDispatcher { "/stop", "/interrupt" -> SlashResult.Interrupt "/new" -> SlashResult.NewSession "/fork", "/branch" -> SlashResult.SessionBranch + "/model" -> SlashResult.ModelSwitch else -> SlashResult.RpcDispatch } } @@ -38,4 +46,13 @@ sealed class SlashResult { /** Fork the active conversation via the session.branch WebSocket RPC. */ data object SessionBranch : SlashResult() + + /** + * Hot-swap the current session's model via the backend `config.set` RPC + * (key="model" → `_apply_model_switch`). NOT command.dispatch (4018s on + * /model) and NOT prompt.submit (LLM would treat it as text). The value + * string carries the flags `parse_model_flags` understands: + * `/model --provider --session`. + */ + data object ModelSwitch : SlashResult() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index 71b80f37..99242e28 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -63,6 +63,7 @@ import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.model.components.MoaConfigDialog +import com.m57.hermescontrol.ui.model.components.ModelPickerDialog @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -241,6 +242,7 @@ fun ModelScreen( ModelPickerDialog( providers = state.providers, title = "Set Main Model", + isLoading = state.isLoading && state.providers.isEmpty(), onSelect = { provider, model -> viewModel.setMainModel(provider, model) }, @@ -264,6 +266,7 @@ fun ModelScreen( ModelPickerDialog( providers = state.providers, title = "Set Aux: ${state.auxPickerTask}", + isLoading = state.isLoading && state.providers.isEmpty(), onSelect = { provider, model -> viewModel.setAuxTask(state.auxPickerTask, provider, model) }, @@ -542,70 +545,6 @@ private fun AuxTasksDialog( // Model Picker Dialog // ──────────────────────────────────────────────────────────────────── -@Composable -private fun ModelPickerDialog( - providers: List, - title: String, - onSelect: (provider: String, model: String) -> Unit, - onDismiss: () -> Unit, -) { - var pickerQuery by remember { mutableStateOf("") } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - Column { - SearchBar( - query = pickerQuery, - onQueryChange = { pickerQuery = it }, - placeholder = "Search providers...", - ) - Spacer(modifier = Modifier.height(8.dp)) - - LazyColumn(modifier = Modifier.height(400.dp)) { - items( - providers.filter { - pickerQuery.isBlank() || - it.name.contains(pickerQuery, ignoreCase = true) || - it.slug.contains(pickerQuery, ignoreCase = true) - }, - key = { it.slug }, - ) { provider -> - val models = - provider.models.orEmpty().filter { - pickerQuery.isBlank() || - it.contains(pickerQuery, ignoreCase = true) - } - - if (models.isNotEmpty()) { - Text( - text = provider.name, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), - ) - - models.forEach { model -> - OutlinedButton( - onClick = { onSelect(provider.slug, model) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), - ) { - Text(text = model, style = MaterialTheme.typography.bodySmall) - } - } - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - // ──────────────────────────────────────────────────────────────────── // Pinned Section Card — extracted from existing screen // ──────────────────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt new file mode 100644 index 00000000..6babb9ef --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt @@ -0,0 +1,179 @@ +package com.m57.hermescontrol.ui.model.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.ui.common.LoadingState +import com.m57.hermescontrol.ui.common.SearchBar + +/** + * Reusable model picker used by both the global model screen and the + * in-session `/model` hot-swap (issue #589). Selecting a provider/model pair + * invokes [onSelect]; the consumer decides what to do with it (global + * `config.yaml` assignment vs. a session-scoped `/model` slash command). + * + * [pinnedModels] renders a pinned section at the top (mirrors the global model + * screen's pin section) so frequently-used models are one tap away. + */ +@Composable +fun ModelPickerDialog( + providers: List, + title: String, + isLoading: Boolean = false, + pinnedModels: List = emptyList(), + onSelect: (provider: String, model: String) -> Unit, + onDismiss: () -> Unit, +) { + var pickerQuery by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + if (isLoading) { + LoadingState( + subtitle = "Loading models…", + modifier = Modifier.fillMaxWidth(), + ) + } else if (providers.isEmpty() && pinnedModels.isEmpty()) { + Text( + text = "No models available.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val filteredPinned = + remember(pickerQuery, pinnedModels) { + if (pickerQuery.isBlank()) { + pinnedModels + } else { + pinnedModels.filter { + it.modelName.contains(pickerQuery, ignoreCase = true) || + it.providerSlug.contains(pickerQuery, ignoreCase = true) + } + } + } + + SearchBar( + query = pickerQuery, + onQueryChange = { pickerQuery = it }, + placeholder = "Search models and providers...", + ) + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn(modifier = Modifier.height(400.dp)) { + // ── Pinned section ── + if (filteredPinned.isNotEmpty()) { + item(key = "pinned-header") { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 4.dp, bottom = 4.dp), + ) { + Icon( + imageVector = Icons.Filled.PushPin, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = "Pinned", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + } + } + items( + filteredPinned, + key = { "pinned:${it.providerSlug}:${it.modelName}" }, + ) { pinned -> + OutlinedButton( + onClick = { onSelect(pinned.providerSlug, pinned.modelName) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = "${pinned.modelName} · ${pinned.providerSlug}", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + + // ── All providers / models ── + items( + providers.filter { provider -> + pickerQuery.isBlank() || + provider.name.contains(pickerQuery, ignoreCase = true) || + provider.slug.contains(pickerQuery, ignoreCase = true) || + provider.models.orEmpty().any { + it.contains(pickerQuery, ignoreCase = true) + } + }, + key = { it.slug }, + ) { provider -> + val models = + provider.models.orEmpty().filter { + pickerQuery.isBlank() || + it.contains(pickerQuery, ignoreCase = true) + } + + if (models.isNotEmpty()) { + Text( + text = provider.name, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + ) + + models.forEach { model -> + OutlinedButton( + onClick = { onSelect(provider.slug, model) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Text(text = model, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} 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 b60670f5..1ac57b6d 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 @@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.ws.JsonRpcError import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -68,6 +69,7 @@ class ChatViewModelTest { every { Dispatchers.Main } returns testMainDispatcher mockkObject(AuthManager) + every { AuthManager.getPinnedModels() } returns emptyList() mockkObject(HermesWsClient) mockkObject(ApiClient) mockkObject(HermesDatabase) @@ -101,6 +103,30 @@ class ChatViewModelTest { arg<((String) -> Unit)?>(2)?.invoke(id) id } + + // Stub model-options so preloadModelOptions() (fired at GatewayReady) is safe. + val mockApi = mockk(relaxed = true) + every { ApiClient.hermesApi } returns mockApi + coEvery { + mockApi.getModelOptions(any(), any()) + } returns + retrofit2.Response.success( + com.m57.hermescontrol.data.model.ModelOptionsResponse( + providers = + listOf( + com.m57.hermescontrol.data.model.ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-4o", "gpt-4o-mini"), + ), + com.m57.hermescontrol.data.model.ModelProvider( + slug = "anthropic", + name = "Anthropic", + models = listOf("claude-3-5-sonnet"), + ), + ), + ), + ) } @After @@ -113,7 +139,7 @@ class ChatViewModelTest { /** Create a ViewModel with the fake repo injected directly. */ private fun createViewModel(startCleanup: Boolean = false): ChatViewModel = - ChatViewModel(app, startCleanup, fakeRepo) + ChatViewModel(app, startCleanup, fakeRepo, testDispatcher) /** * Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result, @@ -320,6 +346,129 @@ class ChatViewModelTest { verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } } + @Test + fun testBareModelCommand_opensPickerInsteadOfDispatch() = + runTest { + val (viewModel, _) = createViewModelWithSession() + + // A bare "/model" must NOT dispatch a slash command; it opens the picker. + viewModel.sendMessage("/model") + advanceUntilIdle() + + assertTrue( + "picker should be shown when bare /model is typed", + viewModel.uiState.value.showModelPicker, + ) + assertTrue( + "picker should have preloaded providers (cached at GatewayReady)", + viewModel.uiState.value.modelPickerProviders.isNotEmpty(), + ) + verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } + } + + @Test + fun testModelPickerSelection_hotSwapsCurrentSessionViaSlash() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + viewModel.sendMessage("/model") + advanceUntilIdle() + assertTrue(viewModel.uiState.value.showModelPicker) + + // Selecting a model must send the bare spec "gpt-4o --provider openai + // --session" via the `config.set` RPC with key="model" (the gateway + // routes key=="model" to _apply_model_switch; the /model prefix is + // stripped before send because config.set does not parse slash + // commands). NOT command.dispatch (4018s on /model), NOT prompt.submit + // (LLM would treat it as text). Capture the config.set params. + val modelCalls = mutableListOf>() + every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { + val params = arg>(1) + modelCalls.add( + Triple( + params["key"] as String, + params["value"] as String, + params["session_id"] as String, + ), + ) + "req-cfg-${modelCalls.size}" + } + + viewModel.sendSlashModel("openai", "gpt-4o") + advanceUntilIdle() + + assertFalse("picker closes after selection", viewModel.uiState.value.showModelPicker) + assertEquals( + "openai/gpt-4o", + viewModel.uiState.value.currentSessionModel, + ) + verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } + val call = modelCalls.firstOrNull { it.first == "model" } + assertNotNull("selection must route through config.set key=model", call) + assertEquals("gpt-4o --provider openai --session", call!!.second) + assertEquals(sessionId, call.third) + } + + @Test + fun testTypedModelCommandWithArg_dispatchesDirectly() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + // A fully-typed "/model --provider --session" bypasses the + // picker and dispatches straight to the backend as a normal prompt. + viewModel.sendMessage("/model gpt-4o --provider openai --session") + advanceUntilIdle() + + assertFalse( + "typed /model with arg should not open the picker", + viewModel.uiState.value.showModelPicker, + ) + // A fully-typed /model goes to the backend via the `config.set` RPC + // (key="model"), which the gateway routes to _apply_model_switch. NOT + // command.dispatch (4018s on /model) and NOT prompt.submit (LLM would + // treat it as text). + verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } + } + + @Test + fun testTypedModelCommand_caseInsensitive_doesNotForwardSlashPrefix() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + // A fully-typed /MODEL (uppercase) must still route through + // config.set key=model with the BARE spec — the leading "/MODEL" + // slash prefix must be stripped, or parse_model_flags on the + // backend won't recognize it and the hot-swap silently fails. + val modelCalls = mutableListOf>() + every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers { + val params = arg>(1) + modelCalls.add( + Triple( + params["key"] as String, + params["value"] as String, + params["session_id"] as String, + ), + ) + "req-cfg-ci-${modelCalls.size}" + } + + viewModel.sendMessage("/MODEL gpt-4o --provider openai --session") + advanceUntilIdle() + + val call = modelCalls.firstOrNull { it.first == "model" } + assertNotNull("uppercase /MODEL must route through config.set key=model", call) + assertEquals( + "slash prefix must be stripped before send", + "gpt-4o --provider openai --session", + call!!.second, + ) + assertFalse( + "value must not carry the literal /MODEL prefix", + call.second.startsWith("/"), + ) + assertEquals(sessionId, call.third) + } + // ── Connection / init tests ────────────────────────────────────────────── @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt index f9781761..2404a2f8 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcherTest.kt @@ -43,6 +43,16 @@ class SlashCommandDispatcherTest { assertEquals(SlashResult.SessionBranch, dispatcher.dispatch("/FORK hello")) } + @Test + fun `model routes to ModelSwitch`() { + // /model is a real backend slash command (issue #589) that must travel as + // a normal prompt message — NOT via command.dispatch (which 4018s on it, + // because that RPC only knows quick/plugin/bundle/skill commands). + assertEquals(SlashResult.ModelSwitch, dispatcher.dispatch("/model")) + assertEquals(SlashResult.ModelSwitch, dispatcher.dispatch("/MODEL openai/gpt-4o --session")) + assertEquals(SlashResult.ModelSwitch, dispatcher.dispatch("/Model")) + } + @Test fun `NEW uppercase still routes to NewSession`() { // Dispatcher lower-cases before matching, so case must not matter.