From 7e780f4832ed8c34a23dd292b522df3e9705bd0a Mon Sep 17 00:00:00 2001 From: asheriif Date: Sun, 3 May 2026 14:50:00 +0000 Subject: [PATCH 1/2] fix(tui): run plugin slash commands live --- tests/tui_gateway/test_protocol.py | 30 ++++++++++++++++++++++++++++++ tui_gateway/server.py | 24 +++++++++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 2e54bb93ea..96df9823a6 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -391,6 +391,36 @@ def test_slash_exec_rejects_skill_commands(server): assert "skill command" in resp["error"]["message"] +def test_slash_exec_handles_plugin_commands_in_live_gateway(server): + """Plugin slash commands return normal slash.exec output without using the worker.""" + sid = "test-session" + + class Worker: + def __init__(self): + self.calls = [] + + def run(self, cmd): + self.calls.append(cmd) + return f"worker:{cmd}" + + worker = Worker() + server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker} + + with patch( + "hermes_cli.plugins.get_plugin_command_handler", + lambda name: (lambda arg: f"plugin:{arg}") if name == "plugin-cmd" else None, + ): + resp = server.handle_request({ + "id": "r-plugin-slash", + "method": "slash.exec", + "params": {"command": "plugin-cmd hello", "session_id": sid}, + }) + + assert "error" not in resp + assert resp["result"] == {"output": "plugin:hello"} + assert worker.calls == [] + + @pytest.mark.parametrize("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.""" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index fe66d3798d..c59d358d74 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5165,9 +5165,13 @@ def _(rid, params: dict) -> dict: 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. - _cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split() - _cmd_base = _cmd_parts[0] if _cmd_parts else "" + # slash worker — see _PENDING_INPUT_COMMANDS definition above. Plugin + # commands must also avoid the worker, but unlike skills/pending-input they + # still return normal slash.exec output so the TUI keeps the pager path. + _cmd_text = cmd.lstrip("/") if cmd.startswith("/") else cmd + _cmd_parts = _cmd_text.split(maxsplit=1) + _cmd_base = (_cmd_parts[0] if _cmd_parts else "").lower() + _cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else "" if _cmd_base in _PENDING_INPUT_COMMANDS: return _err( @@ -5185,6 +5189,20 @@ def _(rid, params: dict) -> dict: except Exception: pass + try: + from hermes_cli.plugins import ( + get_plugin_command_handler, + resolve_plugin_command_result, + ) + + if _cmd_base: + plugin_handler = get_plugin_command_handler(_cmd_base) + if plugin_handler: + result = resolve_plugin_command_result(plugin_handler(_cmd_arg)) + return _ok(rid, {"output": str(result or "(no output)")}) + except Exception as e: + return _err(rid, 4018, f"plugin command error: {e}") + worker = session.get("slash_worker") if not worker: try: From 21c7c9f0ca5f3c2fc5e1c64d4165879c004338a4 Mon Sep 17 00:00:00 2001 From: asheriif Date: Mon, 4 May 2026 09:07:37 +0000 Subject: [PATCH 2/2] fix(tui): harden plugin slash exec errors --- tests/tui_gateway/test_protocol.py | 63 ++++++++++++++++++++++++++++++ tui_gateway/server.py | 29 ++++++++------ 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 96df9823a6..a26a360a24 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -421,6 +421,69 @@ def test_slash_exec_handles_plugin_commands_in_live_gateway(server): assert worker.calls == [] +def test_slash_exec_plugin_lookup_failure_falls_back_to_worker(server): + """Plugin discovery failures must not break ordinary slash-worker commands.""" + sid = "test-session" + + class Worker: + def __init__(self): + self.calls = [] + + def run(self, cmd): + self.calls.append(cmd) + return f"worker:{cmd}" + + worker = Worker() + server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker} + + with patch( + "hermes_cli.plugins.get_plugin_command_handler", + side_effect=RuntimeError("discovery boom"), + ): + resp = server.handle_request({ + "id": "r-plugin-lookup-failure", + "method": "slash.exec", + "params": {"command": "help", "session_id": sid}, + }) + + assert "error" not in resp + assert resp["result"] == {"output": "worker:help"} + assert worker.calls == ["help"] + + +def test_slash_exec_plugin_handler_error_returns_output(server): + """Plugin handler failures return slash output so the TUI does not redispatch.""" + sid = "test-session" + + class Worker: + def __init__(self): + self.calls = [] + + def run(self, cmd): + self.calls.append(cmd) + return f"worker:{cmd}" + + def handler(arg): + raise RuntimeError(f"handler boom: {arg}") + + worker = Worker() + server._sessions[sid] = {"session_key": sid, "agent": None, "slash_worker": worker} + + with patch( + "hermes_cli.plugins.get_plugin_command_handler", + lambda name: handler if name == "plugin-cmd" else None, + ): + resp = server.handle_request({ + "id": "r-plugin-handler-error", + "method": "slash.exec", + "params": {"command": "plugin-cmd hello", "session_id": sid}, + }) + + assert "error" not in resp + assert resp["result"] == {"output": "Plugin command error: handler boom: hello"} + assert worker.calls == [] + + @pytest.mark.parametrize("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.""" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c59d358d74..fff8c8d51a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5189,19 +5189,26 @@ def _(rid, params: dict) -> dict: except Exception: pass - try: - from hermes_cli.plugins import ( - get_plugin_command_handler, - resolve_plugin_command_result, - ) + plugin_handler = None + resolve_plugin_command_result = None + if _cmd_base: + try: + from hermes_cli.plugins import ( + get_plugin_command_handler, + resolve_plugin_command_result, + ) - if _cmd_base: plugin_handler = get_plugin_command_handler(_cmd_base) - if plugin_handler: - result = resolve_plugin_command_result(plugin_handler(_cmd_arg)) - return _ok(rid, {"output": str(result or "(no output)")}) - except Exception as e: - return _err(rid, 4018, f"plugin command error: {e}") + except Exception: + plugin_handler = None + resolve_plugin_command_result = None + + if plugin_handler and resolve_plugin_command_result: + try: + result = resolve_plugin_command_result(plugin_handler(_cmd_arg)) + return _ok(rid, {"output": str(result or "(no output)")}) + except Exception as e: + return _ok(rid, {"output": f"Plugin command error: {e}"}) worker = session.get("slash_worker") if not worker: