mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
208 lines
7.4 KiB
Python
208 lines
7.4 KiB
Python
"""Tests for ``hermes migrate xai`` — apply path with ruamel round-trip."""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.xai_retirement import (
|
|
RetirementIssue,
|
|
apply_migration,
|
|
find_retired_xai_refs,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def trap_config(tmp_path: Path) -> Path:
|
|
"""A config.yaml with retired models AND comments to verify round-trip."""
|
|
p = tmp_path / "config.yaml"
|
|
p.write_text(
|
|
"# Hermes config (sample)\n"
|
|
"principal:\n"
|
|
" provider: xai # the main model\n"
|
|
" model: grok-4-1-fast-non-reasoning # retiring May 15\n"
|
|
" temperature: 0.5\n"
|
|
"auxiliary:\n"
|
|
" vision:\n"
|
|
" provider: xai\n"
|
|
" model: grok-4-fast-reasoning # retiring\n"
|
|
" compression:\n"
|
|
" provider: openai # not affected\n"
|
|
" model: gpt-4o-mini\n"
|
|
"delegation:\n"
|
|
" model: grok-code-fast-1 # retiring\n"
|
|
"plugins:\n"
|
|
" image_gen:\n"
|
|
" xai:\n"
|
|
" model: grok-imagine-image-pro # retiring\n",
|
|
encoding="utf-8",
|
|
)
|
|
return p
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_config(tmp_path: Path) -> Path:
|
|
p = tmp_path / "config.yaml"
|
|
p.write_text(
|
|
"principal:\n"
|
|
" provider: xai\n"
|
|
" model: grok-4.3\n",
|
|
encoding="utf-8",
|
|
)
|
|
return p
|
|
|
|
|
|
def _parse(path: Path) -> dict:
|
|
"""Load with ruamel for assertion convenience."""
|
|
from ruamel.yaml import YAML
|
|
yaml = YAML(typ="rt")
|
|
with path.open("r", encoding="utf-8") as fh:
|
|
return yaml.load(fh)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dry-run / no-op
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNoOpPaths:
|
|
def test_clean_config_returns_unchanged_result(self, clean_config: Path):
|
|
issues = find_retired_xai_refs(_parse(clean_config))
|
|
assert issues == []
|
|
result = apply_migration(clean_config, issues)
|
|
assert result.config_changed is False
|
|
assert result.backup_path is None
|
|
# File untouched
|
|
assert "grok-4.3" in clean_config.read_text(encoding="utf-8")
|
|
|
|
def test_empty_issues_list_is_noop(self, trap_config: Path):
|
|
original = trap_config.read_text(encoding="utf-8")
|
|
result = apply_migration(trap_config, issues=[])
|
|
assert result.config_changed is False
|
|
assert trap_config.read_text(encoding="utf-8") == original
|
|
|
|
def test_missing_file_raises(self, tmp_path: Path):
|
|
with pytest.raises(FileNotFoundError):
|
|
apply_migration(tmp_path / "absent.yaml", issues=[
|
|
RetirementIssue(
|
|
config_path="principal.model",
|
|
current_model="grok-3",
|
|
replacement="grok-4.3",
|
|
)
|
|
])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Apply: surgical replacement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestApplyReplacement:
|
|
def test_replaces_principal_model(self, trap_config: Path):
|
|
issues = find_retired_xai_refs(_parse(trap_config))
|
|
result = apply_migration(trap_config, issues)
|
|
assert result.config_changed is True
|
|
cfg = _parse(trap_config)
|
|
assert cfg["principal"]["model"] == "grok-4.3"
|
|
|
|
|
|
|
|
|
|
|
|
def test_does_not_touch_unrelated_slots(self, trap_config: Path):
|
|
issues = find_retired_xai_refs(_parse(trap_config))
|
|
apply_migration(trap_config, issues)
|
|
cfg = _parse(trap_config)
|
|
# auxiliary.compression was never xAI, must remain untouched
|
|
assert cfg["auxiliary"]["compression"]["model"] == "gpt-4o-mini"
|
|
assert cfg["auxiliary"]["compression"]["provider"] == "openai"
|
|
# principal.temperature must survive
|
|
assert cfg["principal"]["temperature"] == 0.5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Round-trip preservation (the hard part)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRoundTripPreservation:
|
|
|
|
|
|
def test_preserves_top_level_key_order(self, trap_config: Path):
|
|
issues = find_retired_xai_refs(_parse(trap_config))
|
|
apply_migration(trap_config, issues)
|
|
text = trap_config.read_text(encoding="utf-8")
|
|
order = [
|
|
text.index("principal:"),
|
|
text.index("auxiliary:"),
|
|
text.index("delegation:"),
|
|
text.index("plugins:"),
|
|
]
|
|
assert order == sorted(order)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backup behaviour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBackup:
|
|
def test_backup_is_written_by_default(self, trap_config: Path):
|
|
issues = find_retired_xai_refs(_parse(trap_config))
|
|
original = trap_config.read_text(encoding="utf-8")
|
|
result = apply_migration(trap_config, issues)
|
|
assert result.backup_path is not None
|
|
assert result.backup_path.exists()
|
|
assert result.backup_path.read_text(encoding="utf-8") == original
|
|
|
|
|
|
def test_no_backup_when_disabled(self, trap_config: Path):
|
|
issues = find_retired_xai_refs(_parse(trap_config))
|
|
result = apply_migration(trap_config, issues, backup=False)
|
|
assert result.backup_path is None
|
|
# No bak file in the directory
|
|
assert not list(trap_config.parent.glob("*.bak-pre-migrate-xai-*"))
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Idempotence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestIdempotence:
|
|
def test_apply_twice_is_safe(self, trap_config: Path):
|
|
# First pass: replace
|
|
issues_1 = find_retired_xai_refs(_parse(trap_config))
|
|
apply_migration(trap_config, issues_1)
|
|
# Second pass: nothing to do
|
|
issues_2 = find_retired_xai_refs(_parse(trap_config))
|
|
assert issues_2 == []
|
|
result_2 = apply_migration(trap_config, issues_2)
|
|
assert result_2.config_changed is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-closed on unreadable existing config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestUnreadableExistingConfig:
|
|
def test_apply_refuses_to_overwrite_unreadable_config(self, trap_config: Path):
|
|
"""apply_migration must not clobber an existing config.yaml it can't
|
|
read. It reads the file first (which raises on an unreadable file), and
|
|
the require_readable_config_before_write guard before the write is a
|
|
belt-and-suspenders backstop for the read-then-write window. Either way
|
|
the original bytes must survive."""
|
|
import os
|
|
|
|
issues = find_retired_xai_refs(_parse(trap_config))
|
|
assert issues # sanity: trap_config has retired refs
|
|
original = trap_config.read_bytes()
|
|
|
|
os.chmod(trap_config, 0o000)
|
|
try:
|
|
with pytest.raises((PermissionError, RuntimeError, OSError)):
|
|
apply_migration(trap_config, issues, backup=False)
|
|
finally:
|
|
os.chmod(trap_config, 0o644)
|
|
|
|
assert trap_config.read_bytes() == original
|