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

@@ -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()
}