diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 6ee5fe65b..77cd7b167 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -231,3 +231,51 @@ 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.scan_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"] + + +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" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a7dae9e5c..45c95a6da 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2333,6 +2333,19 @@ def _(rid, params: dict) -> dict: if not cmd: return _err(rid, 4004, "empty command") + # Skill slash commands (e.g. /hermes-agent-dev) must NOT go through the + # slash worker — process_command() queues the skill payload onto + # _pending_input which nobody reads in the worker subprocess. Reject + # here so the TUI falls through to command.dispatch which handles skills + # correctly (builds the invocation message and returns it to the client). + try: + from agent.skill_commands import scan_skill_commands + _cmd_key = f"/{cmd.split()[0]}" if not cmd.startswith("/") else f"/{cmd.lstrip('/').split()[0]}" + if _cmd_key in scan_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: diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 9e1db9946..a8f050a27 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -121,6 +121,37 @@ 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) + }) }) const buildCtx = (overrides: Partial = {}): Ctx => ({ diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 865ab8579..4555c8505 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/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) => ( - - {label} - +const renderLink = (key: number, t: Theme, label: string, url: string) => ( + + + {label} + + ) const trimBareUrl = (value: string) => { @@ -38,9 +40,11 @@ const trimBareUrl = (value: string) => { } const renderAutolink = (key: number, t: Theme, raw: string) => ( - - {raw.replace(/^mailto:/, '')} - + + + {raw.replace(/^mailto:/, '')} + + ) const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2) @@ -141,7 +145,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } 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]) { diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 9b2deec35..faab71ae9 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -63,6 +63,7 @@ declare module '@hermes/ink' { export const Box: React.ComponentType export const AlternateScreen: React.ComponentType export const Ansi: React.ComponentType + export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }> export const NoSelect: React.ComponentType export const ScrollBox: React.ComponentType export const Text: React.ComponentType