diff --git a/cli.py b/cli.py index d76ce01bc3..d045a4e52d 100644 --- a/cli.py +++ b/cli.py @@ -6582,12 +6582,17 @@ class HermesCLI: self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]") # Check for plugin-registered slash commands elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names(): - from hermes_cli.plugins import get_plugin_command_handler + from hermes_cli.plugins import ( + get_plugin_command_handler, + resolve_plugin_command_result, + ) plugin_handler = get_plugin_command_handler(base_cmd.lstrip("/")) if plugin_handler: user_args = cmd_original[len(base_cmd):].strip() try: - result = plugin_handler(user_args) + result = resolve_plugin_command_result( + plugin_handler(user_args) + ) if result: _cprint(str(result)) except Exception as e: diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index d7913eb9b5..3fff37bb08 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -33,12 +33,15 @@ so plugin-defined tools appear alongside the built-in tools. from __future__ import annotations +import asyncio import importlib import importlib.metadata import importlib.util +import inspect import logging import os import sys +import threading import types from dataclasses import dataclass, field from pathlib import Path @@ -1226,6 +1229,46 @@ def get_plugin_command_handler(name: str) -> Optional[Callable]: return entry["handler"] if entry else None +def resolve_plugin_command_result(result: Any) -> Any: + """Resolve a plugin command return value, awaiting async handlers when needed. + + Sync CLI/TUI dispatch sites call plugin handlers from plain functions. + If a handler is async, await it directly when no loop is running; if + we're already inside an active loop, run it in a helper thread with its + own loop so the caller still gets a concrete result synchronously. + """ + if not inspect.isawaitable(result): + return result + + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(result) + + outcome: Dict[str, Any] = {} + failure: Dict[str, BaseException] = {} + done = threading.Event() + + def _runner() -> None: + try: + outcome["value"] = asyncio.run(result) + except BaseException as exc: # pragma: no cover - re-raised below + failure["exc"] = exc + finally: + done.set() + + thread = threading.Thread( + target=_runner, + name="hermes-plugin-command-await", + daemon=True, + ) + thread.start() + done.wait() + if "exc" in failure: + raise failure["exc"] + return outcome.get("value") + + def get_plugin_commands() -> Dict[str, dict]: """Return the full plugin commands dict (name → {handler, description, plugin}). diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 157f967e52..9a46cf5951 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -21,6 +21,7 @@ from hermes_cli.plugins import ( get_plugin_command_handler, get_plugin_commands, get_pre_tool_call_block_message, + resolve_plugin_command_result, discover_plugins, invoke_hook, ) @@ -1061,6 +1062,27 @@ class TestPluginCommands: assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b" +class TestPluginCommandResultResolution: + def test_returns_sync_values_unchanged(self): + assert resolve_plugin_command_result("ok") == "ok" + + def test_awaits_async_result_without_running_loop(self): + async def _handler(): + return "async-ok" + + assert resolve_plugin_command_result(_handler()) == "async-ok" + + def test_awaits_async_result_with_running_loop(self, monkeypatch): + class _Loop: + pass + + async def _handler(): + return "threaded-ok" + + monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop()) + assert resolve_plugin_command_result(_handler()) == "threaded-ok" + + # ── TestPluginDispatchTool ──────────────────────────────────────────────── diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index bd527608a7..2e54bb93ea 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -594,6 +594,24 @@ def test_command_dispatch_returns_skill_payload(server): assert result["name"] == "hermes-agent-dev" +def test_command_dispatch_awaits_async_plugin_handler(server): + async def _handler(arg): + return f"async:{arg}" + + with patch( + "hermes_cli.plugins.get_plugin_command_handler", + lambda name: _handler if name == "async-cmd" else None, + ): + resp = server.handle_request({ + "id": "r-plugin", + "method": "command.dispatch", + "params": {"name": "async-cmd", "arg": "hello"}, + }) + + assert "error" not in resp + assert resp["result"] == {"type": "plugin", "output": "async:hello"} + + # ── dispatch(): pool routing for long handlers (#12546) ────────────── diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f503549511..84b89a437c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4115,11 +4115,15 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) try: - from hermes_cli.plugins import get_plugin_command_handler + from hermes_cli.plugins import ( + get_plugin_command_handler, + resolve_plugin_command_result, + ) handler = get_plugin_command_handler(name) if handler: - return _ok(rid, {"type": "plugin", "output": str(handler(arg) or "")}) + result = resolve_plugin_command_result(handler(arg)) + return _ok(rid, {"type": "plugin", "output": str(result or "")}) except Exception: pass