fix(#576): surface command.dispatch 4018 errors instead of silent dead-tap (#603)

* fix(#576): surface command.dispatch 4018 errors instead of silent dead-tap

* fix(#576): fall back to slash.exec for command.dispatch 4018 registry misses

* fix(#576): block cli_only/TUI-only commands at dispatch (blocklist deliverable)

* review(#603): address agy findings — tighten 4018 match, case-insensitive filter, add double-fault + blank-output tests

* fix(#576): surface command dispatch outputs and update tests

---------

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-15 23:43:00 +03:00
1 parent e273488adc
commit d9f20f8eb2
5 files changed
+281 -93

No files matched your search

@@ -24,3 +24,66 @@ data class CommandCategory(
val name: String,
val pairs: List<List<String>>,
)
/**
* Desktop/CLI-only and TUI-only slash commands that do NOT function on mobile
* (issue #574). Source of truth for the backend `cli_only` / TUI-only flags
* returned by `commands.catalog`.
*
* Two consumers:
* 1. The slash-command suggestion menu hides these (so users don't discover
* dead commands).
* 2. [com.m57.hermescontrol.ui.chat.ChatViewModel.handleSlashCommand] blocks
* them at dispatch time (issue #576, deliverable #3) — a user who types one
* anyway gets a clear "not supported on mobile" message instead of a doomed
* RPC that produces TUI-only output or a confusing error.
*/
object CommandBlocklist {
val UNSUPPORTED: Set<String> =
setOf(
"/clear",
"/redraw",
"/history",
"/save",
"/prompt",
"/snapshot",
"/handoff",
"/journey",
"/config",
"/statusbar",
"/timestamps",
"/verbose",
"/skin",
"/indicator",
"/busy",
"/tools",
"/toolsets",
"/skills",
"/pet",
"/hatch",
"/cron",
"/reload",
"/browser",
"/plugins",
"/billing",
"/platforms",
"/copy",
"/paste",
"/image",
"/quit",
// TUI-only extras (meaningless outside the TUI)
"/compact",
"/logs",
"/mouse",
)
/**
* True if [command] (e.g. "/clear" or "/CLEAR arg") is blocked on mobile.
* Matches on the exact command name (the token before the first space),
* so "/reload" is blocked while "/reload-mcp" (a supported command) is not.
*/
fun contains(command: String): Boolean {
val name = command.split(" ", limit = 2)[0].lowercase()
return name in UNSUPPORTED
}
}
@@ -127,6 +127,7 @@ import coil.compose.AsyncImage
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
@@ -739,45 +740,13 @@ private fun ChatInputBar(
Column {
// Commands hidden from the suggestion menu — desktop/CLI-only and
// TUI-only commands that don't function on mobile (issue #574).
// Source of truth: backend `cli_only` / TUI-only flags in the catalog.
val hiddenSlashDisplay =
setOf(
"/clear",
"/redraw",
"/history",
"/save",
"/prompt",
"/snapshot",
"/handoff",
"/journey",
"/config",
"/statusbar",
"/timestamps",
"/verbose",
"/skin",
"/indicator",
"/busy",
"/tools",
"/toolsets",
"/skills",
"/pet",
"/hatch",
"/cron",
"/reload",
"/browser",
"/plugins",
"/billing",
"/platforms",
"/copy",
"/paste",
"/image",
"/quit",
// TUI-only extras (meaningless outside the TUI)
"/compact",
"/logs",
"/mouse",
)
val commandNames = commandCatalog.pairs.map { it[0] }.filter { it !in hiddenSlashDisplay }
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
// also enforced at dispatch time (issue #576, deliverable #3).
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
val commandNames =
commandCatalog.pairs
.map { it[0] }
.filter { it.lowercase() !in hiddenSlashDisplay }
androidx.compose.animation.AnimatedVisibility(
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
@@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
@@ -855,6 +856,17 @@ class ChatViewModel(
}
}
// Block desktop/CLI-only + TUI-only commands that don't function on
// mobile (issue #576, deliverable #3). These are also hidden from the
// suggestion menu, but a user can still type one — intercept it here
// (before any RPC fires) with a clear message instead of a doomed call.
if (CommandBlocklist.contains(command)) {
addAssistantMessage(
"⚠️ ${command.split(" ", limit = 2)[0]} is not supported on mobile",
)
return
}
when (val result = slashDispatcher.dispatch(command)) {
is SlashResult.Interrupt -> {
interruptSession()
@@ -912,11 +924,47 @@ class ChatViewModel(
val name = parts[0].lowercase().removePrefix("/")
val arg = parts.getOrElse(1) { "" }
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.COMMAND_DISPATCH,
mapOf("name" to name, "arg" to arg, "session_id" to sessionId),
onSent = { id -> trackRequest(id, WsMethods.COMMAND_DISPATCH) },
)
try {
// Primary path: command.dispatch handles quick/plugin/bundle/
// skill commands + a few hardcoded ones. It returns a hard 4018
// "not a ... command" for everything that lives only in the TUI
// slash worker (the 29 commands that 4018'd on mobile — issue
// #576). For those we fall back to slash.exec, which runs the
// full COMMAND_REGISTRY through the worker.
val result =
wsClient.request(
WsMethods.COMMAND_DISPATCH,
mapOf("name" to name, "arg" to arg, "session_id" to sessionId),
).await()
handleDispatchResult(result)
} catch (e: HermesWsClient.HermesRpcException) {
val msg = e.message.orEmpty()
// Registry miss on command.dispatch: the backend emits exactly
// "not a quick/plugin/bundle/skill command: <name>" (tui_gateway
// server.py L12408). Match that precise phrase so unrelated
// errors can't accidentally trigger the slash.exec fallback.
if (msg.contains("not a quick/plugin/bundle/skill command")) {
// Registry miss on command.dispatch -> retry via slash.exec,
// which routes the full CLI command set through the worker.
try {
val result =
wsClient.request(
WsMethods.SLASH_EXEC,
mapOf(
"command" to "/$name${if (arg.isNotEmpty()) " $arg" else ""}",
"session_id" to sessionId,
),
).await()
val output = (result as? Map<*, *>)?.get("output") as? String
if (!output.isNullOrBlank()) addAssistantMessage(output)
} catch (e2: HermesWsClient.HermesRpcException) {
addAssistantMessage("⚠️ /$name: ${e2.message}")
}
} else {
// Legit error from command.dispatch (busy, no history, etc.)
addAssistantMessage("⚠️ /$name: ${e.message}")
}
}
}
}