diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8a43b23745c88..55e5b758f5656 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12398,6 +12398,14 @@ def cmd_dashboard(args): exc_info=True, ) + # The desktop app and remote backends run agent turns through serve's + # in-process /api/ws gateway (and the dashboard's Chat tab), so lifecycle + # hooks must be wired on this path too. _prepare_agent_startup's + # _AGENT_COMMANDS gate deliberately excludes serve/dashboard (they are not + # interactive agent-entrypoint commands), which means hooks would never + # register here otherwise. + _register_shell_hooks(accept_hooks=False) + from hermes_cli.web_server import start_server # Interactive auth setup: if this bind will engage the auth gate but no @@ -12651,6 +12659,35 @@ def _should_background_mcp_startup(args) -> bool: return args.command in {None, "chat", "rl"} +def _register_shell_hooks(accept_hooks: bool = False) -> None: + """Register configured shell + outbound webhooks (idempotent). + + Shared by ``_prepare_agent_startup`` (interactive agent entrypoints) and + ``cmd_dashboard`` (the headless ``serve`` backend and the dashboard), so + lifecycle hooks fire regardless of how the agent turn is hosted. ``serve`` + is deliberately absent from ``_AGENT_COMMANDS`` (it is not an interactive + agent-entrypoint command), but it still hosts agent turns via its + in-process ``/api/ws`` gateway and must therefore wire hooks itself. + """ + try: + from hermes_cli.config import load_config + from agent.shell_hooks import register_from_config + + _hooks_cfg = load_config() + register_from_config(_hooks_cfg, accept_hooks=accept_hooks) + + from agent.outbound_webhooks import ( + register_from_config as register_outbound_webhooks, + ) + + register_outbound_webhooks(_hooks_cfg) + except Exception: + logger.debug( + "shell-hook registration failed", + exc_info=True, + ) + + def _prepare_agent_startup(args) -> None: """Discover plugins/MCP/hooks for commands that can run an agent turn.""" # --yolo: chokepoint guarantee that HERMES_YOLO_MODE is set before ANY @@ -12729,23 +12766,7 @@ def _prepare_agent_startup(args) -> None: "MCP tool discovery failed at CLI startup", exc_info=True, ) - try: - from hermes_cli.config import load_config - from agent.shell_hooks import register_from_config - - _hooks_cfg = load_config() - register_from_config(_hooks_cfg, accept_hooks=_accept_hooks) - - from agent.outbound_webhooks import ( - register_from_config as register_outbound_webhooks, - ) - - register_outbound_webhooks(_hooks_cfg) - except Exception: - logger.debug( - "shell-hook registration failed at CLI startup", - exc_info=True, - ) + _register_shell_hooks(accept_hooks=_accept_hooks) def _apply_safe_mode(args) -> None: diff --git a/tests/hermes_cli/test_dashboard_shell_hooks.py b/tests/hermes_cli/test_dashboard_shell_hooks.py new file mode 100644 index 0000000000000..9257c3df74e45 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_shell_hooks.py @@ -0,0 +1,102 @@ +"""serve/dashboard must register shell hooks so lifecycle hooks (mnemon +prime/remind/nudge, …) fire on the desktop/headless agent host. + +Regression: the shell-hook registration in ``_prepare_agent_startup`` is gated +on ``_AGENT_COMMANDS = {None, chat, acp, rl}`` (+ cron/gateway/mcp subcommands), +which excludes ``serve``/``dashboard``. The desktop app runs agent turns through +serve's in-process ``/api/ws`` gateway, so hooks were never registered there and +lifecycle hooks silently stopped firing after an upgrade dropped the earlier +local patch. +""" + +from __future__ import annotations + +import types + +import pytest + + +def _args(**kw): + defaults = dict( + status=False, + stop=False, + host="127.0.0.1", + port=9119, + no_open=True, + insecure=False, + skip_build=False, + isolated=False, + open_profile="", + headless_backend=True, + ) + defaults.update(kw) + return types.SimpleNamespace(**defaults) + + +@pytest.fixture +def main_mod(): + import hermes_cli.main as main_mod + + return main_mod + + +def _neutralize_startup(main_mod, monkeypatch): + """Mock the expensive/blocking cmd_dashboard steps so the test reaches the + hook-registration call without a real web server, web build, or config.""" + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + monkeypatch.setattr( + "hermes_cli.config.require_parseable_user_config", lambda **kw: None + ) + monkeypatch.setattr("hermes_cli.plugins.discover_plugins", lambda: None) + monkeypatch.setattr( + "hermes_cli.mcp_startup.start_background_mcp_discovery", lambda **kw: None + ) + import hermes_cli.web_server as web_server + + monkeypatch.setattr(web_server, "start_server", lambda **kw: None) + monkeypatch.setattr( + main_mod, "_maybe_setup_dashboard_auth_interactively", lambda args: None + ) + + +def test_serve_registers_shell_hooks(main_mod, monkeypatch): + """Headless serve must register hooks before starting the server.""" + _neutralize_startup(main_mod, monkeypatch) + + calls = [] + monkeypatch.setattr( + main_mod, + "_register_shell_hooks", + lambda accept_hooks=False: calls.append(accept_hooks), + ) + + main_mod.cmd_dashboard(_args()) + + assert calls == [False] # headless: never auto-accept hooks + + +def test_register_shell_hooks_wires_shell_and_outbound(monkeypatch): + """The shared helper forwards to both shell-hook and outbound-webhook + registrars with the caller's accept_hooks flag.""" + import hermes_cli.main as main_mod + + shell_calls = [] + outbound_calls = [] + + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"hooks": {}}) + monkeypatch.setattr( + "agent.shell_hooks.register_from_config", + lambda cfg, accept_hooks=False: shell_calls.append((cfg, accept_hooks)), + ) + monkeypatch.setattr( + "agent.outbound_webhooks.register_from_config", + lambda cfg: outbound_calls.append(cfg), + ) + + main_mod._register_shell_hooks(accept_hooks=True) + + assert shell_calls == [({"hooks": {}}, True)] + assert outbound_calls == [{"hooks": {}}]