TUI slash command dispatch bugs and fixes
NousResearch/hermes-agent · cluster 12143 · hermes cli
Data as of
Nothing open on GitHub. Fix merged in #12141. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #12141. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PRs #12141, #62859, #121077. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These members concern slash commands (/steer, /queue, and custom skill-bundle commands) silently failing or misbehaving in the TUI and CLI interfaces. The group includes bug reports and corresponding fix attempts addressing command dispatch, idle-state handling, and related TUI behavior.
Open issues0
Open PRs0
Fixes on record
-
#12141 fix(tui): clickable hyperlinks and skill slash command dispatch
files:
tests/tui_gateway/test_protocol.py,tui_gateway/server.py,ui-tui/src/__tests__/asCommandDispatch.test.ts,ui-tui/src/__tests__/createSlashHandler.test.ts,ui-tui/src/app/createSlashHandler.ts,ui-tui/src/app/useMainApp.ts,ui-tui/src/components/markdown.tsx,ui-tui/src/components/messageLine.tsx,ui-tui/src/gatewayTypes.ts,ui-tui/src/lib/rpc.ts,ui-tui/src/types/hermes-ink.d.tsShow the diff
-
tests/tui_gateway/test_protocol.py@@ -231,3 +231,201 @@ def test_cli_exec_blocked(server, argv): ]) def test_cli_exec_allowed(server, argv): assert server._cli_exec_blocked(argv) is None + + +# ── slash.exec skill command interception ──────────────────────────── + + +def test_slash_exec_rejects_skill_commands(server): + """slash.exec must reject skill commands so the TUI falls through to command.dispatch.""" + # Register a mock session + sid = "test-session" + server._sessions[sid] = {"session_key": sid, "agent": None} + + # Mock scan_skill_commands to return a known skill + fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}} + + with patch("agent.skill_commands.get_skill_commands", return_value=fake_skills): + resp = server.handle_request({ + "id": "r1", + "method": "slash.exec", + "params": {"command": "hermes-agent-dev", "session_id": sid}, + }) + + # Should return an error so the TUI's .catch() fires command.dispatch + assert "error" in resp + assert resp["error"]["code"] == 4018 + assert "skill command" in resp["error"]["message"] + + [redacted:email]("cmd", ["retry", "queue hello", "q hello", "steer fix the test", "plan"]) +def test_slash_exec_rejects_pending_input_commands(server, cmd): + """slash.exec must reject commands that use _pending_input in the CLI.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid, "agent": None} + + resp = server.handle_request({ + "id": "r1", + "method": "slash.exec", + "params": {"command": cmd, "session_id": sid}, + }) + + assert "error" in resp + assert resp["error"]["code"] == 4018 + assert "pending-input command" in resp["error"]["message"] + + +def test_command_dispatch_queue_sends_message(server): + """command.dispatch /queue returns {type: 'send', message: ...} for the TUI.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + + resp = server.handle_request({ + "id": "r1", + "method": "command.dispatch", + "params": {"name": "queue", "arg": "tell me about quantum computing", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "tell me about quantum computing" + + +def test_command_dispatch_queue_requires_arg(server): + """command.dispatch /queue without an argument returns an error.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + + resp = server.handle_request({ + "id": "r2", + "method": "command.dispatch", + "params": {"name": "queue", "arg": "", "session_id": sid}, + }) + + assert "error" in resp + assert resp["error"]["code"] == 4004 + + +def test_command_dispatch_steer_fallback_sends_message(server): + """command.dispatch /steer with no active agent falls back to send.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid, "agent": None} + + resp = server.handle_request({ + "id": "r3", + "method": "command.dispatch", + "params": {"name": "steer", "arg": "focus on testing", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "focus on testing" + + +def test_command_dispatch_retry_finds_last_user_message(server): + """command.dispatch /retry walks session['history'] to find the last user message.""" + sid = "test-session" + history = [ + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "second question"}, + {"role": "assistant", "content": "second answer"}, + ] + server._sessions[sid] = { + "session_key": sid, + "agent": None, + "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + } + + resp = server.handle_request({ + "id": "r4", + "method": "command.dispatch", + "params": {"name": "retry", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "second question" + # Verify history was truncated: everything from last user message onward removed + assert len(server._sessions[sid]["history"]) == 2 + assert server._sessions[sid]["history"][-1]["role"] == "assistant" + assert server._sessions[sid]["history_version"] == 1 + + +def test_command_dispatch_retry_empty_history(server): + """command.dispatch /retry with empty history returns error.""" + sid = "test-session" + server._sessions[sid] = { + "session_key": sid, + "agent": None, + "history": [], + "history_lock": threading.Lock(), + "history_version": 0, + } + + resp = server.handle_request({ + "id": "r5", + "method": "command.dispatch", + "params": {"name": "retry", "session_id": sid}, + }) + + assert "error" in resp + assert resp["error"]["code"] == 4018 + + +def test_command_dispatch_retry_handles_multipart_content(server): + """command.dispatch /retry extracts text from multipart content lists.""" + sid = "test-session" + history = [ + {"role": "user", "content": [ + {"type": "text", "text": "analyze this"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}} + ]}, + {"role": "assistant", "content": "I see the image."}, + ] + server._sessions[sid] = { + "session_key": sid, + "agent": None, + "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + } + + resp = server.handle_request({ + "id": "r6", + "method": "command.dispatch", + "params": {"name": "retry", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "analyze this" + + +def test_command_dispatch_returns_skill_payload(server): + """command.dispatch returns structured skill payload for the TUI to send().""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + + fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}} + fake_msg = "Loaded skill content here" + + with patch("agent.skill_commands.scan_skill_commands", return_value=fake_skills), \ + patch("agent.skill_commands.build_skill_invocation_message", return_value=fake_msg): + resp = server.handle_request({ + "id": "r2", + "method": "command.dispatch", + "params": {"name": "hermes-agent-dev", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "skill" + assert result["message"] == fake_msg + assert result["name"] == "hermes-agent-dev" -
tui_gateway/server.py@@ -1949,6 +1949,13 @@ def _(rid, params: dict) -> dict: ("/logs", "Show recent gateway log lines", "TUI"), ] +# Commands that queue messages onto _pending_input in the CLI. +# In the TUI the slash worker subprocess has no reader for that queue, +# so slash.exec rejects them → TUI falls through to command.dispatch. +_PENDING_INPUT_COMMANDS: frozenset[str] = frozenset({ + "retry", "queue", "q", "steer", "plan", +}) + @method("commands.catalog") def _(rid, params: dict) -> dict: @@ -2117,6 +2124,75 @@ def _(rid, params: dict) -> dict: except Exception: pass + # ── Commands that queue messages onto _pending_input in the CLI ─── + # In the TUI the slash worker subprocess has no reader for that queue, + # so we handle them here and return a structured payload. + + if name in ("queue", "q"): + if not arg: + return _err(rid, 4004, "usage: /queue <prompt>") + return _ok(rid, {"type": "send", "message": arg}) + + if name == "retry": + if not session: + return _err(rid, 4001, "no active session to retry") + history = session.get("history", []) + if not history: + return _err(rid, 4018, "no previous user message to retry") + # Walk backwards to find the last user message + last_user_idx = None + for i in range(len(history) - 1, -1, -1): + if history[i].get("role") == "user": + last_user_idx = i + break + if last_user_idx is None: + return _err(rid, 4018, "no previous user message to retry") + content = history[last_user_idx].get("content", "") + if isinstance(content, list): + content = " ".join( + p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text" + ) + if not content: + return _err(rid, 4018, "last user message is empty") + # Truncate history: remove everything from the last user message onward + # (mirrors CLI retry_last() which strips the failed exchange) + with session["history_lock"]: + session["history"] = history[:last_user_idx] + session["history_version"] = int(session.get("history_version", 0)) + 1 + return _ok(rid, {"type": "send", "message": content}) + + if name == "steer": + if not arg: + return _err(rid, 4004, "usage: /steer <prompt>") + agent = session.get("agent") if session else None + if agent and hasattr(agent, "steer"): + try: + accepted = agent.steer(arg) + if accepted: + return _ok(rid, {"type": "exec", "output": f"⏩ Steer queued — arrives after the next tool call: {arg[:80]}{'...' if len(arg) > 80 else ''}"}) + except Exception: + pass + # Fallback: no active run, treat as next-turn message + return _ok(rid, {"type": "send", "message": arg}) + + if name == "plan": + try: + from agent.skill_commands import build_skill_invocation_message as _bsim, build_plan_path + user_instruction = arg or "" + plan_path = build_plan_path(user_instruction) + msg = _bsim( + "/plan", user_instruction, + task_id=session.get("session_key", "") if session else "", + runtime_note=( + "Save the markdown plan with write_file to this exact relative path " + f"inside the active workspace/backend cwd: {plan_path}" + ), + ) + if msg: + return _ok(rid, {"type": "send", "message": msg}) + except Exception as e: + return _err(rid, 5030, f"plan skill failed: {e}") + return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") @@ -2333,6 +2409,26 @@ def _(rid, params: dict) -> dict: if not cmd: return _err(rid, 4004, "empty command") + # Skill slash commands and _pending_input commands must NOT go through the + # slash worker — see _PENDING_INPUT_COMMANDS definition above. + # (/browser connect/disconnect also uses _pending_input for context + # notes, but the actual browser operations need the slash worker's + # env-var side effects, so they stay in slash.exec — only the context + # note to the model is lost, which is low-severity.) + _cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split() + _cmd_base = _cmd_parts[0] if _cmd_parts else "" + + if _cmd_base in _PENDING_INPUT_COMMANDS: + return _err(rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}") + + try: + from agent.skill_commands import get_skill_commands + _cmd_key = f"/{_cmd_base}" + if _cmd_key in get_skill_commands(): + return _err(rid, 4018, f"skill command: use command.dispatch for {_cmd_key}") + except Exception: + pass + worker = session.get("slash_worker") if not worker: try: -
ui-tui/src/__tests__/asCommandDispatch.test.ts@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { asCommandDispatch } from '../lib/rpc.js' describe('asCommandDispatch', () => { - it('parses exec, alias, and skill', () => { + it('parses exec, alias, skill, and send', () => { expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' }) expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' }) expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({ @@ -11,11 +11,17 @@ describe('asCommandDispatch', () => { name: 'x', message: 'do' }) + expect(asCommandDispatch({ type: 'send', message: 'hello world' })).toEqual({ + type: 'send', + message: 'hello world' + }) }) it('rejects malformed payloads', () => { expect(asCommandDispatch(null)).toBeNull() expect(asCommandDispatch({ type: 'alias' })).toBeNull() expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull() + expect(asCommandDispatch({ type: 'send' })).toBeNull() + expect(asCommandDispatch({ type: 'send', message: 42 })).toBeNull() }) }) -
ui-tui/src/__tests__/createSlashHandler.test.ts@@ -121,6 +121,67 @@ describe('createSlashHandler', () => { expect(createSlashHandler(ctx)('/h')).toBe(true) expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array)) }) + + it('falls through to command.dispatch for skill commands and sends the message', async () => { + const skillMessage = 'Use this skill to do X.\n\n## Steps\n1. First step' + + const ctx = buildCtx({ + gateway: { + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn((method: string) => { + if (method === 'slash.exec') { + return Promise.reject(new Error('skill command: use command.dispatch')) + } + + if (method === 'command.dispatch') { + return Promise.resolve({ type: 'skill', message: skillMessage, name: 'hermes-agent-dev' }) + } + + return Promise.resolve({}) + }) + }, + rpc: vi.fn(() => Promise.resolve({})) + } + }) + + const h = createSlashHandler(ctx) + expect(h('/hermes-agent-dev')).toBe(true) + await vi.waitFor(() => { + expect(ctx.transcript.sys).toHaveBeenCalledWith('⚡ loading skill: hermes-agent-dev') + }) + expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage) + }) + + it('handles send-type dispatch for /plan command', async () => { + const planMessage = 'Plan skill content loaded' + + const ctx = buildCtx({ + gateway: { + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn((method: string) => { + if (method === 'slash.exec') { + return Promise.reject(new Error('pending-input command')) + } + + if (method === 'command.dispatch') { + return Promise.resolve({ type: 'send', message: planMessage }) + } + + return Promise.resolve({}) + }) + }, + rpc: vi.fn(() => Promise.resolve({})) + } + }) + + const h = createSlashHandler(ctx) + expect(h('/plan create a REST API')).toBe(true) + await vi.waitFor(() => { + expect(ctx.transcript.send).toHaveBeenCalledWith(planMessage) + }) + }) }) const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({ -
ui-tui/src/app/createSlashHandler.ts@@ -105,6 +105,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`) } + + if (d.type === 'send') { + return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`) + } }) .catch(guardedErr) }) -
ui-tui/src/app/useMainApp.ts@@ -1,4 +1,4 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -284,6 +284,13 @@ export function useMainApp(gw: GatewayClient) { useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) + // ── Terminal tab title ───────────────────────────────────────────── + // Show model name + status so users can identify the Hermes tab. + const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? '' + const titleStatus = ui.busy ? '⏳' : '✓' + const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes' + useTerminalTitle(terminalTitle) + useEffect(() => { if (!ui.sid || !stdout) { return -
ui-tui/src/components/markdown.tsx@@ -1,4 +1,4 @@ -import { Box, Text } from '@hermes/ink' +import { Box, Link, Text } from '@hermes/ink' import { memo, type ReactNode, useMemo } from 'react' import type { Theme } from '../theme.js' @@ -22,10 +22,12 @@ type Fence = { len: number } -const renderLink = (key: number, t: Theme, label: string) => ( - <Text color={t.color.amber} key={key} underline> - {label} - </Text> +const renderLink = (key: number, t: Theme, label: string, url: string) => ( + <Link key={key} url={url}> + <Text color={t.color.amber} underline> + {label} + </Text> + </Link> ) const trimBareUrl = (value: string) => { @@ -38,9 +40,11 @@ const trimBareUrl = (value: string) => { } const renderAutolink = (key: number, t: Theme, raw: string) => ( - <Text color={t.color.amber} key={key} underline> - {raw.replace(/^mailto:/, '')} - </Text> + <Link key={key} url={raw}> + <Text color={t.color.amber} underline> + {raw.replace(/^mailto:/, '')} + </Text> + </Link> ) const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2) @@ -141,7 +145,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { </Text> ) } else if (m[4] && m[5]) { - parts.push(renderLink(parts.length, t, m[4])) + parts.push(renderLink(parts.length, t, m[4], m[5])) } else if (m[6]) { parts.push(renderAutolink(parts.length, t, m[6])) } else if (m[7]) { -
ui-tui/src/components/messageLine.tsx@@ -28,12 +28,19 @@ export const MessageLine = memo(function MessageLine({ } if (msg.role === 'tool') { + const maxChars = Math.max(24, cols - 14) + const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text + const preview = compactPreview(stripped, maxChars) || '(empty tool result)' + return ( <Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}> - <Text color={t.color.dim} wrap="truncate-end"> - {compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || - '(empty tool result)'} - </Text> + {hasAnsi(msg.text) ? ( + <Text wrap="truncate-end"><Ansi>{msg.text}</Ansi></Text> + ) : ( + <Text color={t.color.dim} wrap="truncate-end"> + {preview} + </Text> + )} </Box> ) } -
ui-tui/src/gatewayTypes.ts@@ -47,6 +47,7 @@ export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' } | { message?: string; name: string; type: 'skill' } + | { message: string; type: 'send' } // ── Config ─────────────────────────────────────────────────────────── -
ui-tui/src/lib/rpc.ts@@ -26,6 +26,10 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined } } + if (t === 'send' && typeof o.message === 'string') { + return { type: 'send', message: o.message } + } + return null } -
ui-tui/src/types/hermes-ink.d.ts@@ -63,6 +63,7 @@ declare module '@hermes/ink' { export const Box: React.ComponentType<any> export const AlternateScreen: React.ComponentType<any> export const Ansi: React.ComponentType<any> + export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }> export const NoSelect: React.ComponentType<any> export const ScrollBox: React.ComponentType<any> export const Text: React.ComponentType<any> @@ -92,6 +93,7 @@ declare module '@hermes/ink' { export function useHasSelection(): boolean export function useStdout(): { readonly stdout?: NodeJS.WriteStream } export function useTerminalFocus(): boolean + export function useTerminalTitle(title: string | null): void export function useDeclaredCursor(args: { readonly line: number readonly column: number
-
-
#62859 fix(tui): dispatch custom skill bundles as agent turns
files:
tests/tui_gateway/test_protocol.py,tui_gateway/server.py -
#121077 fix(tui-gateway): never park an idle /steer on the agent
files:
tests/tui_gateway/test_tui_gateway_server.py,tui_gateway/methods_session.py,tui_gateway/methods_tools.py,ui-tui/src/__tests__/steerCommand.test.ts,ui-tui/src/app/slash/commands/core.ts
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.25: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab