fix: repair 57 failing CI tests across 14 files (#5823)

* fix: repair 57 failing CI tests across 14 files

Categories of fixes:

**Test isolation under xdist (-n auto):**
- test_hermes_logging: Strip ALL RotatingFileHandlers before each test
  to prevent handlers leaked from other xdist workers from polluting counts
- test_code_execution: Force TERMINAL_ENV=local in setUp — prevents Modal
  AuthError when another test leaks TERMINAL_ENV=modal
- test_timezone: Same TERMINAL_ENV fix for execute_code timezone tests
- test_codex_execution_paths: Mock _resolve_turn_agent_config to ensure
  model resolution works regardless of xdist worker state

**Matrix adapter tests (nio not installed in CI):**
- Add _make_fake_nio() helper with real response classes for isinstance()
  checks in production code
- Replace MagicMock(spec=nio.XxxResponse) with fake_nio instances
- Wrap production method calls with patch.dict('sys.modules', {'nio': ...})
  so import nio succeeds in method bodies
- Use try/except instead of pytest.importorskip for nio.crypto imports
  (importorskip can be fooled by MagicMock in sys.modules)
- test_matrix_voice: Skip entire file if nio is a mock, not just missing

**Stale test expectations:**
- test_cli_provider_resolution: _prompt_provider_choice now takes **kwargs
  (default param added); mock getpass.getpass alongside input
- test_anthropic_oauth_flow: Mock getpass.getpass (code switched from input)
- test_gemini_provider: Mock models.dev + OpenRouter API lookups to test
  hardcoded defaults without external API variance
- test_code_execution: Add notify_on_complete to blocked terminal params
- test_setup_openclaw_migration: Mock prompt_choice to select 'Full setup'
  (new quick-setup path leads to _require_tty → sys.exit in CI)
- test_skill_manager_tool: Patch get_all_skills_dirs alongside SKILLS_DIR
  so _find_skill searches tmp_path, not real ~/.hermes/skills/

**Missing attributes in object.__new__ test runners:**
- test_platform_reconnect: Add session_store to _make_runner()
- test_session_race_guard: Add hooks, _running_agents_ts, session_store,
  delivery_router to _make_runner()

**Production bug fix (gateway/run.py):**
- Fix sentinel eviction race: _AGENT_PENDING_SENTINEL was immediately
  evicted by the stale-detection logic because sentinels have no
  get_activity_summary() method, causing _stale_idle=inf >= timeout.
  Guard _should_evict with 'is not _AGENT_PENDING_SENTINEL'.

* fix: address remaining CI failures

- test_setup_openclaw_migration: Also mock _offer_launch_chat (called at
  end of both quick and full setup paths)
- test_code_execution: Move TERMINAL_ENV=local to module level to protect
  ALL test classes (TestEnvVarFiltering, TestExecuteCodeEdgeCases,
  TestInterruptHandling, TestHeadTailTruncation) from xdist env leaks
- test_matrix: Use try/except for nio.crypto imports (importorskip can be
  fooled by MagicMock in sys.modules under xdist)
This commit is contained in:
Teknium 2026-04-07 09:58:45 -07:00 committed by GitHub
parent f18a2aa634
commit caded0a5e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 208 additions and 69 deletions

View file

@ -1858,6 +1858,11 @@ class GatewayRunner:
if _quick_key in self._running_agents and _stale_ts: if _quick_key in self._running_agents and _stale_ts:
_stale_age = time.time() - _stale_ts _stale_age = time.time() - _stale_ts
_stale_agent = self._running_agents.get(_quick_key) _stale_agent = self._running_agents.get(_quick_key)
# Never evict the pending sentinel — it was just placed moments
# ago during the async setup phase before the real agent is
# created. Sentinels have no get_activity_summary(), so the
# idle check below would always evaluate to inf >= timeout and
# immediately evict them, racing with the setup path.
_stale_idle = float("inf") # assume idle if we can't check _stale_idle = float("inf") # assume idle if we can't check
_stale_detail = "" _stale_detail = ""
if _stale_agent and hasattr(_stale_agent, "get_activity_summary"): if _stale_agent and hasattr(_stale_agent, "get_activity_summary"):
@ -1876,9 +1881,12 @@ class GatewayRunner:
# cases where the agent object was garbage-collected). # cases where the agent object was garbage-collected).
_wall_ttl = max(_raw_stale_timeout * 10, 7200) if _raw_stale_timeout > 0 else float("inf") _wall_ttl = max(_raw_stale_timeout * 10, 7200) if _raw_stale_timeout > 0 else float("inf")
_should_evict = ( _should_evict = (
_stale_agent is not _AGENT_PENDING_SENTINEL
and (
(_raw_stale_timeout > 0 and _stale_idle >= _raw_stale_timeout) (_raw_stale_timeout > 0 and _stale_idle >= _raw_stale_timeout)
or _stale_age > _wall_ttl or _stale_age > _wall_ttl
) )
)
if _should_evict: if _should_evict:
logger.warning( logger.warning(
"Evicting stale _running_agents entry for %s " "Evicting stale _running_agents entry for %s "

View file

@ -2,12 +2,54 @@
import asyncio import asyncio
import json import json
import re import re
import sys
import types
import pytest import pytest
from unittest.mock import MagicMock, patch, AsyncMock from unittest.mock import MagicMock, patch, AsyncMock
from gateway.config import Platform, PlatformConfig from gateway.config import Platform, PlatformConfig
def _make_fake_nio():
"""Create a lightweight fake ``nio`` module with real response classes.
Tests that call production methods doing ``import nio`` / ``isinstance(resp, nio.XxxResponse)``
need real classes (not MagicMock auto-attributes) to satisfy isinstance checks.
Use via ``patch.dict("sys.modules", {"nio": _make_fake_nio()})``.
"""
mod = types.ModuleType("nio")
class RoomSendResponse:
def __init__(self, event_id="$fake"):
self.event_id = event_id
class RoomRedactResponse:
pass
class RoomCreateResponse:
def __init__(self, room_id="!fake:example.org"):
self.room_id = room_id
class RoomInviteResponse:
pass
class UploadResponse:
def __init__(self, content_uri="mxc://example.org/fake"):
self.content_uri = content_uri
# Minimal Api stub for code that checks nio.Api.RoomPreset
class _Api:
pass
mod.Api = _Api
mod.RoomSendResponse = RoomSendResponse
mod.RoomRedactResponse = RoomRedactResponse
mod.RoomCreateResponse = RoomCreateResponse
mod.RoomInviteResponse = RoomInviteResponse
mod.UploadResponse = UploadResponse
return mod
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Platform & Config # Platform & Config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -1450,7 +1492,10 @@ class TestMatrixEncryptedMedia:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_on_room_message_media_decrypts_encrypted_image_and_passes_local_path(self): async def test_on_room_message_media_decrypts_encrypted_image_and_passes_local_path(self):
try:
from nio.crypto.attachments import encrypt_attachment from nio.crypto.attachments import encrypt_attachment
except (ImportError, ModuleNotFoundError):
pytest.skip("matrix-nio[e2e] required for encryption tests")
adapter = _make_adapter() adapter = _make_adapter()
adapter._user_id = "@bot:example.org" adapter._user_id = "@bot:example.org"
@ -1518,7 +1563,10 @@ class TestMatrixEncryptedMedia:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_on_room_message_media_decrypts_encrypted_voice_and_caches_audio(self): async def test_on_room_message_media_decrypts_encrypted_voice_and_caches_audio(self):
try:
from nio.crypto.attachments import encrypt_attachment from nio.crypto.attachments import encrypt_attachment
except (ImportError, ModuleNotFoundError):
pytest.skip("matrix-nio[e2e] required for encryption tests")
adapter = _make_adapter() adapter = _make_adapter()
adapter._user_id = "@bot:example.org" adapter._user_id = "@bot:example.org"
@ -1587,7 +1635,10 @@ class TestMatrixEncryptedMedia:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_on_room_message_media_decrypts_encrypted_file_and_caches_document(self): async def test_on_room_message_media_decrypts_encrypted_file_and_caches_document(self):
try:
from nio.crypto.attachments import encrypt_attachment from nio.crypto.attachments import encrypt_attachment
except (ImportError, ModuleNotFoundError):
pytest.skip("matrix-nio[e2e] required for encryption tests")
adapter = _make_adapter() adapter = _make_adapter()
adapter._user_id = "@bot:example.org" adapter._user_id = "@bot:example.org"
@ -1883,13 +1934,14 @@ class TestMatrixReactions:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_reaction(self): async def test_send_reaction(self):
"""_send_reaction should call room_send with m.reaction.""" """_send_reaction should call room_send with m.reaction."""
nio = pytest.importorskip("nio") fake_nio = _make_fake_nio()
mock_client = MagicMock() mock_client = MagicMock()
mock_client.room_send = AsyncMock( mock_client.room_send = AsyncMock(
return_value=MagicMock(spec=nio.RoomSendResponse) return_value=fake_nio.RoomSendResponse("$reaction1")
) )
self.adapter._client = mock_client self.adapter._client = mock_client
with patch.dict("sys.modules", {"nio": fake_nio}):
result = await self.adapter._send_reaction("!room:ex", "$event1", "👍") result = await self.adapter._send_reaction("!room:ex", "$event1", "👍")
assert result is True assert result is True
mock_client.room_send.assert_called_once() mock_client.room_send.assert_called_once()
@ -1902,6 +1954,7 @@ class TestMatrixReactions:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_reaction_no_client(self): async def test_send_reaction_no_client(self):
self.adapter._client = None self.adapter._client = None
with patch.dict("sys.modules", {"nio": _make_fake_nio()}):
result = await self.adapter._send_reaction("!room:ex", "$ev", "👍") result = await self.adapter._send_reaction("!room:ex", "$ev", "👍")
assert result is False assert result is False
@ -1999,13 +2052,14 @@ class TestMatrixRedaction:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redact_message(self): async def test_redact_message(self):
nio = pytest.importorskip("nio") fake_nio = _make_fake_nio()
mock_client = MagicMock() mock_client = MagicMock()
mock_client.room_redact = AsyncMock( mock_client.room_redact = AsyncMock(
return_value=MagicMock(spec=nio.RoomRedactResponse) return_value=fake_nio.RoomRedactResponse()
) )
self.adapter._client = mock_client self.adapter._client = mock_client
with patch.dict("sys.modules", {"nio": fake_nio}):
result = await self.adapter.redact_message("!room:ex", "$ev1", "oops") result = await self.adapter.redact_message("!room:ex", "$ev1", "oops")
assert result is True assert result is True
mock_client.room_redact.assert_called_once() mock_client.room_redact.assert_called_once()
@ -2013,6 +2067,7 @@ class TestMatrixRedaction:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_redact_no_client(self): async def test_redact_no_client(self):
self.adapter._client = None self.adapter._client = None
with patch.dict("sys.modules", {"nio": _make_fake_nio()}):
result = await self.adapter.redact_message("!room:ex", "$ev1") result = await self.adapter.redact_message("!room:ex", "$ev1")
assert result is False assert result is False
@ -2027,32 +2082,34 @@ class TestMatrixRoomManagement:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_room(self): async def test_create_room(self):
nio = pytest.importorskip("nio") fake_nio = _make_fake_nio()
mock_resp = MagicMock(spec=nio.RoomCreateResponse) mock_resp = fake_nio.RoomCreateResponse(room_id="!new:example.org")
mock_resp.room_id = "!new:example.org"
mock_client = MagicMock() mock_client = MagicMock()
mock_client.room_create = AsyncMock(return_value=mock_resp) mock_client.room_create = AsyncMock(return_value=mock_resp)
self.adapter._client = mock_client self.adapter._client = mock_client
with patch.dict("sys.modules", {"nio": fake_nio}):
room_id = await self.adapter.create_room(name="Test Room", topic="A test") room_id = await self.adapter.create_room(name="Test Room", topic="A test")
assert room_id == "!new:example.org" assert room_id == "!new:example.org"
assert "!new:example.org" in self.adapter._joined_rooms assert "!new:example.org" in self.adapter._joined_rooms
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invite_user(self): async def test_invite_user(self):
nio = pytest.importorskip("nio") fake_nio = _make_fake_nio()
mock_client = MagicMock() mock_client = MagicMock()
mock_client.room_invite = AsyncMock( mock_client.room_invite = AsyncMock(
return_value=MagicMock(spec=nio.RoomInviteResponse) return_value=fake_nio.RoomInviteResponse()
) )
self.adapter._client = mock_client self.adapter._client = mock_client
with patch.dict("sys.modules", {"nio": fake_nio}):
result = await self.adapter.invite_user("!room:ex", "@user:ex") result = await self.adapter.invite_user("!room:ex", "@user:ex")
assert result is True assert result is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_room_no_client(self): async def test_create_room_no_client(self):
self.adapter._client = None self.adapter._client = None
with patch.dict("sys.modules", {"nio": _make_fake_nio()}):
result = await self.adapter.create_room() result = await self.adapter.create_room()
assert result is None assert result is None
@ -2099,13 +2156,13 @@ class TestMatrixMessageTypes:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_emote(self): async def test_send_emote(self):
nio = pytest.importorskip("nio") fake_nio = _make_fake_nio()
mock_client = MagicMock() mock_client = MagicMock()
mock_resp = MagicMock(spec=nio.RoomSendResponse) mock_resp = fake_nio.RoomSendResponse(event_id="$emote1")
mock_resp.event_id = "$emote1"
mock_client.room_send = AsyncMock(return_value=mock_resp) mock_client.room_send = AsyncMock(return_value=mock_resp)
self.adapter._client = mock_client self.adapter._client = mock_client
with patch.dict("sys.modules", {"nio": fake_nio}):
result = await self.adapter.send_emote("!room:ex", "waves hello") result = await self.adapter.send_emote("!room:ex", "waves hello")
assert result.success is True assert result.success is True
call_args = mock_client.room_send.call_args[0] call_args = mock_client.room_send.call_args[0]
@ -2113,13 +2170,13 @@ class TestMatrixMessageTypes:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_notice(self): async def test_send_notice(self):
nio = pytest.importorskip("nio") fake_nio = _make_fake_nio()
mock_client = MagicMock() mock_client = MagicMock()
mock_resp = MagicMock(spec=nio.RoomSendResponse) mock_resp = fake_nio.RoomSendResponse(event_id="$notice1")
mock_resp.event_id = "$notice1"
mock_client.room_send = AsyncMock(return_value=mock_resp) mock_client.room_send = AsyncMock(return_value=mock_resp)
self.adapter._client = mock_client self.adapter._client = mock_client
with patch.dict("sys.modules", {"nio": fake_nio}):
result = await self.adapter.send_notice("!room:ex", "System message") result = await self.adapter.send_notice("!room:ex", "System message")
assert result.success is True assert result.success is True
call_args = mock_client.room_send.call_args[0] call_args = mock_client.room_send.call_args[0]
@ -2128,5 +2185,6 @@ class TestMatrixMessageTypes:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_emote_empty_text(self): async def test_send_emote_empty_text(self):
self.adapter._client = MagicMock() self.adapter._client = MagicMock()
with patch.dict("sys.modules", {"nio": _make_fake_nio()}):
result = await self.adapter.send_emote("!room:ex", "") result = await self.adapter.send_emote("!room:ex", "")
assert result.success is False assert result.success is False

View file

@ -1,10 +1,18 @@
"""Tests for Matrix voice message support (MSC3245).""" """Tests for Matrix voice message support (MSC3245)."""
import io import io
import types
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
nio = pytest.importorskip("nio", reason="matrix-nio not installed") # Try importing real nio; skip entire file if not available.
# A MagicMock in sys.modules (from another test) is not the real package.
try:
import nio as _nio_probe
if not isinstance(_nio_probe, types.ModuleType) or not hasattr(_nio_probe, "__file__"):
pytest.skip("nio in sys.modules is a mock, not the real package", allow_module_level=True)
except ImportError:
pytest.skip("matrix-nio not installed", allow_module_level=True)
from gateway.platforms.base import MessageType from gateway.platforms.base import MessageType

View file

@ -59,6 +59,7 @@ def _make_runner():
runner._honcho_managers = {} runner._honcho_managers = {}
runner._honcho_configs = {} runner._honcho_configs = {}
runner._shutdown_all_gateway_honcho = lambda: None runner._shutdown_all_gateway_honcho = lambda: None
runner.session_store = MagicMock()
return runner return runner

View file

@ -36,11 +36,16 @@ def _make_runner():
) )
runner.adapters = {Platform.TELEGRAM: _FakeAdapter()} runner.adapters = {Platform.TELEGRAM: _FakeAdapter()}
runner._running_agents = {} runner._running_agents = {}
runner._running_agents_ts = {}
runner._pending_messages = {} runner._pending_messages = {}
runner._pending_approvals = {} runner._pending_approvals = {}
runner._voice_mode = {} runner._voice_mode = {}
runner._background_tasks = set() runner._background_tasks = set()
runner._is_user_authorized = lambda _source: True runner._is_user_authorized = lambda _source: True
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.session_store = MagicMock()
runner.delivery_router = MagicMock()
return runner return runner

View file

@ -184,6 +184,8 @@ class TestSetupWizardOpenclawIntegration:
patch("hermes_cli.auth.get_active_provider", return_value=None), patch("hermes_cli.auth.get_active_provider", return_value=None),
# User presses Enter to start # User presses Enter to start
patch("builtins.input", return_value=""), patch("builtins.input", return_value=""),
# Select "Full setup" (index 1) so we exercise the full path
patch.object(setup_mod, "prompt_choice", return_value=1),
# Mock the migration offer # Mock the migration offer
patch.object( patch.object(
setup_mod, "_offer_openclaw_migration", return_value=False setup_mod, "_offer_openclaw_migration", return_value=False
@ -196,6 +198,7 @@ class TestSetupWizardOpenclawIntegration:
patch.object(setup_mod, "setup_tools"), patch.object(setup_mod, "setup_tools"),
patch.object(setup_mod, "save_config"), patch.object(setup_mod, "save_config"),
patch.object(setup_mod, "_print_setup_summary"), patch.object(setup_mod, "_print_setup_summary"),
patch.object(setup_mod, "_offer_launch_chat"),
): ):
setup_mod.run_setup_wizard(args) setup_mod.run_setup_wizard(args)
@ -218,6 +221,7 @@ class TestSetupWizardOpenclawIntegration:
patch.object(setup_mod, "is_interactive_stdin", return_value=True), patch.object(setup_mod, "is_interactive_stdin", return_value=True),
patch("hermes_cli.auth.get_active_provider", return_value=None), patch("hermes_cli.auth.get_active_provider", return_value=None),
patch("builtins.input", return_value=""), patch("builtins.input", return_value=""),
patch.object(setup_mod, "prompt_choice", return_value=1),
patch.object(setup_mod, "_offer_openclaw_migration", return_value=True), patch.object(setup_mod, "_offer_openclaw_migration", return_value=True),
patch.object(setup_mod, "setup_model_provider"), patch.object(setup_mod, "setup_model_provider"),
patch.object(setup_mod, "setup_terminal_backend"), patch.object(setup_mod, "setup_terminal_backend"),
@ -226,6 +230,7 @@ class TestSetupWizardOpenclawIntegration:
patch.object(setup_mod, "setup_tools"), patch.object(setup_mod, "setup_tools"),
patch.object(setup_mod, "save_config"), patch.object(setup_mod, "save_config"),
patch.object(setup_mod, "_print_setup_summary"), patch.object(setup_mod, "_print_setup_summary"),
patch.object(setup_mod, "_offer_launch_chat"),
): ):
setup_mod.run_setup_wizard(args) setup_mod.run_setup_wizard(args)
@ -249,6 +254,7 @@ class TestSetupWizardOpenclawIntegration:
patch.object(setup_mod, "is_interactive_stdin", return_value=True), patch.object(setup_mod, "is_interactive_stdin", return_value=True),
patch("hermes_cli.auth.get_active_provider", return_value=None), patch("hermes_cli.auth.get_active_provider", return_value=None),
patch("builtins.input", return_value=""), patch("builtins.input", return_value=""),
patch.object(setup_mod, "prompt_choice", return_value=1),
patch.object(setup_mod, "_offer_openclaw_migration", return_value=True), patch.object(setup_mod, "_offer_openclaw_migration", return_value=True),
patch.object(setup_mod, "setup_model_provider") as setup_model_provider, patch.object(setup_mod, "setup_model_provider") as setup_model_provider,
patch.object(setup_mod, "setup_terminal_backend"), patch.object(setup_mod, "setup_terminal_backend"),
@ -257,6 +263,7 @@ class TestSetupWizardOpenclawIntegration:
patch.object(setup_mod, "setup_tools"), patch.object(setup_mod, "setup_tools"),
patch.object(setup_mod, "save_config"), patch.object(setup_mod, "save_config"),
patch.object(setup_mod, "_print_setup_summary"), patch.object(setup_mod, "_print_setup_summary"),
patch.object(setup_mod, "_offer_launch_chat"),
): ):
setup_mod.run_setup_wizard(args) setup_mod.run_setup_wizard(args)
@ -438,6 +445,7 @@ class TestSetupWizardSkipsConfiguredSections:
patch.object(setup_mod, "is_interactive_stdin", return_value=True), patch.object(setup_mod, "is_interactive_stdin", return_value=True),
patch("hermes_cli.auth.get_active_provider", return_value=None), patch("hermes_cli.auth.get_active_provider", return_value=None),
patch("builtins.input", return_value=""), patch("builtins.input", return_value=""),
patch.object(setup_mod, "prompt_choice", return_value=1),
# Migration succeeds and flips the env_side flag # Migration succeeds and flips the env_side flag
patch.object( patch.object(
setup_mod, "_offer_openclaw_migration", setup_mod, "_offer_openclaw_migration",

View file

@ -40,6 +40,7 @@ def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypa
monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None)
monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False) monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False)
monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token") monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token")
monkeypatch.setattr("getpass.getpass", lambda _prompt="": "sk-ant-oat01-manual-token")
from hermes_cli.main import _run_anthropic_oauth_flow from hermes_cli.main import _run_anthropic_oauth_flow

View file

@ -538,7 +538,7 @@ def test_cmd_model_falls_back_to_auto_on_invalid_provider(monkeypatch, capsys):
return "openrouter" return "openrouter"
monkeypatch.setattr("hermes_cli.auth.resolve_provider", _resolve_provider) monkeypatch.setattr("hermes_cli.auth.resolve_provider", _resolve_provider)
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices: len(choices) - 1) monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: len(choices) - 1)
monkeypatch.setattr("sys.stdin", type("FakeTTY", (), {"isatty": lambda self: True})()) monkeypatch.setattr("sys.stdin", type("FakeTTY", (), {"isatty": lambda self: True})())
hermes_main.cmd_model(SimpleNamespace()) hermes_main.cmd_model(SimpleNamespace())
@ -579,6 +579,7 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
# "Use this model? [Y/n]:" — confirm with Enter, then context length. # "Use this model? [Y/n]:" — confirm with Enter, then context length.
answers = iter(["http://localhost:8000", "local-key", "", ""]) answers = iter(["http://localhost:8000", "local-key", "", ""])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers)) monkeypatch.setattr("builtins.input", lambda _prompt="": next(answers))
monkeypatch.setattr("getpass.getpass", lambda _prompt="": next(answers))
hermes_main._model_flow_custom({}) hermes_main._model_flow_custom({})
output = capsys.readouterr().out output = capsys.readouterr().out
@ -601,7 +602,7 @@ def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None) monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None)
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous") monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous")
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None) monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None)
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices: 0) monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices, **kwargs: 0)
captured = {} captured = {}

View file

@ -152,11 +152,22 @@ def test_gateway_run_agent_codex_path_handles_internal_401_refresh(monkeypatch):
runner._provider_routing = {} runner._provider_routing = {}
runner._fallback_model = None runner._fallback_model = None
runner._running_agents = {} runner._running_agents = {}
runner._smart_model_routing = {}
from unittest.mock import MagicMock, AsyncMock from unittest.mock import MagicMock, AsyncMock
runner.hooks = MagicMock() runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock() runner.hooks.emit = AsyncMock()
runner.hooks.loaded_hooks = [] runner.hooks.loaded_hooks = []
runner._session_db = None runner._session_db = None
# Ensure model resolution returns the codex model even if xdist
# leaked env vars cleared HERMES_MODEL.
monkeypatch.setattr(
gateway_run.GatewayRunner,
"_resolve_turn_agent_config",
lambda self, msg, model, runtime: {
"model": model or "gpt-5.3-codex",
"runtime": runtime,
},
)
source = SessionSource( source = SessionSource(
platform=Platform.LOCAL, platform=Platform.LOCAL,

View file

@ -171,6 +171,10 @@ class TestGeminiModelNormalization:
class TestGeminiContextLength: class TestGeminiContextLength:
def test_gemma_4_31b_context(self): def test_gemma_4_31b_context(self):
# Mock external API lookups to test against hardcoded defaults
# (models.dev and OpenRouter may return different values like 262144).
with patch("agent.models_dev.lookup_models_dev_context", return_value=None), \
patch("agent.model_metadata.fetch_model_metadata", return_value={}):
ctx = get_model_context_length("gemma-4-31b-it", provider="gemini") ctx = get_model_context_length("gemma-4-31b-it", provider="gemini")
assert ctx == 256000 assert ctx == 256000

View file

@ -14,14 +14,29 @@ import hermes_logging
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_logging_state(): def _reset_logging_state():
"""Reset the module-level sentinel and clean up root logger handlers """Reset the module-level sentinel and clean up root logger handlers
added by setup_logging() so tests don't leak state.""" added by setup_logging() so tests don't leak state.
Under xdist (-n auto) other test modules may have called setup_logging()
in the same worker process, leaving RotatingFileHandlers on the root
logger. We strip ALL RotatingFileHandlers before each test so the count
assertions are stable regardless of test ordering.
"""
hermes_logging._logging_initialized = False hermes_logging._logging_initialized = False
root = logging.getLogger() root = logging.getLogger()
original_handlers = list(root.handlers) # Strip ALL RotatingFileHandlers — not just the ones we added — so that
# handlers leaked from other test modules in the same xdist worker don't
# pollute our counts.
pre_existing = []
for h in list(root.handlers):
if isinstance(h, RotatingFileHandler):
root.removeHandler(h)
h.close()
else:
pre_existing.append(h)
yield yield
# Restore — remove any handlers added during the test. # Restore — remove any handlers added during the test.
for h in list(root.handlers): for h in list(root.handlers):
if h not in original_handlers: if h not in pre_existing:
root.removeHandler(h) root.removeHandler(h)
h.close() h.close()
hermes_logging._logging_initialized = False hermes_logging._logging_initialized = False

View file

@ -136,8 +136,11 @@ class TestCodeExecutionTZ:
"""Verify TZ env var is passed to sandboxed child process via real execute_code.""" """Verify TZ env var is passed to sandboxed child process via real execute_code."""
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _import_execute_code(self): def _import_execute_code(self, monkeypatch):
"""Lazy-import execute_code to avoid pulling in firecrawl at collection time.""" """Lazy-import execute_code to avoid pulling in firecrawl at collection time."""
# Force local backend — other tests in the same xdist worker may leak
# TERMINAL_ENV=modal/docker which causes modal.exception.AuthError.
monkeypatch.setenv("TERMINAL_ENV", "local")
try: try:
from tools.code_execution_tool import execute_code from tools.code_execution_tool import execute_code
self._execute_code = execute_code self._execute_code = execute_code

View file

@ -15,9 +15,13 @@ Run with: python -m pytest tests/test_code_execution.py -v
import pytest import pytest
# pytestmark removed — tests run fine (61 pass, ~99s) # pytestmark removed — tests run fine (61 pass, ~99s)
import json import json
import os import os
# Force local terminal backend for ALL tests in this file.
# Under xdist, another test may leak TERMINAL_ENV=modal/docker, sending
# execute_code down the remote path → modal.exception.AuthError.
os.environ["TERMINAL_ENV"] = "local"
import sys import sys
import time import time
import threading import threading
@ -325,7 +329,7 @@ class TestStubSchemaDrift(unittest.TestCase):
# Parameters that are internal (injected by the handler, not user-facing) # Parameters that are internal (injected by the handler, not user-facing)
_INTERNAL_PARAMS = {"task_id", "user_task"} _INTERNAL_PARAMS = {"task_id", "user_task"}
# Parameters intentionally blocked in the sandbox # Parameters intentionally blocked in the sandbox
_BLOCKED_TERMINAL_PARAMS = {"background", "check_interval", "pty"} _BLOCKED_TERMINAL_PARAMS = {"background", "check_interval", "pty", "notify_on_complete"}
def test_stubs_cover_all_schema_params(self): def test_stubs_cover_all_schema_params(self):
"""Every user-facing parameter in the real schema must appear in the """Every user-facing parameter in the real schema must appear in the

View file

@ -1,6 +1,7 @@
"""Tests for tools/skill_manager_tool.py — skill creation, editing, and deletion.""" """Tests for tools/skill_manager_tool.py — skill creation, editing, and deletion."""
import json import json
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@ -24,6 +25,15 @@ from tools.skill_manager_tool import (
) )
@contextmanager
def _skill_dir(tmp_path):
"""Patch both SKILLS_DIR and get_all_skills_dirs so _find_skill searches
only the temp directory not the real ~/.hermes/skills/."""
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path), \
patch("agent.skill_utils.get_all_skills_dirs", return_value=[tmp_path]):
yield
VALID_SKILL_CONTENT = """\ VALID_SKILL_CONTENT = """\
--- ---
name: test-skill name: test-skill
@ -179,32 +189,32 @@ class TestValidateFilePath:
class TestCreateSkill: class TestCreateSkill:
def test_create_skill(self, tmp_path): def test_create_skill(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
result = _create_skill("my-skill", VALID_SKILL_CONTENT) result = _create_skill("my-skill", VALID_SKILL_CONTENT)
assert result["success"] is True assert result["success"] is True
assert (tmp_path / "my-skill" / "SKILL.md").exists() assert (tmp_path / "my-skill" / "SKILL.md").exists()
def test_create_with_category(self, tmp_path): def test_create_with_category(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
result = _create_skill("my-skill", VALID_SKILL_CONTENT, category="devops") result = _create_skill("my-skill", VALID_SKILL_CONTENT, category="devops")
assert result["success"] is True assert result["success"] is True
assert (tmp_path / "devops" / "my-skill" / "SKILL.md").exists() assert (tmp_path / "devops" / "my-skill" / "SKILL.md").exists()
assert result["category"] == "devops" assert result["category"] == "devops"
def test_create_duplicate_blocked(self, tmp_path): def test_create_duplicate_blocked(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
result = _create_skill("my-skill", VALID_SKILL_CONTENT) result = _create_skill("my-skill", VALID_SKILL_CONTENT)
assert result["success"] is False assert result["success"] is False
assert "already exists" in result["error"] assert "already exists" in result["error"]
def test_create_invalid_name(self, tmp_path): def test_create_invalid_name(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
result = _create_skill("Invalid Name!", VALID_SKILL_CONTENT) result = _create_skill("Invalid Name!", VALID_SKILL_CONTENT)
assert result["success"] is False assert result["success"] is False
def test_create_invalid_content(self, tmp_path): def test_create_invalid_content(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
result = _create_skill("my-skill", "no frontmatter here") result = _create_skill("my-skill", "no frontmatter here")
assert result["success"] is False assert result["success"] is False
@ -212,7 +222,8 @@ class TestCreateSkill:
skills_dir = tmp_path / "skills" skills_dir = tmp_path / "skills"
skills_dir.mkdir() skills_dir.mkdir()
with patch("tools.skill_manager_tool.SKILLS_DIR", skills_dir): with patch("tools.skill_manager_tool.SKILLS_DIR", skills_dir), \
patch("agent.skill_utils.get_all_skills_dirs", return_value=[skills_dir]):
result = _create_skill("my-skill", VALID_SKILL_CONTENT, category="../escape") result = _create_skill("my-skill", VALID_SKILL_CONTENT, category="../escape")
assert result["success"] is False assert result["success"] is False
@ -224,7 +235,8 @@ class TestCreateSkill:
skills_dir.mkdir() skills_dir.mkdir()
outside = tmp_path / "outside" outside = tmp_path / "outside"
with patch("tools.skill_manager_tool.SKILLS_DIR", skills_dir): with patch("tools.skill_manager_tool.SKILLS_DIR", skills_dir), \
patch("agent.skill_utils.get_all_skills_dirs", return_value=[skills_dir]):
result = _create_skill("my-skill", VALID_SKILL_CONTENT, category=str(outside)) result = _create_skill("my-skill", VALID_SKILL_CONTENT, category=str(outside))
assert result["success"] is False assert result["success"] is False
@ -234,7 +246,7 @@ class TestCreateSkill:
class TestEditSkill: class TestEditSkill:
def test_edit_existing_skill(self, tmp_path): def test_edit_existing_skill(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2) result = _edit_skill("my-skill", VALID_SKILL_CONTENT_2)
assert result["success"] is True assert result["success"] is True
@ -242,13 +254,13 @@ class TestEditSkill:
assert "Updated description" in content assert "Updated description" in content
def test_edit_nonexistent_skill(self, tmp_path): def test_edit_nonexistent_skill(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
result = _edit_skill("nonexistent", VALID_SKILL_CONTENT) result = _edit_skill("nonexistent", VALID_SKILL_CONTENT)
assert result["success"] is False assert result["success"] is False
assert "not found" in result["error"] assert "not found" in result["error"]
def test_edit_invalid_content_rejected(self, tmp_path): def test_edit_invalid_content_rejected(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
result = _edit_skill("my-skill", "no frontmatter") result = _edit_skill("my-skill", "no frontmatter")
assert result["success"] is False assert result["success"] is False
@ -259,7 +271,7 @@ class TestEditSkill:
class TestPatchSkill: class TestPatchSkill:
def test_patch_unique_match(self, tmp_path): def test_patch_unique_match(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
result = _patch_skill("my-skill", "Do the thing.", "Do the new thing.") result = _patch_skill("my-skill", "Do the thing.", "Do the new thing.")
assert result["success"] is True assert result["success"] is True
@ -267,7 +279,7 @@ class TestPatchSkill:
assert "Do the new thing." in content assert "Do the new thing." in content
def test_patch_nonexistent_string(self, tmp_path): def test_patch_nonexistent_string(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
result = _patch_skill("my-skill", "this text does not exist", "replacement") result = _patch_skill("my-skill", "this text does not exist", "replacement")
assert result["success"] is False assert result["success"] is False
@ -284,7 +296,7 @@ description: A test skill.
word word word word
""" """
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", content) _create_skill("my-skill", content)
result = _patch_skill("my-skill", "word", "replaced") result = _patch_skill("my-skill", "word", "replaced")
assert result["success"] is False assert result["success"] is False
@ -301,39 +313,39 @@ description: A test skill.
word word word word
""" """
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", content) _create_skill("my-skill", content)
result = _patch_skill("my-skill", "word", "replaced", replace_all=True) result = _patch_skill("my-skill", "word", "replaced", replace_all=True)
assert result["success"] is True assert result["success"] is True
def test_patch_supporting_file(self, tmp_path): def test_patch_supporting_file(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
_write_file("my-skill", "references/api.md", "old text here") _write_file("my-skill", "references/api.md", "old text here")
result = _patch_skill("my-skill", "old text", "new text", file_path="references/api.md") result = _patch_skill("my-skill", "old text", "new text", file_path="references/api.md")
assert result["success"] is True assert result["success"] is True
def test_patch_skill_not_found(self, tmp_path): def test_patch_skill_not_found(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
result = _patch_skill("nonexistent", "old", "new") result = _patch_skill("nonexistent", "old", "new")
assert result["success"] is False assert result["success"] is False
class TestDeleteSkill: class TestDeleteSkill:
def test_delete_existing(self, tmp_path): def test_delete_existing(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
result = _delete_skill("my-skill") result = _delete_skill("my-skill")
assert result["success"] is True assert result["success"] is True
assert not (tmp_path / "my-skill").exists() assert not (tmp_path / "my-skill").exists()
def test_delete_nonexistent(self, tmp_path): def test_delete_nonexistent(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
result = _delete_skill("nonexistent") result = _delete_skill("nonexistent")
assert result["success"] is False assert result["success"] is False
def test_delete_cleans_empty_category_dir(self, tmp_path): def test_delete_cleans_empty_category_dir(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT, category="devops") _create_skill("my-skill", VALID_SKILL_CONTENT, category="devops")
_delete_skill("my-skill") _delete_skill("my-skill")
assert not (tmp_path / "devops").exists() assert not (tmp_path / "devops").exists()
@ -346,19 +358,19 @@ class TestDeleteSkill:
class TestWriteFile: class TestWriteFile:
def test_write_reference_file(self, tmp_path): def test_write_reference_file(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
result = _write_file("my-skill", "references/api.md", "# API\nEndpoint docs.") result = _write_file("my-skill", "references/api.md", "# API\nEndpoint docs.")
assert result["success"] is True assert result["success"] is True
assert (tmp_path / "my-skill" / "references" / "api.md").exists() assert (tmp_path / "my-skill" / "references" / "api.md").exists()
def test_write_to_nonexistent_skill(self, tmp_path): def test_write_to_nonexistent_skill(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
result = _write_file("nonexistent", "references/doc.md", "content") result = _write_file("nonexistent", "references/doc.md", "content")
assert result["success"] is False assert result["success"] is False
def test_write_to_disallowed_path(self, tmp_path): def test_write_to_disallowed_path(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
result = _write_file("my-skill", "secret/evil.py", "malicious") result = _write_file("my-skill", "secret/evil.py", "malicious")
assert result["success"] is False assert result["success"] is False
@ -366,7 +378,7 @@ class TestWriteFile:
class TestRemoveFile: class TestRemoveFile:
def test_remove_existing_file(self, tmp_path): def test_remove_existing_file(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
_write_file("my-skill", "references/api.md", "content") _write_file("my-skill", "references/api.md", "content")
result = _remove_file("my-skill", "references/api.md") result = _remove_file("my-skill", "references/api.md")
@ -374,7 +386,7 @@ class TestRemoveFile:
assert not (tmp_path / "my-skill" / "references" / "api.md").exists() assert not (tmp_path / "my-skill" / "references" / "api.md").exists()
def test_remove_nonexistent_file(self, tmp_path): def test_remove_nonexistent_file(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
_create_skill("my-skill", VALID_SKILL_CONTENT) _create_skill("my-skill", VALID_SKILL_CONTENT)
result = _remove_file("my-skill", "references/nope.md") result = _remove_file("my-skill", "references/nope.md")
assert result["success"] is False assert result["success"] is False
@ -387,27 +399,27 @@ class TestRemoveFile:
class TestSkillManageDispatcher: class TestSkillManageDispatcher:
def test_unknown_action(self, tmp_path): def test_unknown_action(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
raw = skill_manage(action="explode", name="test") raw = skill_manage(action="explode", name="test")
result = json.loads(raw) result = json.loads(raw)
assert result["success"] is False assert result["success"] is False
assert "Unknown action" in result["error"] assert "Unknown action" in result["error"]
def test_create_without_content(self, tmp_path): def test_create_without_content(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
raw = skill_manage(action="create", name="test") raw = skill_manage(action="create", name="test")
result = json.loads(raw) result = json.loads(raw)
assert result["success"] is False assert result["success"] is False
assert "content" in result["error"].lower() assert "content" in result["error"].lower()
def test_patch_without_old_string(self, tmp_path): def test_patch_without_old_string(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
raw = skill_manage(action="patch", name="test") raw = skill_manage(action="patch", name="test")
result = json.loads(raw) result = json.loads(raw)
assert result["success"] is False assert result["success"] is False
def test_full_create_via_dispatcher(self, tmp_path): def test_full_create_via_dispatcher(self, tmp_path):
with patch("tools.skill_manager_tool.SKILLS_DIR", tmp_path): with _skill_dir(tmp_path):
raw = skill_manage(action="create", name="test-skill", content=VALID_SKILL_CONTENT) raw = skill_manage(action="create", name="test-skill", content=VALID_SKILL_CONTENT)
result = json.loads(raw) result = json.loads(raw)
assert result["success"] is True assert result["success"] is True