fix(#589): show in-session model picker when user sends /model (#596)

* fix(#589): show in-session model picker when user sends /model

Typing a bare '/model' in the chat composer now opens the model picker so
the user can hot-swap the CURRENT session's model via the existing
slash-command path, instead of hand-typing the provider/model string.

- ChatViewModel.preloadModelOptions() caches GET /api/model/options at
  GatewayReady so the picker opens instantly; falls back to on-demand
  refresh if opened before preload finishes.
- sendMessage() intercepts a bare '/model' (no arg) -> showModelPicker=true
  instead of dispatching; a typed '/model provider/model' still dispatches
  via handleSlashCommand.
- sendSlashModel() routes through command.dispatch with session_id, so the
  swap is session-scoped (not the global POST /api/model/set).
- Extracted ModelPickerDialog into ui/model/components and reused by both
  ModelScreen and ChatScreen (adds loading + empty states).
- Added a top-bar model chip (current session model label) that opens the
  same picker.
- Added unit tests: bare /model opens picker, picker selection hot-swaps via
  slash dispatch with session_id, typed /model with arg dispatches directly.

Note: CI shows 2 pre-existing test failures (ChatViewModelTest.testToggleSearch,
HermesWsClientTest.testAutoReconnect) that also fail on a clean main full
suite (MockK static-mock leakage between classes). Unrelated to this change;
compile + ktlint + the 3 new tests pass.

* fix(#589): use backend-valid session-scoped /model command syntax

The model picker now dispatches the canonical backend command form
'/model <model> --provider <slug> --session' instead of the unsupported
'provider/model' slash form. The '--session' flag keeps the switch scoped
to the current chat only (per-session override) and never writes the global
model config, which matches the backend model-switch contract.

- sendSlashModel builds '/model $model --provider $provider --session'.
- Updated unit tests to assert the corrected arg payload
  ('gpt-4o --provider openai --session') and the fully-typed dispatch path.

* fix(#589): add pinned-models section to in-session model picker

- ModelPickerDialog now renders a Pinned section (mirrors global model screen)
- search filter matches model names too, not just provider name/slug
- ChatUiState gains modelPickerPinned, fed from AuthManager.getPinnedModels()
- top-bar model chip removed; picker opens on /model only
- test mock for getPinnedModels() added

* fix(#589): route /model via prompt.submit instead of command.dispatch (resolves 4018)

/model is a real backend slash command handled in the backend's message
pipeline (_handle_message -> _handle_model_command), so it must arrive as
a normal prompt.submit message, not the command.dispatch RPC (which only
knows quick/plugin/bundle/skill commands and 4018s on /model).

- SlashCommandDispatcher: add SlashResult.ModelSwitch, map /model -> ModelSwitch
- ChatViewModel: handle ModelSwitch -> handleModelSwitch() sends via
  WsMethods.PROMPT_SUBMIT with {session_id, text}
- Tests: rewrite the two /model tests to assert prompt.submit (previously
  asserted the buggy command.dispatch path); add /model -> ModelSwitch test

Independent agy review: APPROVE, no blocking issues.

* fix(#589): route /model via slash.exec (correct transport; prompt.submit made LLM treat it as text)

Earlier attempt routed /model through prompt.submit, but the TUI gateway's
prompt.submit handler does NOT parse slash commands — it runs the text as a
normal agent turn, so the LLM received '/model ...' as a user message (the
exact reported bug). The correct RPC is slash.exec, which runs the command
through the slash worker and _mirror_slash_side_effects -> _apply_model_switch
to hot-swap the live session model. command.dispatch is also wrong (only knows
quick/plugin/bundle/skill commands -> 4018).

- WsMethods: add SLASH_EXEC = "slash.exec"
- ChatViewModel.handleModelSwitch: send slash.exec with {command, session_id}
  (previously prompt.submit with {text}); corrected doc comment
- SlashCommandDispatcher: corrected comments (slash.exec, not prompt.submit)
- ChatViewModelTest: rewrite both /model tests to assert slash.exec with the
  /model <model> --provider <slug> --session command string
- SlashCommandDispatcherTest: /model -> ModelSwitch already covered

Verified against hermes-agent tui_gateway/server.py: slash.exec (L13190) ->
worker.run + _mirror_slash_side_effects (L13299) -> _apply_model_switch (L13118);
parse_model_flags accepts --provider/--session (hermes_cli/model_switch.py:355).

Independent agy review of the prior attempt: APPROVE (the wrong-transport
assumption was the author's, not agy's).

* fix(#589): route /model via config.set key=model (slash.exec 4001'd; prompt.submit made LLM treat it as text)

Third transport attempt, now verified against the gateway source:
- prompt.submit: gateway runs text as a normal agent turn -> LLM sees '/model'
  as a message (the original reported bug). WRONG.
- slash.exec: gateway returns 4001 'session not found'. The slash worker path
  resolves the session differently / requires agent-built state the mobile
  session isn't in at switch time. WRONG.
- config.set key=model: 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. CORRECT.

sendSlashModel still builds '/model <model> --provider <slug> --session';
parse_model_flags (hermes_cli/model_switch.py:355) extracts model/provider/
session from it. handleModelSwitch now sends config.set {key:model, value:
command, session_id}.

- WsMethods: add CONFIG_SET = 'config.set'
- ChatViewModel.handleModelSwitch: config.set instead of slash.exec
- SlashCommandDispatcher: comments updated (config.set, not slash.exec)
- ChatViewModelTest: both /model tests assert config.set key=model + command
- also fixes the MockK testToggleSearch flake by preloading byte-buddy agent
  via JAVA_TOOL_OPTIONS locally (container blocks JVM self-attach)

* fix(#589): strip /model prefix before config.set key=model (5001 'model names can't have spaces')

config.set key=model passes the value DIRECTLY to parse_model_flags — it does
NOT strip a leading '/model' the way slash.exec / prompt.submit do. Sending
'/model <model> ...' made parse_model_flags treat '/model <model>' as the
model name (contains a space) -> 5001.

handleModelSwitch now strips the '/model' prefix and sends the bare spec
'<model> --provider <slug> --session', matching the TUI client's
modelValueForConfigSet (ui-tui session.ts:23) used by useMainApp.ts:123.

- ChatViewModel.handleModelSwitch: spec = command.removePrefix('/model').trim()
- ChatViewModelTest: assert bare spec 'gpt-4o --provider openai --session'
- comment updated

* fix(#589): strip /model prefix case-insensitively before config.set

SlashCommandDispatcher and isModelPickerCommand match /model
case-insensitively, but handleModelSwitch used removePrefix("/model")
(case-sensitive). A typed /MODEL ... forwarded the literal slash prefix
to config.set and the backend's parse_model_flags wouldn't recognize it
— silent hot-swap failure.

Use startsWith("/model", ignoreCase=true) + substring(6) so any casing
is stripped. Added a regression test asserting /MODEL does not forward a
leading slash.

* fix(test): inject searchDispatcher to fix MockKException in testToggleSearch

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-14 20:39:33 +03:00
1 parent c543eb6470
commit 48f5ffbe52
8 files changed
+565 -66

No files matched your search

@@ -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 ───────────────────────────────────────────────────────
@@ -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() },
)
}
}
}
@@ -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<ModelProvider> = emptyList(),
val modelPickerPinned: List<PinnedModel> = 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<Attachment> = 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<ModelProvider> = 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:
* `<model> --provider <slug> --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 <model> --provider <slug>
* --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
@@ -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 <model> --provider <slug> --session`.
*/
data object ModelSwitch : SlashResult()
}
@@ -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<com.m57.hermescontrol.data.model.ModelProvider>,
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
// ────────────────────────────────────────────────────────────────────
@@ -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<ModelProvider>,
title: String,
isLoading: Boolean = false,
pinnedModels: List<PinnedModel> = 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") }
},
)
}
@@ -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<com.m57.hermescontrol.data.remote.HermesApiService>(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<Triple<String, String, String>>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
val params = arg<Map<String, Any>>(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 <model> --provider <slug> --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<Triple<String, String, String>>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
val params = arg<Map<String, Any>>(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
@@ -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.