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

This commit is contained in:
红尘
2026-05-31 09:36:58 +08:00
commit d73ff9b0fb
4188 changed files with 1614916 additions and 0 deletions
View File
+100
View File
@@ -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
+202
View File
@@ -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
+812
View File
@@ -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}
+67
View File
@@ -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)