mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
434 lines
17 KiB
Python
434 lines
17 KiB
Python
"""Tests for /update gateway slash command.
|
|
|
|
Tests both the _handle_update_command handler (spawns update process) and
|
|
the _send_update_notification startup hook (sends results after restart).
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock, AsyncMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform
|
|
from gateway.platforms.base import MessageEvent
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
def _make_event(text="/update", platform=Platform.TELEGRAM,
|
|
user_id="12345", chat_id="67890", thread_id=None):
|
|
"""Build a MessageEvent for testing."""
|
|
source = SessionSource(
|
|
platform=platform,
|
|
user_id=user_id,
|
|
chat_id=chat_id,
|
|
user_name="testuser",
|
|
thread_id=thread_id,
|
|
)
|
|
return MessageEvent(text=text, source=source)
|
|
|
|
|
|
def _make_runner():
|
|
"""Create a bare GatewayRunner without calling __init__."""
|
|
from gateway.run import GatewayRunner
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.adapters = {}
|
|
runner._voice_mode = {}
|
|
return runner
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _handle_update_command
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHandleUpdateCommand:
|
|
"""Tests for GatewayRunner._handle_update_command."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_git_directory(self, tmp_path):
|
|
"""Returns an error when .git does not exist."""
|
|
runner = _make_runner()
|
|
event = _make_event()
|
|
# Point _hermes_home to tmp_path and project_root to a dir without .git
|
|
fake_root = tmp_path / "project"
|
|
fake_root.mkdir()
|
|
with patch("gateway.run._hermes_home", tmp_path), \
|
|
patch("gateway.run.Path") as MockPath:
|
|
# Path(__file__).parent.parent.resolve() -> fake_root
|
|
MockPath.return_value = MagicMock()
|
|
MockPath.__truediv__ = Path.__truediv__
|
|
# Easier: just patch the __file__ resolution in the method
|
|
pass
|
|
|
|
# Simpler approach — mock at method level using a wrapper
|
|
runner = _make_runner()
|
|
|
|
with patch("gateway.run._hermes_home", tmp_path):
|
|
# The handler does Path(__file__).parent.parent.resolve()
|
|
# We need to make project_root / '.git' not exist.
|
|
# Since Path(__file__) resolves to the real gateway/run.py,
|
|
# project_root will be the real hermes-agent dir (which HAS .git).
|
|
# Patch Path to control this.
|
|
original_path = Path
|
|
|
|
class FakePath(type(Path())):
|
|
pass
|
|
|
|
# Actually, simplest: just patch the specific file attr.
|
|
# The _handle_update_command handler lives in gateway/slash_commands.py
|
|
# (extracted from run.py in the god-file decomposition); it resolves
|
|
# project_root via Path(__file__).parent.parent, so fake that file.
|
|
fake_file = str(fake_root / "gateway" / "slash_commands.py")
|
|
(fake_root / "gateway").mkdir(parents=True)
|
|
(fake_root / "gateway" / "slash_commands.py").touch()
|
|
|
|
with patch("gateway.slash_commands.__file__", fake_file):
|
|
result = await runner._handle_update_command(event)
|
|
|
|
assert "Not a git repository" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_hermes_bin_fallback(self):
|
|
"""_resolve_hermes_bin falls back to sys.executable argv when which fails."""
|
|
import sys
|
|
from gateway.run import _resolve_hermes_bin
|
|
|
|
fake_spec = MagicMock()
|
|
with patch("shutil.which", return_value=None), \
|
|
patch("importlib.util.find_spec", return_value=fake_spec):
|
|
result = _resolve_hermes_bin()
|
|
|
|
assert result == [sys.executable, "-m", "hermes_cli.main"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_writes_pending_marker(self, tmp_path):
|
|
"""Writes .update_pending.json with correct platform and chat info."""
|
|
runner = _make_runner()
|
|
event = _make_event(platform=Platform.TELEGRAM, chat_id="99999")
|
|
event.message_id = "m-update"
|
|
|
|
fake_root = tmp_path / "project"
|
|
fake_root.mkdir()
|
|
(fake_root / ".git").mkdir()
|
|
(fake_root / "gateway").mkdir()
|
|
(fake_root / "gateway" / "run.py").touch()
|
|
fake_file = str(fake_root / "gateway" / "run.py")
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home), \
|
|
patch("gateway.run.__file__", fake_file), \
|
|
patch("shutil.which", side_effect=lambda x: "/usr/bin/hermes" if x == "hermes" else "/usr/bin/setsid"), \
|
|
patch("subprocess.Popen"):
|
|
result = await runner._handle_update_command(event)
|
|
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
assert pending_path.exists()
|
|
data = json.loads(pending_path.read_text())
|
|
assert data["platform"] == "telegram"
|
|
assert data["chat_id"] == "99999"
|
|
assert data["chat_type"] == "dm"
|
|
assert data["message_id"] == "m-update"
|
|
assert "timestamp" in data
|
|
assert not (hermes_home / ".update_exit_code").exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_when_no_setsid(self, tmp_path):
|
|
"""Falls back to start_new_session=True when setsid is not available."""
|
|
runner = _make_runner()
|
|
event = _make_event()
|
|
|
|
fake_root = tmp_path / "project"
|
|
fake_root.mkdir()
|
|
(fake_root / ".git").mkdir()
|
|
(fake_root / "gateway").mkdir()
|
|
(fake_root / "gateway" / "run.py").touch()
|
|
fake_file = str(fake_root / "gateway" / "run.py")
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
mock_popen = MagicMock()
|
|
|
|
def which_no_setsid(x):
|
|
if x == "hermes":
|
|
return "/usr/bin/hermes"
|
|
if x == "setsid":
|
|
return None
|
|
return None
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home), \
|
|
patch("gateway.run.__file__", fake_file), \
|
|
patch("shutil.which", side_effect=which_no_setsid), \
|
|
patch("subprocess.Popen", mock_popen):
|
|
result = await runner._handle_update_command(event)
|
|
|
|
# Verify plain bash -c fallback (no nohup, no setsid)
|
|
call_args = mock_popen.call_args[0][0]
|
|
assert call_args[0] == "bash"
|
|
assert "nohup" not in call_args[2]
|
|
assert ".update_exit_code" in call_args[2]
|
|
# start_new_session=True should be in kwargs
|
|
call_kwargs = mock_popen.call_args[1]
|
|
assert call_kwargs.get("start_new_session") is True
|
|
assert "Starting Hermes update" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Platform allowlist gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateCommandPlatformGate:
|
|
"""Tests for the platform-allowlist gate at the top of
|
|
``_handle_update_command``. Built-in messaging platforms are listed in
|
|
``_UPDATE_ALLOWED_PLATFORMS``; plugin-migrated platforms (discord,
|
|
mattermost, teams, …) are NOT in the frozenset and rely on the
|
|
registry's ``allow_update_command=True`` fallback. Programmatic
|
|
interfaces (ACP, API server, webhooks) must be blocked.
|
|
"""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allows_plugin_platform_via_registry_fallback(self, monkeypatch):
|
|
"""A plugin-migrated platform (DISCORD) is no longer in
|
|
``_UPDATE_ALLOWED_PLATFORMS`` but must still pass the gate via
|
|
the registry's ``allow_update_command=True`` flag.
|
|
|
|
This test is the empirical guarantee that removing DISCORD from
|
|
the hardcoded frozenset does not regress the /update command for
|
|
Discord users.
|
|
"""
|
|
from gateway.run import GatewayRunner
|
|
|
|
# Precondition: DISCORD is NOT in the hardcoded set anymore.
|
|
assert Platform.DISCORD not in GatewayRunner._UPDATE_ALLOWED_PLATFORMS
|
|
|
|
# Make sure the plugin registry is populated so the fallback fires.
|
|
from hermes_cli.plugins import PluginManager
|
|
PluginManager().discover_and_load(force=True)
|
|
from gateway.platform_registry import platform_registry
|
|
discord_entry = platform_registry.get("discord")
|
|
assert discord_entry is not None
|
|
assert discord_entry.allow_update_command is True
|
|
|
|
runner = _make_runner()
|
|
event = _make_event(platform=Platform.DISCORD)
|
|
monkeypatch.setenv("HERMES_MANAGED", "")
|
|
|
|
with patch("subprocess.Popen"):
|
|
result = await runner._handle_update_command(event)
|
|
|
|
# The gate must NOT have rejected us — anything other than the
|
|
# ``platform_not_messaging`` rejection string is acceptable here.
|
|
# Later steps may legitimately return success ("Starting Hermes
|
|
# update…") or fail for environment reasons.
|
|
assert "only available from messaging platforms" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allows_homeassistant_via_registry_fallback(self, monkeypatch):
|
|
"""Same as DISCORD/MATTERMOST: HOMEASSISTANT is now plugin-migrated
|
|
(PR #40709) and not in the hardcoded frozenset; the registry must
|
|
keep /update working via ``allow_update_command=True``.
|
|
"""
|
|
from gateway.run import GatewayRunner
|
|
|
|
assert Platform.HOMEASSISTANT not in GatewayRunner._UPDATE_ALLOWED_PLATFORMS
|
|
|
|
from hermes_cli.plugins import PluginManager
|
|
PluginManager().discover_and_load(force=True)
|
|
from gateway.platform_registry import platform_registry
|
|
ha_entry = platform_registry.get("homeassistant")
|
|
assert ha_entry is not None
|
|
assert ha_entry.allow_update_command is True
|
|
|
|
runner = _make_runner()
|
|
event = _make_event(platform=Platform.HOMEASSISTANT)
|
|
monkeypatch.setenv("HERMES_MANAGED", "")
|
|
|
|
with patch("subprocess.Popen"):
|
|
result = await runner._handle_update_command(event)
|
|
|
|
assert "only available from messaging platforms" not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _send_update_notification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSendUpdateNotification:
|
|
"""Tests for GatewayRunner._send_update_notification."""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_defers_notification_while_update_still_running(self, tmp_path):
|
|
"""Returns False and keeps marker files when the update has not exited yet."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
pending_path.write_text(json.dumps({
|
|
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
|
|
}))
|
|
(hermes_home / ".update_output.txt").write_text("still running")
|
|
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
result = await runner._send_update_notification()
|
|
|
|
assert result is False
|
|
mock_adapter.send.assert_not_called()
|
|
assert pending_path.exists()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recovers_from_claimed_pending_file(self, tmp_path):
|
|
"""A claimed pending file from a crashed notifier is still deliverable."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
claimed_path = hermes_home / ".update_pending.claimed.json"
|
|
claimed_path.write_text(json.dumps({
|
|
"platform": "telegram", "chat_id": "67890", "user_id": "12345",
|
|
}))
|
|
(hermes_home / ".update_output.txt").write_text("done")
|
|
(hermes_home / ".update_exit_code").write_text("0")
|
|
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
result = await runner._send_update_notification()
|
|
|
|
assert result is True
|
|
mock_adapter.send.assert_called_once()
|
|
assert not claimed_path.exists()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sends_notification_with_output(self, tmp_path):
|
|
"""Sends update output to the correct platform and chat."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
# Write pending marker
|
|
pending = {
|
|
"platform": "telegram",
|
|
"chat_id": "67890",
|
|
"user_id": "12345",
|
|
"timestamp": "2026-03-04T21:00:00",
|
|
}
|
|
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
|
(hermes_home / ".update_output.txt").write_text(
|
|
"→ Found 3 new commit(s)\n✓ Code updated!\n✓ Update complete!"
|
|
)
|
|
(hermes_home / ".update_exit_code").write_text("0")
|
|
|
|
# Mock the adapter
|
|
mock_adapter = AsyncMock()
|
|
mock_adapter.send = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
await runner._send_update_notification()
|
|
|
|
mock_adapter.send.assert_called_once()
|
|
call_args = mock_adapter.send.call_args
|
|
assert call_args[0][0] == "67890" # chat_id
|
|
assert "Update complete" in call_args[0][1] or "update finished" in call_args[0][1].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleans_up_on_error(self, tmp_path):
|
|
"""Files are cleaned up even if notification fails."""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
output_path = hermes_home / ".update_output.txt"
|
|
exit_code_path = hermes_home / ".update_exit_code"
|
|
pending_path.write_text(json.dumps({
|
|
"platform": "telegram", "chat_id": "111", "user_id": "222",
|
|
}))
|
|
output_path.write_text("✓ Done")
|
|
exit_code_path.write_text("0")
|
|
|
|
# Adapter send raises
|
|
mock_adapter = AsyncMock()
|
|
mock_adapter.send.side_effect = RuntimeError("network error")
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
await runner._send_update_notification()
|
|
|
|
# Files should still be cleaned up (finally block)
|
|
assert not pending_path.exists()
|
|
assert not output_path.exists()
|
|
assert not exit_code_path.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_adapter_for_platform_preserves_markers(self, tmp_path):
|
|
"""A finished update whose platform is offline keeps its markers.
|
|
|
|
When the target platform's adapter has not reconnected yet, dropping
|
|
the completion markers would silently lose the notification. Instead the
|
|
call defers (returns False) and leaves every marker on disk so a later
|
|
retry can deliver once the platform is back.
|
|
"""
|
|
runner = _make_runner()
|
|
hermes_home = tmp_path / "hermes"
|
|
hermes_home.mkdir()
|
|
|
|
pending = {"platform": "discord", "chat_id": "111", "user_id": "222"}
|
|
pending_path = hermes_home / ".update_pending.json"
|
|
output_path = hermes_home / ".update_output.txt"
|
|
exit_code_path = hermes_home / ".update_exit_code"
|
|
pending_path.write_text(json.dumps(pending))
|
|
output_path.write_text("Done")
|
|
exit_code_path.write_text("0")
|
|
|
|
# Only telegram adapter available, but pending says discord
|
|
mock_adapter = AsyncMock()
|
|
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
|
|
|
with patch("gateway.run._hermes_home", hermes_home):
|
|
result = await runner._send_update_notification()
|
|
|
|
# No send (wrong platform offline) and the result is deferred.
|
|
assert result is False
|
|
mock_adapter.send.assert_not_called()
|
|
# Markers are preserved for a later retry — NOT cleaned up.
|
|
assert pending_path.exists()
|
|
assert output_path.exists()
|
|
assert exit_code_path.exists()
|
|
# The marker stays in its canonical pending location (claim restored).
|
|
assert not (hermes_home / ".update_pending.claimed.json").exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /update in help and known_commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUpdateInHelp:
|
|
"""Verify /update appears in help text and known commands set."""
|
|
|
|
|
|
def test_update_is_known_command(self):
|
|
"""The /update command is in the help text (proxy for _known_commands)."""
|
|
# _known_commands is local to _handle_message, so we verify by
|
|
# checking the help output includes it.
|
|
from gateway.run import GatewayRunner
|
|
import inspect
|
|
source = inspect.getsource(GatewayRunner._handle_message)
|
|
assert '"update"' in source
|