Initial import of NousResearch/hermes-agent
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker / shell lint / Lint Dockerfile (hadolint) (push) Has been cancelled
Docker / shell lint / Lint docker/ shell scripts (shellcheck) (push) Has been cancelled
Docker Build and Publish / build-amd64 (push) Has been cancelled
Docker Build and Publish / build-arm64 (push) Has been cancelled
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Nix Lockfile Fix / auto-fix-main (push) Has been cancelled
Nix Lockfile Fix / fix (push) Has been cancelled
Nix / nix (macos-latest) (push) Has been cancelled
Nix / nix (ubuntu-latest) (push) Has been cancelled
OSV-Scanner / Scan lockfiles (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"""Tests for tui_gateway/entry.py sys.path hardening (issue #15989).
|
||||
|
||||
When the TUI backend is spawned by Node.js, the Python interpreter may have
|
||||
'' or '.' at the front of sys.path, allowing a local utils/ directory in CWD
|
||||
to shadow the installed utils module. entry.py must sanitize sys.path before
|
||||
any non-stdlib import is resolved.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _reload_entry_with_env(env_overrides: dict) -> None:
|
||||
"""Re-execute entry.py's module-level path setup under a controlled env."""
|
||||
# We only want to exercise the sys.path fixup block, not the signal/import
|
||||
# machinery that follows. We do this by running the fixup code verbatim in
|
||||
# a fresh copy of sys.path rather than importing the real module (which
|
||||
# would trigger tui_gateway.server imports requiring heavy mocks).
|
||||
original_path = sys.path[:]
|
||||
original_env = {k: os.environ.get(k) for k in env_overrides}
|
||||
try:
|
||||
with patch.dict(os.environ, env_overrides, clear=False):
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
sys.path = [p for p in sys.path if p not in {"", "."}]
|
||||
return sys.path[:]
|
||||
finally:
|
||||
sys.path = original_path
|
||||
for k, v in original_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def test_empty_string_and_dot_removed_from_sys_path():
|
||||
original = sys.path[:]
|
||||
try:
|
||||
sys.path.insert(0, "")
|
||||
sys.path.insert(0, ".")
|
||||
assert "" in sys.path
|
||||
assert "." in sys.path
|
||||
|
||||
# Run the entry.py fixup logic directly
|
||||
sys.path = [p for p in sys.path if p not in {"", "."}]
|
||||
|
||||
assert "" not in sys.path
|
||||
assert "." not in sys.path
|
||||
finally:
|
||||
sys.path = original
|
||||
|
||||
|
||||
def test_hermes_src_root_inserted_at_front():
|
||||
original = sys.path[:]
|
||||
try:
|
||||
fake_root = "/fake/hermes/src"
|
||||
with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}):
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
sys.path = [p for p in sys.path if p not in {"", "."}]
|
||||
|
||||
assert sys.path[0] == fake_root
|
||||
finally:
|
||||
sys.path = original
|
||||
|
||||
|
||||
def test_src_root_not_duplicated_if_already_present():
|
||||
original = sys.path[:]
|
||||
try:
|
||||
fake_root = "/already/present"
|
||||
sys.path.insert(0, fake_root)
|
||||
count_before = sys.path.count(fake_root)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}):
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
sys.path = [p for p in sys.path if p not in {"", "."}]
|
||||
|
||||
assert sys.path.count(fake_root) == count_before
|
||||
finally:
|
||||
sys.path = original
|
||||
|
||||
|
||||
def test_no_src_root_env_does_not_crash():
|
||||
original = sys.path[:]
|
||||
try:
|
||||
env = {k: v for k, v in os.environ.items() if k != "HERMES_PYTHON_SRC_ROOT"}
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.update(env)
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
sys.path = [p for p in sys.path if p not in {"", "."}]
|
||||
# No exception raised
|
||||
finally:
|
||||
sys.path = original
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for /goal handling in tui_gateway.
|
||||
|
||||
The TUI routes ``/goal`` through ``command.dispatch`` (not ``slash.exec``)
|
||||
because the CLI's ``_handle_goal_command`` queues the kickoff message onto
|
||||
``_pending_input``, which the slash-worker subprocess has no reader for.
|
||||
Instead we handle ``/goal`` directly in the server and return a
|
||||
``{"type": "send", "notice": ..., "message": ...}`` payload the TUI client
|
||||
uses to render a system line and fire the kickoff prompt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
# Bust the goal-module DB cache so it re-resolves HERMES_HOME.
|
||||
from hermes_cli import goals
|
||||
|
||||
goals._DB_CACHE.clear()
|
||||
yield home
|
||||
goals._DB_CACHE.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(hermes_home):
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"hermes_cli.env_loader": MagicMock(),
|
||||
"hermes_cli.banner": MagicMock(),
|
||||
},
|
||||
):
|
||||
mod = importlib.import_module("tui_gateway.server")
|
||||
yield mod
|
||||
# Reset module-level session state without re-importing. importlib.reload
|
||||
# would re-register the module's atexit hooks (ThreadPoolExecutor
|
||||
# shutdown, _shutdown_sessions); the duplicates race the stderr
|
||||
# buffer at interpreter shutdown and surface as Fatal Python error:
|
||||
# _enter_buffered_busy. Clearing the per-session dicts gives the
|
||||
# next test a clean slate; _methods is NOT cleared because it's
|
||||
# populated at module import time and re-registration only happens
|
||||
# via reload (which we don't do).
|
||||
mod._sessions.clear()
|
||||
mod._pending.clear()
|
||||
mod._answers.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(server):
|
||||
sid = "sid-test"
|
||||
session_key = "tui-goal-session-1"
|
||||
s = {
|
||||
"session_key": session_key,
|
||||
"history": [],
|
||||
"history_lock": threading.Lock(),
|
||||
"history_version": 0,
|
||||
"running": False,
|
||||
"attached_images": [],
|
||||
"cols": 120,
|
||||
}
|
||||
server._sessions[sid] = s
|
||||
return sid, session_key, s
|
||||
|
||||
|
||||
def _call(server, method, **params):
|
||||
handler = server._methods[method]
|
||||
return handler(1, params)
|
||||
|
||||
|
||||
# ── command.dispatch /goal ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_goal_bare_shows_status_when_none_set(server, session):
|
||||
sid, _, _ = session
|
||||
r = _call(server, "command.dispatch", name="goal", arg="", session_id=sid)
|
||||
assert r["result"]["type"] == "exec"
|
||||
assert "No active goal" in r["result"]["output"]
|
||||
|
||||
|
||||
def test_goal_whitespace_only_shows_status(server, session):
|
||||
sid, _, _ = session
|
||||
r = _call(server, "command.dispatch", name="goal", arg=" ", session_id=sid)
|
||||
assert r["result"]["type"] == "exec"
|
||||
assert "No active goal" in r["result"]["output"]
|
||||
|
||||
|
||||
def test_goal_status_alias_shows_status(server, session):
|
||||
sid, _, _ = session
|
||||
r = _call(server, "command.dispatch", name="goal", arg="status", session_id=sid)
|
||||
assert r["result"]["type"] == "exec"
|
||||
assert "No active goal" in r["result"]["output"]
|
||||
|
||||
|
||||
def test_goal_set_returns_send_with_notice(server, session):
|
||||
sid, session_key, _ = session
|
||||
r = _call(server, "command.dispatch", name="goal", arg="build a rocket", session_id=sid)
|
||||
result = r["result"]
|
||||
assert result["type"] == "send"
|
||||
assert result["message"] == "build a rocket"
|
||||
assert "notice" in result
|
||||
assert "Goal set" in result["notice"]
|
||||
assert "20-turn budget" in result["notice"]
|
||||
|
||||
# Persisted in SessionDB
|
||||
from hermes_cli.goals import GoalManager
|
||||
|
||||
mgr = GoalManager(session_key)
|
||||
assert mgr.state is not None
|
||||
assert mgr.state.goal == "build a rocket"
|
||||
assert mgr.state.status == "active"
|
||||
|
||||
|
||||
def test_goal_pause_after_set(server, session):
|
||||
sid, session_key, _ = session
|
||||
_call(server, "command.dispatch", name="goal", arg="write a story", session_id=sid)
|
||||
r = _call(server, "command.dispatch", name="goal", arg="pause", session_id=sid)
|
||||
assert r["result"]["type"] == "exec"
|
||||
assert "paused" in r["result"]["output"].lower()
|
||||
|
||||
from hermes_cli.goals import GoalManager
|
||||
|
||||
assert GoalManager(session_key).state.status == "paused"
|
||||
|
||||
|
||||
def test_goal_resume_reactivates(server, session):
|
||||
sid, session_key, _ = session
|
||||
_call(server, "command.dispatch", name="goal", arg="write a story", session_id=sid)
|
||||
_call(server, "command.dispatch", name="goal", arg="pause", session_id=sid)
|
||||
r = _call(server, "command.dispatch", name="goal", arg="resume", session_id=sid)
|
||||
assert r["result"]["type"] == "exec"
|
||||
assert "resumed" in r["result"]["output"].lower()
|
||||
|
||||
from hermes_cli.goals import GoalManager
|
||||
|
||||
assert GoalManager(session_key).state.status == "active"
|
||||
|
||||
|
||||
def test_goal_clear_removes_active_goal(server, session):
|
||||
sid, session_key, _ = session
|
||||
_call(server, "command.dispatch", name="goal", arg="write a story", session_id=sid)
|
||||
r = _call(server, "command.dispatch", name="goal", arg="clear", session_id=sid)
|
||||
assert r["result"]["type"] == "exec"
|
||||
assert "cleared" in r["result"]["output"].lower()
|
||||
|
||||
from hermes_cli.goals import GoalManager
|
||||
|
||||
# After clear the row is marked status=cleared (kept for audit);
|
||||
# ``has_goal()`` / ``is_active()`` return False so the goal loop
|
||||
# stays off and ``status`` reports "No active goal".
|
||||
mgr = GoalManager(session_key)
|
||||
assert not mgr.has_goal()
|
||||
assert not mgr.is_active()
|
||||
assert "No active goal" in mgr.status_line()
|
||||
|
||||
|
||||
def test_goal_stop_and_done_are_clear_aliases(server, session):
|
||||
sid, _, _ = session
|
||||
_call(server, "command.dispatch", name="goal", arg="first goal", session_id=sid)
|
||||
r = _call(server, "command.dispatch", name="goal", arg="stop", session_id=sid)
|
||||
assert "cleared" in r["result"]["output"].lower()
|
||||
|
||||
_call(server, "command.dispatch", name="goal", arg="second goal", session_id=sid)
|
||||
r = _call(server, "command.dispatch", name="goal", arg="done", session_id=sid)
|
||||
assert "cleared" in r["result"]["output"].lower()
|
||||
|
||||
|
||||
def test_goal_requires_session(server):
|
||||
r = _call(server, "command.dispatch", name="goal", arg="nope", session_id="unknown")
|
||||
assert "error" in r
|
||||
assert r["error"]["code"] == 4001
|
||||
|
||||
|
||||
# ── slash.exec /goal routing ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_slash_exec_rejects_goal_routes_to_command_dispatch(server, session):
|
||||
"""slash.exec must reject /goal with 4018 so the TUI client falls through
|
||||
to command.dispatch. Without this, the HermesCLI slash-worker subprocess
|
||||
would set the goal but silently drop the kickoff — the queue is in-proc."""
|
||||
sid, _, _ = session
|
||||
r = _call(server, "slash.exec", command="goal status", session_id=sid)
|
||||
assert "error" in r
|
||||
assert r["error"]["code"] == 4018
|
||||
assert "command.dispatch" in r["error"]["message"]
|
||||
|
||||
|
||||
def test_pending_input_commands_includes_goal(server):
|
||||
"""Guard: _PENDING_INPUT_COMMANDS must list 'goal' — removing it would
|
||||
silently re-break the TUI."""
|
||||
assert "goal" in server._PENDING_INPUT_COMMANDS
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Regression test for #11884: _make_agent must resolve runtime provider.
|
||||
|
||||
Without resolve_runtime_provider(), bare-slug models in config
|
||||
(e.g. ``claude-opus-4-6`` with ``model.provider: anthropic``) leave
|
||||
provider/base_url/api_key empty in AIAgent, causing HTTP 404.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_make_agent_passes_resolved_provider():
|
||||
"""_make_agent forwards provider/base_url/api_key/api_mode from
|
||||
resolve_runtime_provider to AIAgent."""
|
||||
|
||||
fake_runtime = {
|
||||
"provider": "anthropic",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
"api_key": "sk-test-key",
|
||||
"api_mode": "anthropic_messages",
|
||||
"command": None,
|
||||
"args": None,
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
fake_cfg = {
|
||||
"model": {"default": "claude-opus-4-6", "provider": "anthropic"},
|
||||
"agent": {"system_prompt": "test"},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
|
||||
patch("tui_gateway.server._get_db", return_value=MagicMock()),
|
||||
patch("tui_gateway.server._load_tool_progress_mode", return_value="compact"),
|
||||
patch("tui_gateway.server._load_reasoning_config", return_value=None),
|
||||
patch("tui_gateway.server._load_service_tier", return_value=None),
|
||||
patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=fake_runtime,
|
||||
) as mock_resolve,
|
||||
patch("run_agent.AIAgent") as mock_agent,
|
||||
):
|
||||
|
||||
from tui_gateway.server import _make_agent
|
||||
|
||||
_make_agent("sid-1", "key-1")
|
||||
|
||||
# target_model comes from _resolve_startup_runtime() which reads
|
||||
# _load_cfg(). Due to module-level caching in tui_gateway.server,
|
||||
# the patched config may not take effect when the module was already
|
||||
# imported by an earlier test. Assert the stable part of the call.
|
||||
mock_resolve.assert_called_once()
|
||||
assert mock_resolve.call_args.kwargs.get("requested") is None
|
||||
|
||||
call_kwargs = mock_agent.call_args
|
||||
assert call_kwargs.kwargs["provider"] == "anthropic"
|
||||
assert call_kwargs.kwargs["base_url"] == "https://api.anthropic.com"
|
||||
assert call_kwargs.kwargs["api_key"] == "sk-test-key"
|
||||
assert call_kwargs.kwargs["api_mode"] == "anthropic_messages"
|
||||
|
||||
|
||||
def test_make_agent_ignores_display_personality_without_system_prompt():
|
||||
"""The TUI matches the classic CLI: personality only becomes active once
|
||||
it has been saved to agent.system_prompt."""
|
||||
|
||||
fake_runtime = {
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://api.synthetic.new/v1",
|
||||
"api_key": "sk-test",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": None,
|
||||
"credential_pool": None,
|
||||
}
|
||||
fake_cfg = {
|
||||
"agent": {
|
||||
"system_prompt": "",
|
||||
"personalities": {"kawaii": "sparkle system prompt"},
|
||||
},
|
||||
"display": {"personality": "kawaii"},
|
||||
"model": {"default": "glm-5"},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
|
||||
patch("tui_gateway.server._get_db", return_value=MagicMock()),
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=fake_runtime,
|
||||
),
|
||||
patch("run_agent.AIAgent") as mock_agent,
|
||||
):
|
||||
from tui_gateway.server import _make_agent
|
||||
|
||||
_make_agent("sid-default-personality", "key-default-personality")
|
||||
|
||||
assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None
|
||||
|
||||
|
||||
def test_make_agent_honors_tui_launch_env_flags():
|
||||
fake_runtime = {
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://api.synthetic.new/v1",
|
||||
"api_key": "sk-test",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": None,
|
||||
"credential_pool": None,
|
||||
}
|
||||
fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "glm-5"}}
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HERMES_TUI_MAX_TURNS": "7",
|
||||
"HERMES_TUI_CHECKPOINTS": "1",
|
||||
"HERMES_TUI_PASS_SESSION_ID": "1",
|
||||
"HERMES_IGNORE_RULES": "1",
|
||||
},
|
||||
),
|
||||
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
|
||||
patch("tui_gateway.server._get_db", return_value=MagicMock()),
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=fake_runtime,
|
||||
),
|
||||
patch("run_agent.AIAgent") as mock_agent,
|
||||
):
|
||||
from tui_gateway.server import _make_agent
|
||||
|
||||
_make_agent("sid-env", "key-env")
|
||||
|
||||
kwargs = mock_agent.call_args.kwargs
|
||||
assert kwargs["max_iterations"] == 7
|
||||
assert kwargs["checkpoints_enabled"] is True
|
||||
assert kwargs["pass_session_id"] is True
|
||||
assert kwargs["skip_context_files"] is True
|
||||
assert kwargs["skip_memory"] is True
|
||||
|
||||
|
||||
def test_probe_config_health_flags_null_sections():
|
||||
"""Bare YAML keys (`agent:` with no value) parse as None and silently
|
||||
drop nested settings; probe must surface them so users can fix."""
|
||||
from tui_gateway.server import _probe_config_health
|
||||
|
||||
assert _probe_config_health({"agent": {"x": 1}}) == ""
|
||||
assert _probe_config_health({}) == ""
|
||||
|
||||
msg = _probe_config_health({"agent": None, "display": None, "model": {}})
|
||||
assert "agent" in msg and "display" in msg
|
||||
assert "model" not in msg
|
||||
|
||||
|
||||
def test_probe_config_health_flags_null_personalities_with_active_personality():
|
||||
from tui_gateway.server import _probe_config_health
|
||||
|
||||
msg = _probe_config_health(
|
||||
{
|
||||
"agent": {"personalities": None},
|
||||
"display": {"personality": "kawaii"},
|
||||
"model": {},
|
||||
}
|
||||
)
|
||||
assert "display.personality" in msg
|
||||
assert "agent.personalities" in msg
|
||||
|
||||
|
||||
def test_make_agent_tolerates_null_config_sections():
|
||||
"""Bare `agent:` / `display:` keys in ~/.hermes/config.yaml parse as
|
||||
None. cfg.get("agent", {}) returns None (default only fires on missing
|
||||
key), so downstream .get() chains must be guarded. Reported via Twitter
|
||||
against the new TUI."""
|
||||
|
||||
fake_runtime = {
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://api.synthetic.new/v1",
|
||||
"api_key": "sk-test",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": None,
|
||||
"credential_pool": None,
|
||||
}
|
||||
null_cfg = {"agent": None, "display": None, "model": {"default": "glm-5"}}
|
||||
|
||||
with (
|
||||
patch("tui_gateway.server._load_cfg", return_value=null_cfg),
|
||||
patch("tui_gateway.server._get_db", return_value=MagicMock()),
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=fake_runtime,
|
||||
),
|
||||
patch("run_agent.AIAgent") as mock_agent,
|
||||
):
|
||||
|
||||
from tui_gateway.server import _make_agent
|
||||
|
||||
_make_agent("sid-null", "key-null")
|
||||
|
||||
assert mock_agent.called
|
||||
|
||||
|
||||
def test_make_agent_tolerates_null_personalities_with_active_personality():
|
||||
fake_runtime = {
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://api.synthetic.new/v1",
|
||||
"api_key": "sk-test",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": None,
|
||||
"credential_pool": None,
|
||||
}
|
||||
cfg = {
|
||||
"agent": {"personalities": None},
|
||||
"display": {"personality": "kawaii"},
|
||||
"model": {"default": "glm-5"},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("tui_gateway.server._load_cfg", return_value=cfg),
|
||||
patch("tui_gateway.server._get_db", return_value=MagicMock()),
|
||||
patch("cli.load_cli_config", return_value={"agent": {"personalities": None}}),
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=fake_runtime,
|
||||
),
|
||||
patch("run_agent.AIAgent") as mock_agent,
|
||||
):
|
||||
from tui_gateway.server import _make_agent
|
||||
|
||||
_make_agent("sid-null-personality", "key-null-personality")
|
||||
|
||||
assert mock_agent.called
|
||||
assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None
|
||||
@@ -0,0 +1,812 @@
|
||||
"""Tests for tui_gateway JSON-RPC protocol plumbing."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_original_stdout = sys.stdout
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_stdout():
|
||||
yield
|
||||
sys.stdout = _original_stdout
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server():
|
||||
with patch.dict("sys.modules", {
|
||||
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
|
||||
"hermes_cli.env_loader": MagicMock(),
|
||||
"hermes_cli.banner": MagicMock(),
|
||||
"hermes_state": MagicMock(),
|
||||
}):
|
||||
import importlib
|
||||
mod = importlib.import_module("tui_gateway.server")
|
||||
yield mod
|
||||
# Reset module-level session state without re-importing. importlib.reload
|
||||
# would re-register the module's atexit hooks (ThreadPoolExecutor
|
||||
# shutdown, _shutdown_sessions); the duplicates race the stderr
|
||||
# buffer at interpreter shutdown and surface as Fatal Python error:
|
||||
# _enter_buffered_busy. Clearing the per-session dicts gives the
|
||||
# next test a clean slate; _methods is NOT cleared because it's
|
||||
# populated at module import time and re-registration only happens
|
||||
# via reload (which we don't do).
|
||||
mod._sessions.clear()
|
||||
mod._pending.clear()
|
||||
mod._answers.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def capture(server):
|
||||
"""Redirect server's real stdout to a StringIO and return (server, buf)."""
|
||||
buf = io.StringIO()
|
||||
server._real_stdout = buf
|
||||
return server, buf
|
||||
|
||||
|
||||
# ── JSON-RPC envelope ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unknown_method(server):
|
||||
resp = server.handle_request({"id": "1", "method": "bogus"})
|
||||
assert resp["error"]["code"] == -32601
|
||||
|
||||
|
||||
def test_ok_envelope(server):
|
||||
assert server._ok("r1", {"x": 1}) == {
|
||||
"jsonrpc": "2.0", "id": "r1", "result": {"x": 1},
|
||||
}
|
||||
|
||||
|
||||
def test_err_envelope(server):
|
||||
assert server._err("r2", 4001, "nope") == {
|
||||
"jsonrpc": "2.0", "id": "r2", "error": {"code": 4001, "message": "nope"},
|
||||
}
|
||||
|
||||
|
||||
# ── write_json ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_write_json(capture):
|
||||
server, buf = capture
|
||||
assert server.write_json({"test": True})
|
||||
assert json.loads(buf.getvalue()) == {"test": True}
|
||||
|
||||
|
||||
def test_write_json_broken_pipe(server):
|
||||
class _Broken:
|
||||
def write(self, _): raise BrokenPipeError
|
||||
def flush(self): raise BrokenPipeError
|
||||
|
||||
server._real_stdout = _Broken()
|
||||
assert server.write_json({"x": 1}) is False
|
||||
|
||||
|
||||
def test_write_json_closed_stream_returns_false(server):
|
||||
"""ValueError ('I/O on closed file') used to bubble up; treat as gone."""
|
||||
|
||||
class _Closed:
|
||||
def write(self, _): raise ValueError("I/O operation on closed file")
|
||||
def flush(self): raise ValueError("I/O operation on closed file")
|
||||
|
||||
server._real_stdout = _Closed()
|
||||
assert server.write_json({"x": 1}) is False
|
||||
|
||||
|
||||
def test_write_json_unicode_encode_error_re_raises(server):
|
||||
"""A non-UTF-8 stdout encoding raises UnicodeEncodeError (a ValueError
|
||||
subclass). It must NOT be swallowed as 'peer gone' — that would let
|
||||
`entry.py` exit cleanly via the False path and hide the real config
|
||||
bug. We re-raise so the existing crash-log infrastructure records it."""
|
||||
|
||||
class _AsciiOnly:
|
||||
def write(self, line):
|
||||
line.encode("ascii") # raises UnicodeEncodeError on non-ascii
|
||||
def flush(self): pass
|
||||
|
||||
server._real_stdout = _AsciiOnly()
|
||||
with pytest.raises(UnicodeEncodeError):
|
||||
server.write_json({"msg": "héllo"})
|
||||
|
||||
|
||||
def test_write_json_unrelated_value_error_re_raises(server):
|
||||
"""Only ValueError('...closed file...') means peer gone. Other
|
||||
ValueErrors are programming errors and must surface."""
|
||||
|
||||
class _BadValue:
|
||||
def write(self, _): raise ValueError("something else entirely")
|
||||
def flush(self): pass
|
||||
|
||||
server._real_stdout = _BadValue()
|
||||
with pytest.raises(ValueError, match="something else entirely"):
|
||||
server.write_json({"x": 1})
|
||||
|
||||
|
||||
def test_write_json_non_serializable_payload_re_raises(server):
|
||||
"""Non-JSON-safe payloads are programming errors — they must NOT be
|
||||
silently dropped via the False path (which would trigger a clean exit
|
||||
in entry.py and mask the real bug)."""
|
||||
import io
|
||||
|
||||
server._real_stdout = io.StringIO()
|
||||
with pytest.raises(TypeError):
|
||||
server.write_json({"obj": object()})
|
||||
|
||||
|
||||
def test_write_json_peer_gone_oserror_on_flush_returns_false(server):
|
||||
"""A flush that raises a peer-gone OSError (EPIPE) must not strand
|
||||
the lock or crash; it returns False so the dispatcher exits cleanly."""
|
||||
import errno
|
||||
|
||||
written = []
|
||||
|
||||
class _FlushPeerGone:
|
||||
def write(self, line): written.append(line)
|
||||
def flush(self): raise OSError(errno.EPIPE, "broken pipe")
|
||||
|
||||
server._real_stdout = _FlushPeerGone()
|
||||
assert server.write_json({"x": 1}) is False
|
||||
assert written and json.loads(written[0]) == {"x": 1}
|
||||
|
||||
|
||||
def test_write_json_non_peer_gone_oserror_re_raises(server):
|
||||
"""Host I/O failures (ENOSPC, EACCES, EIO …) are NOT peer-gone — they
|
||||
must re-raise so the crash log records them instead of looking like
|
||||
a clean disconnect via the False path."""
|
||||
import errno
|
||||
|
||||
class _DiskFull:
|
||||
def write(self, _): raise OSError(errno.ENOSPC, "no space left")
|
||||
def flush(self): pass
|
||||
|
||||
server._real_stdout = _DiskFull()
|
||||
with pytest.raises(OSError, match="no space"):
|
||||
server.write_json({"x": 1})
|
||||
|
||||
|
||||
def test_write_json_skips_flush_when_disable_flush_true(monkeypatch):
|
||||
"""`StdioTransport` skips flush when `_DISABLE_FLUSH` is true.
|
||||
|
||||
Tests the runtime *behaviour* via direct module-attr patch. The env
|
||||
var → module constant wiring is covered by the dedicated env test
|
||||
below; reloading server.py here would re-register atexit hooks and
|
||||
recreate the worker pool.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
transport_mod = importlib.import_module("tui_gateway.transport")
|
||||
monkeypatch.setattr(transport_mod, "_DISABLE_FLUSH", True)
|
||||
|
||||
flushed = {"count": 0}
|
||||
written = []
|
||||
|
||||
class _Stream:
|
||||
def write(self, line): written.append(line)
|
||||
def flush(self): flushed["count"] += 1
|
||||
|
||||
stream = _Stream()
|
||||
transport = transport_mod.StdioTransport(lambda: stream, threading.Lock())
|
||||
|
||||
assert transport.write({"x": 1}) is True
|
||||
assert flushed["count"] == 0
|
||||
|
||||
|
||||
def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch):
|
||||
"""End-to-end: setting `HERMES_TUI_GATEWAY_NO_FLUSH=1` and importing
|
||||
`tui_gateway.transport` fresh actually flips `_DISABLE_FLUSH` true.
|
||||
|
||||
Reloads only the transport module — server.py is untouched so its
|
||||
atexit hooks/worker pool stay intact."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("HERMES_TUI_GATEWAY_NO_FLUSH", "1")
|
||||
transport_mod = importlib.reload(importlib.import_module("tui_gateway.transport"))
|
||||
|
||||
try:
|
||||
assert transport_mod._DISABLE_FLUSH is True
|
||||
finally:
|
||||
# Restore the env-disabled state so other tests see the default.
|
||||
monkeypatch.delenv("HERMES_TUI_GATEWAY_NO_FLUSH", raising=False)
|
||||
importlib.reload(transport_mod)
|
||||
|
||||
|
||||
# ── _emit ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_emit_with_payload(capture):
|
||||
server, buf = capture
|
||||
server._emit("test.event", "s1", {"key": "val"})
|
||||
msg = json.loads(buf.getvalue())
|
||||
|
||||
assert msg["method"] == "event"
|
||||
assert msg["params"]["type"] == "test.event"
|
||||
assert msg["params"]["session_id"] == "s1"
|
||||
assert msg["params"]["payload"]["key"] == "val"
|
||||
|
||||
|
||||
def test_emit_without_payload(capture):
|
||||
server, buf = capture
|
||||
server._emit("ping", "s2")
|
||||
|
||||
assert "payload" not in json.loads(buf.getvalue())["params"]
|
||||
|
||||
|
||||
# ── Blocking prompt round-trip ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_and_respond(capture):
|
||||
server, _ = capture
|
||||
result = [None]
|
||||
|
||||
threading.Thread(
|
||||
target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)),
|
||||
).start()
|
||||
|
||||
for _ in range(100):
|
||||
if server._pending:
|
||||
break
|
||||
threading.Event().wait(0.01)
|
||||
|
||||
rid = next(iter(server._pending))
|
||||
server._answers[rid] = "my_answer"
|
||||
# _pending values are (sid, Event) tuples — unpack to set the Event
|
||||
_, ev = server._pending[rid]
|
||||
ev.set()
|
||||
|
||||
threading.Event().wait(0.1)
|
||||
assert result[0] == "my_answer"
|
||||
|
||||
|
||||
def test_clear_pending(server):
|
||||
ev = threading.Event()
|
||||
# _pending values are (sid, Event) tuples
|
||||
server._pending["r1"] = ("sid-x", ev)
|
||||
server._clear_pending()
|
||||
|
||||
assert ev.is_set()
|
||||
assert server._answers["r1"] == ""
|
||||
|
||||
|
||||
# ── Session lookup ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_sess_missing(server):
|
||||
_, err = server._sess({"session_id": "nope"}, "r1")
|
||||
assert err["error"]["code"] == 4001
|
||||
|
||||
|
||||
def test_sess_found(server):
|
||||
server._sessions["abc"] = {"agent": MagicMock()}
|
||||
s, err = server._sess({"session_id": "abc"}, "r1")
|
||||
|
||||
assert s is not None
|
||||
assert err is None
|
||||
|
||||
|
||||
# ── session.resume payload ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_session_resume_returns_hydrated_messages(server, monkeypatch):
|
||||
class _DB:
|
||||
def get_session(self, _sid):
|
||||
return {"id": "20260409_010101_abc123"}
|
||||
|
||||
def get_session_by_title(self, _title):
|
||||
return None
|
||||
|
||||
def reopen_session(self, _sid):
|
||||
return None
|
||||
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False):
|
||||
return [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "yo"},
|
||||
{"role": "tool", "content": "searched"},
|
||||
{"role": "assistant", "content": " "},
|
||||
{"role": "assistant", "content": None},
|
||||
{"role": "narrator", "content": "skip"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _DB())
|
||||
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: object())
|
||||
monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None)
|
||||
monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "test/model"})
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "r1",
|
||||
"method": "session.resume",
|
||||
"params": {"session_id": "20260409_010101_abc123", "cols": 100},
|
||||
}
|
||||
)
|
||||
|
||||
assert "error" not in resp
|
||||
assert resp["result"]["message_count"] == 3
|
||||
assert resp["result"]["messages"] == [
|
||||
{"role": "user", "text": "hello"},
|
||||
{"role": "assistant", "text": "yo"},
|
||||
{"role": "tool", "name": "tool", "context": ""},
|
||||
]
|
||||
|
||||
|
||||
# ── Config I/O ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_config_load_missing(server, tmp_path):
|
||||
server._hermes_home = tmp_path
|
||||
assert server._load_cfg() == {}
|
||||
|
||||
|
||||
def test_config_roundtrip(server, tmp_path):
|
||||
server._hermes_home = tmp_path
|
||||
server._save_cfg({"model": "test/model"})
|
||||
assert server._load_cfg()["model"] == "test/model"
|
||||
|
||||
|
||||
# ── _cli_exec_blocked ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("argv", [
|
||||
[],
|
||||
["setup"],
|
||||
["gateway"],
|
||||
["sessions", "browse"],
|
||||
["config", "edit"],
|
||||
])
|
||||
def test_cli_exec_blocked(server, argv):
|
||||
assert server._cli_exec_blocked(argv) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("argv", [
|
||||
["version"],
|
||||
["sessions", "list"],
|
||||
])
|
||||
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"]
|
||||
|
||||
|
||||
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 == []
|
||||
|
||||
|
||||
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."""
|
||||
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_skills_manage_search_uses_tools_hub_sources(server):
|
||||
result = type("Result", (), {
|
||||
"description": "Build better terminal demos",
|
||||
"name": "showroom",
|
||||
})()
|
||||
auth = MagicMock(return_value="auth")
|
||||
router = MagicMock(return_value=["source"])
|
||||
search = MagicMock(return_value=[result])
|
||||
fake_hub = types.SimpleNamespace(
|
||||
GitHubAuth=auth,
|
||||
create_source_router=router,
|
||||
unified_search=search,
|
||||
)
|
||||
|
||||
with patch.dict(sys.modules, {"tools.skills_hub": fake_hub}):
|
||||
resp = server.handle_request({
|
||||
"id": "skills-search",
|
||||
"method": "skills.manage",
|
||||
"params": {"action": "search", "query": "showroom"},
|
||||
})
|
||||
|
||||
assert "error" not in resp
|
||||
assert resp["result"] == {
|
||||
"results": [{"description": "Build better terminal demos", "name": "showroom"}]
|
||||
}
|
||||
auth.assert_called_once_with()
|
||||
router.assert_called_once_with("auth")
|
||||
search.assert_called_once_with("showroom", ["source"], source_filter="all", limit=20)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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) ──────────────
|
||||
|
||||
|
||||
def test_dispatch_runs_short_handlers_inline(server):
|
||||
"""Non-long handlers return their response synchronously from dispatch()."""
|
||||
server._methods["fast.ping"] = lambda rid, params: server._ok(rid, {"pong": True})
|
||||
|
||||
resp = server.dispatch({"id": "r1", "method": "fast.ping", "params": {}})
|
||||
|
||||
assert resp == {"jsonrpc": "2.0", "id": "r1", "result": {"pong": True}}
|
||||
|
||||
|
||||
def test_dispatch_offloads_long_handlers_and_emits_via_stdout(capture):
|
||||
"""Long handlers run on the pool and write their response via write_json."""
|
||||
server, buf = capture
|
||||
server._methods["slash.exec"] = lambda rid, params: server._ok(rid, {"output": "hi"})
|
||||
|
||||
resp = server.dispatch({"id": "r2", "method": "slash.exec", "params": {}})
|
||||
assert resp is None
|
||||
|
||||
for _ in range(50):
|
||||
if buf.getvalue():
|
||||
break
|
||||
time.sleep(0.01)
|
||||
|
||||
written = json.loads(buf.getvalue())
|
||||
assert written == {"jsonrpc": "2.0", "id": "r2", "result": {"output": "hi"}}
|
||||
|
||||
|
||||
def test_dispatch_long_handler_does_not_block_fast_handler(server):
|
||||
"""A slow long handler must not prevent a concurrent fast handler from completing."""
|
||||
released = threading.Event()
|
||||
server._methods["slash.exec"] = lambda rid, params: (released.wait(timeout=5), server._ok(rid, {"done": True}))[1]
|
||||
server._methods["fast.ping"] = lambda rid, params: server._ok(rid, {"pong": True})
|
||||
|
||||
t0 = time.monotonic()
|
||||
assert server.dispatch({"id": "slow", "method": "slash.exec", "params": {}}) is None
|
||||
|
||||
fast_resp = server.dispatch({"id": "fast", "method": "fast.ping", "params": {}})
|
||||
fast_elapsed = time.monotonic() - t0
|
||||
|
||||
assert fast_resp["result"] == {"pong": True}
|
||||
assert fast_elapsed < 0.5, f"fast handler blocked for {fast_elapsed:.2f}s behind slow handler"
|
||||
|
||||
released.set()
|
||||
|
||||
|
||||
def test_dispatch_session_compress_does_not_block_fast_handler(server):
|
||||
"""Manual TUI compaction can take minutes, so it must not block the RPC loop."""
|
||||
released = threading.Event()
|
||||
|
||||
def slow_compress(rid, params):
|
||||
released.wait(timeout=5)
|
||||
return server._ok(rid, {"done": True})
|
||||
|
||||
server._methods["session.compress"] = slow_compress
|
||||
server._methods["fast.ping"] = lambda rid, params: server._ok(rid, {"pong": True})
|
||||
|
||||
t0 = time.monotonic()
|
||||
assert server.dispatch({"id": "slow", "method": "session.compress", "params": {}}) is None
|
||||
|
||||
fast_resp = server.dispatch({"id": "fast", "method": "fast.ping", "params": {}})
|
||||
fast_elapsed = time.monotonic() - t0
|
||||
|
||||
assert fast_resp["result"] == {"pong": True}
|
||||
assert fast_elapsed < 0.5, f"fast handler blocked for {fast_elapsed:.2f}s behind session.compress"
|
||||
|
||||
released.set()
|
||||
|
||||
|
||||
def test_dispatch_long_handler_exception_produces_error_response(capture):
|
||||
"""An exception inside a pool-dispatched handler still yields a JSON-RPC error."""
|
||||
server, buf = capture
|
||||
|
||||
def boom(rid, params):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
server._methods["slash.exec"] = boom
|
||||
|
||||
server.dispatch({"id": "r3", "method": "slash.exec", "params": {}})
|
||||
|
||||
for _ in range(50):
|
||||
if buf.getvalue():
|
||||
break
|
||||
time.sleep(0.01)
|
||||
|
||||
written = json.loads(buf.getvalue())
|
||||
assert written["id"] == "r3"
|
||||
assert written["error"]["code"] == -32000
|
||||
assert "kaboom" in written["error"]["message"]
|
||||
|
||||
|
||||
def test_dispatch_unknown_long_method_still_goes_inline(server):
|
||||
"""Method name not in _LONG_HANDLERS takes the sync path even if handler is slow."""
|
||||
server._methods["some.method"] = lambda rid, params: server._ok(rid, {"ok": True})
|
||||
|
||||
resp = server.dispatch({"id": "r4", "method": "some.method", "params": {}})
|
||||
|
||||
assert resp["result"] == {"ok": True}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Tests for tui_gateway.render — rendering bridge fallback behavior."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tui_gateway.render import make_stream_renderer, render_diff, render_message
|
||||
|
||||
|
||||
def _stub_rich(mock_mod):
|
||||
return patch.dict("sys.modules", {"agent.rich_output": mock_mod})
|
||||
|
||||
|
||||
def _no_rich():
|
||||
return patch.dict("sys.modules", {"agent.rich_output": None})
|
||||
|
||||
|
||||
# ── render_message ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_render_message_none_without_module():
|
||||
with _no_rich():
|
||||
assert render_message("hello") is None
|
||||
|
||||
|
||||
def test_render_message_formatted():
|
||||
mod = MagicMock()
|
||||
mod.format_response.return_value = "<b>hi</b>"
|
||||
|
||||
with _stub_rich(mod):
|
||||
assert render_message("hi", 100) == "<b>hi</b>"
|
||||
|
||||
|
||||
def test_render_message_type_error_fallback():
|
||||
mod = MagicMock()
|
||||
mod.format_response.side_effect = [TypeError, "fallback"]
|
||||
|
||||
with _stub_rich(mod):
|
||||
assert render_message("hi") == "fallback"
|
||||
|
||||
|
||||
def test_render_message_exception_returns_none():
|
||||
mod = MagicMock()
|
||||
mod.format_response.side_effect = RuntimeError
|
||||
|
||||
with _stub_rich(mod):
|
||||
assert render_message("hi") is None
|
||||
|
||||
|
||||
# ── render_diff / make_stream_renderer ───────────────────────────────
|
||||
|
||||
|
||||
def test_render_diff_none_without_module():
|
||||
with _no_rich():
|
||||
assert render_diff("+line") is None
|
||||
|
||||
|
||||
def test_stream_renderer_none_without_module():
|
||||
with _no_rich():
|
||||
assert make_stream_renderer() is None
|
||||
|
||||
|
||||
def test_stream_renderer_returns_instance():
|
||||
renderer = MagicMock()
|
||||
mod = MagicMock()
|
||||
mod.StreamingRenderer.return_value = renderer
|
||||
|
||||
with _stub_rich(mod):
|
||||
assert make_stream_renderer(120) is renderer
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Tests for tui_gateway background-review summary delivery.
|
||||
|
||||
When the self-improvement background review fires and saves a skill or
|
||||
memory entry, it calls ``agent.background_review_callback(message)``. In
|
||||
the CLI that routes through a prompt_toolkit-safe ``_cprint``; in the TUI
|
||||
there is no print surface, so without a callback wired up the review
|
||||
writes the change silently. ``_init_session`` attaches a callback that
|
||||
emits a ``review.summary`` event which Ink renders as a persistent
|
||||
transcript line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server():
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"hermes_constants": MagicMock(
|
||||
get_hermes_home=MagicMock(return_value="/tmp/hermes_test_review_summary")
|
||||
),
|
||||
"hermes_cli.env_loader": MagicMock(),
|
||||
"hermes_cli.banner": MagicMock(),
|
||||
"hermes_state": MagicMock(),
|
||||
},
|
||||
):
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("tui_gateway.server")
|
||||
yield mod
|
||||
# Reset module-level session state without re-importing. importlib.reload
|
||||
# would re-register the module's atexit hooks (ThreadPoolExecutor
|
||||
# shutdown, _shutdown_sessions); the duplicates race the stderr
|
||||
# buffer at interpreter shutdown and surface as Fatal Python error:
|
||||
# _enter_buffered_busy. Clearing the per-session dicts gives the
|
||||
# next test a clean slate; _methods is NOT cleared because it's
|
||||
# populated at module import time and re-registration only happens
|
||||
# via reload (which we don't do).
|
||||
mod._sessions.clear()
|
||||
mod._pending.clear()
|
||||
mod._answers.clear()
|
||||
|
||||
|
||||
def test_init_session_attaches_background_review_callback(server, monkeypatch):
|
||||
"""After _init_session, agent.background_review_callback is set to a
|
||||
function that emits 'review.summary' for the session's sid."""
|
||||
# Neutralize side-effect calls inside _init_session so we're testing
|
||||
# just the callback wiring.
|
||||
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object())
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
|
||||
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "m"})
|
||||
monkeypatch.setattr(server, "_load_show_reasoning", lambda: False)
|
||||
monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all")
|
||||
|
||||
captured_emits: list = []
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
"_emit",
|
||||
lambda event, sid, payload=None: captured_emits.append(
|
||||
(event, sid, payload)
|
||||
),
|
||||
)
|
||||
|
||||
class FakeAgent:
|
||||
model = "fake/model"
|
||||
# Presence of the attribute is all the Python side needs; the real
|
||||
# AIAgent has it defaulted to None in __init__.
|
||||
background_review_callback = None
|
||||
|
||||
agent = FakeAgent()
|
||||
server._init_session("sid-abc", "session-key", agent, [], cols=80)
|
||||
|
||||
cb = getattr(agent, "background_review_callback", None)
|
||||
assert callable(cb), (
|
||||
"_init_session must attach a background_review_callback to the "
|
||||
"agent so the self-improvement review is visible in the TUI."
|
||||
)
|
||||
|
||||
# Clear the session.info emit captured during _init_session.
|
||||
captured_emits.clear()
|
||||
|
||||
# Invoke the callback the way AIAgent._spawn_background_review would.
|
||||
cb("💾 Self-improvement review: Skill 'hermes-release' patched")
|
||||
|
||||
# Exactly one review.summary event should have been emitted, bound to
|
||||
# the session id we passed in, carrying the full message text.
|
||||
matched = [e for e in captured_emits if e[0] == "review.summary"]
|
||||
assert len(matched) == 1, captured_emits
|
||||
event, sid, payload = matched[0]
|
||||
assert sid == "sid-abc"
|
||||
assert payload == {
|
||||
"text": "💾 Self-improvement review: Skill 'hermes-release' patched"
|
||||
}
|
||||
|
||||
|
||||
def test_review_summary_callback_survives_agent_without_attribute(server, monkeypatch):
|
||||
"""If the agent is a bare object that doesn't allow attribute
|
||||
assignment (e.g. some stubbed test double), _init_session must not
|
||||
raise — session startup stays robust."""
|
||||
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object())
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
|
||||
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "m"})
|
||||
monkeypatch.setattr(server, "_load_show_reasoning", lambda: False)
|
||||
monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all")
|
||||
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
|
||||
|
||||
class LockedAgent:
|
||||
__slots__ = ("model",)
|
||||
|
||||
def __init__(self):
|
||||
self.model = "fake/model"
|
||||
|
||||
# LockedAgent's __slots__ blocks background_review_callback assignment.
|
||||
server._init_session("sid-x", "key-x", LockedAgent(), [], cols=80)
|
||||
# If we got here, _init_session swallowed the AttributeError gracefully.
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for tui_gateway.entry.wait_for_mcp_discovery (PR #35245).
|
||||
|
||||
MCP tool discovery runs in a background daemon thread so a slow/dead server
|
||||
can't freeze ``gateway.ready``. The agent snapshots its tool list once at
|
||||
build time and never re-reads it, so ``_make_agent`` briefly joins the
|
||||
discovery thread before building — bounded, so a dead server can't re-introduce
|
||||
the startup hang, and a no-op once discovery has finished.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import tui_gateway.entry as entry
|
||||
|
||||
|
||||
def _restore_thread_slot(saved):
|
||||
entry._mcp_discovery_thread = saved
|
||||
|
||||
|
||||
def test_no_thread_is_noop():
|
||||
"""When no discovery thread was started (the common no-MCP case), the
|
||||
helper returns immediately and never blocks."""
|
||||
saved = entry._mcp_discovery_thread
|
||||
try:
|
||||
entry._mcp_discovery_thread = None
|
||||
start = time.monotonic()
|
||||
entry.wait_for_mcp_discovery(timeout=5.0)
|
||||
assert time.monotonic() - start < 0.1
|
||||
finally:
|
||||
_restore_thread_slot(saved)
|
||||
|
||||
|
||||
def test_already_finished_thread_is_noop():
|
||||
"""A thread that has already finished is not joined-on (dead thread)."""
|
||||
saved = entry._mcp_discovery_thread
|
||||
try:
|
||||
t = threading.Thread(target=lambda: None, daemon=True)
|
||||
t.start()
|
||||
t.join() # ensure it's finished
|
||||
entry._mcp_discovery_thread = t
|
||||
start = time.monotonic()
|
||||
entry.wait_for_mcp_discovery(timeout=5.0)
|
||||
assert time.monotonic() - start < 0.1
|
||||
finally:
|
||||
_restore_thread_slot(saved)
|
||||
|
||||
|
||||
def test_fast_thread_is_joined():
|
||||
"""A reachable-but-still-connecting (fast) server lands before the agent
|
||||
snapshots tools — the helper waits for it to finish."""
|
||||
saved = entry._mcp_discovery_thread
|
||||
try:
|
||||
t = threading.Thread(target=lambda: time.sleep(0.05), daemon=True)
|
||||
t.start()
|
||||
entry._mcp_discovery_thread = t
|
||||
entry.wait_for_mcp_discovery(timeout=1.0)
|
||||
assert not t.is_alive() # joined to completion
|
||||
finally:
|
||||
_restore_thread_slot(saved)
|
||||
|
||||
|
||||
def test_hung_thread_is_bounded_by_timeout():
|
||||
"""A slow/dead server must NOT re-introduce the startup hang — the join is
|
||||
bounded by the timeout and returns even though the thread is still alive."""
|
||||
saved = entry._mcp_discovery_thread
|
||||
stop = threading.Event()
|
||||
try:
|
||||
t = threading.Thread(target=stop.wait, daemon=True) # blocks until set
|
||||
t.start()
|
||||
entry._mcp_discovery_thread = t
|
||||
start = time.monotonic()
|
||||
entry.wait_for_mcp_discovery(timeout=0.3)
|
||||
elapsed = time.monotonic() - start
|
||||
assert 0.25 <= elapsed < 1.0 # bounded near the timeout, not forever
|
||||
assert t.is_alive() # thread still running; we did not block on it
|
||||
finally:
|
||||
stop.set()
|
||||
_restore_thread_slot(saved)
|
||||
Reference in New Issue
Block a user