mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
71e323111e
Translates the user's mcp_servers config from ~/.hermes/config.yaml into
the TOML format codex's MCP client expects. Wired into the
/codex-runtime codex_app_server enable path so users get their MCP tool
surface in the spawned subprocess automatically.
The migration runs on every enable. Failures are non-fatal — the runtime
change still proceeds and the user gets a warning so they can fix the
codex config manually.
What translates (mapping verified against codex-rs/core/src/config/edit.rs):
Hermes mcp_servers.<n>.command/args/env → codex stdio transport
Hermes mcp_servers.<n>.url/headers → codex streamable_http transport
Hermes mcp_servers.<n>.timeout → codex tool_timeout_sec
Hermes mcp_servers.<n>.connect_timeout → codex startup_timeout_sec
Hermes mcp_servers.<n>.cwd → codex stdio cwd
Hermes mcp_servers.<n>.enabled: false → codex enabled = false
What does NOT translate (warned + skipped per server):
Hermes-specific keys (sampling, etc.) — codex's MCP client has no
equivalent. Listed in the per-server skipped[] field of the report.
What's NOT migrated (intentional):
AGENTS.md — codex respects this file natively in its cwd. Hermes' own
AGENTS.md (project-level) is already in the worktree, so codex picks
it up without translation. No code needed.
Idempotency design:
All managed content lives between a 'managed by hermes-agent' marker
and the next non-mcp_servers section header. _strip_existing_managed_block
removes the prior managed region cleanly, preserving any user-added
codex config (model, providers.openai, sandbox profiles, etc.) above
or below.
Files added:
hermes_cli/codex_runtime_plugin_migration.py — pure-Python migration
helper. Public API: migrate(hermes_config, codex_home=None,
dry_run=False) returns MigrationReport with .migrated/.errors/
.skipped_keys_per_server. No external TOML dependency — minimal
formatter handles strings/numbers/booleans/lists/inline-tables.
tests/hermes_cli/test_codex_runtime_plugin_migration.py — 39 tests
covering:
- per-server translation (12): stdio/http/sse, cwd, timeouts,
enabled flag, command+url precedence, sampling drop, unknown keys
- TOML formatter (8): types, escaping, inline tables, error case
- existing-block stripping (4): no marker, alone, with user content
above, with user content below
- end-to-end migrate() (8): empty, dry-run, round-trip, idempotent
re-run, preserves user config, error reporting, invalid input,
summary formatting
Files changed:
hermes_cli/codex_runtime_switch.py — apply() now calls migrate() in
the codex_app_server enable branch. Migration failure logs a warning
in the result message but does NOT fail the runtime change. Disable
path (auto) explicitly skips migration.
tests/hermes_cli/test_codex_runtime_switch.py — 3 new tests:
test_enable_triggers_mcp_migration, test_disable_does_not_trigger_migration,
test_migration_failure_does_not_block_enable.
All 325 feature tests green:
- tests/agent/transports/: 249 (incl. 67 new)
- tests/run_agent/test_codex_app_server_integration.py: 9
- tests/hermes_cli/test_codex_runtime_switch.py: 28 (3 new)
- tests/hermes_cli/test_codex_runtime_plugin_migration.py: 39 (new)
195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
"""Tests for the /codex-runtime slash-command shared logic.
|
|
|
|
These cover the pure-Python state machine; CLI and gateway handlers are
|
|
tested separately because they involve config persistence and prompt
|
|
formatting that's surface-specific."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import codex_runtime_switch as crs
|
|
|
|
|
|
class TestParseArgs:
|
|
@pytest.mark.parametrize("arg,expected", [
|
|
("", None),
|
|
(" ", None),
|
|
("auto", "auto"),
|
|
("codex_app_server", "codex_app_server"),
|
|
("on", "codex_app_server"),
|
|
("off", "auto"),
|
|
("codex", "codex_app_server"),
|
|
("default", "auto"),
|
|
("hermes", "auto"),
|
|
("ENABLE", "codex_app_server"), # case-insensitive
|
|
("DiSaBlE", "auto"),
|
|
])
|
|
def test_valid_args(self, arg, expected):
|
|
value, errors = crs.parse_args(arg)
|
|
assert errors == []
|
|
assert value == expected
|
|
|
|
def test_invalid_arg_returns_error(self):
|
|
value, errors = crs.parse_args("turbo")
|
|
assert value is None
|
|
assert errors and "Unknown runtime" in errors[0]
|
|
|
|
|
|
class TestGetCurrentRuntime:
|
|
def test_default_when_unset(self):
|
|
assert crs.get_current_runtime({}) == "auto"
|
|
assert crs.get_current_runtime({"model": {}}) == "auto"
|
|
assert crs.get_current_runtime({"model": {"openai_runtime": ""}}) == "auto"
|
|
|
|
def test_unrecognized_falls_back_to_auto(self):
|
|
assert crs.get_current_runtime(
|
|
{"model": {"openai_runtime": "garbage"}}
|
|
) == "auto"
|
|
|
|
def test_explicit_codex(self):
|
|
assert crs.get_current_runtime(
|
|
{"model": {"openai_runtime": "codex_app_server"}}
|
|
) == "codex_app_server"
|
|
|
|
def test_handles_non_dict_config(self):
|
|
assert crs.get_current_runtime(None) == "auto" # type: ignore[arg-type]
|
|
assert crs.get_current_runtime("notadict") == "auto" # type: ignore[arg-type]
|
|
assert crs.get_current_runtime({"model": "notadict"}) == "auto"
|
|
|
|
|
|
class TestSetRuntime:
|
|
def test_creates_model_section_if_missing(self):
|
|
cfg = {}
|
|
old = crs.set_runtime(cfg, "codex_app_server")
|
|
assert old == "auto"
|
|
assert cfg["model"]["openai_runtime"] == "codex_app_server"
|
|
|
|
def test_returns_previous_value(self):
|
|
cfg = {"model": {"openai_runtime": "codex_app_server"}}
|
|
old = crs.set_runtime(cfg, "auto")
|
|
assert old == "codex_app_server"
|
|
assert cfg["model"]["openai_runtime"] == "auto"
|
|
|
|
def test_invalid_value_raises(self):
|
|
with pytest.raises(ValueError):
|
|
crs.set_runtime({}, "garbage")
|
|
|
|
|
|
class TestApply:
|
|
def test_read_only_call_reports_state(self):
|
|
cfg = {"model": {"openai_runtime": "codex_app_server"}}
|
|
with patch.object(crs, "check_codex_binary_ok",
|
|
return_value=(True, "0.130.0")):
|
|
r = crs.apply(cfg, None)
|
|
assert r.success
|
|
assert r.new_value == "codex_app_server"
|
|
assert r.old_value == "codex_app_server"
|
|
assert "codex_app_server" in r.message
|
|
assert "0.130.0" in r.message
|
|
|
|
def test_no_change_when_already_set(self):
|
|
cfg = {"model": {"openai_runtime": "auto"}}
|
|
r = crs.apply(cfg, "auto")
|
|
assert r.success
|
|
assert r.message == "openai_runtime already set to auto"
|
|
|
|
def test_enable_blocked_when_codex_missing(self):
|
|
cfg = {}
|
|
with patch.object(crs, "check_codex_binary_ok",
|
|
return_value=(False, "codex not found")):
|
|
r = crs.apply(cfg, "codex_app_server")
|
|
assert r.success is False
|
|
assert "Cannot enable" in r.message
|
|
assert "npm i -g @openai/codex" in r.message
|
|
# Config NOT mutated on failure
|
|
assert cfg.get("model", {}).get("openai_runtime") in (None, "")
|
|
|
|
def test_enable_succeeds_when_codex_present(self):
|
|
cfg = {}
|
|
persisted = {}
|
|
|
|
def persist(c):
|
|
persisted.update(c)
|
|
|
|
with patch.object(crs, "check_codex_binary_ok",
|
|
return_value=(True, "0.130.0")):
|
|
r = crs.apply(cfg, "codex_app_server", persist_callback=persist)
|
|
assert r.success
|
|
assert r.new_value == "codex_app_server"
|
|
assert r.old_value == "auto"
|
|
assert r.requires_new_session is True
|
|
assert "delegate_task" in r.message # subagent-disabled banner
|
|
assert cfg["model"]["openai_runtime"] == "codex_app_server"
|
|
assert persisted["model"]["openai_runtime"] == "codex_app_server"
|
|
|
|
def test_disable_does_not_check_binary(self):
|
|
cfg = {"model": {"openai_runtime": "codex_app_server"}}
|
|
with patch.object(crs, "check_codex_binary_ok") as bin_check:
|
|
r = crs.apply(cfg, "auto")
|
|
assert r.success
|
|
# Binary check is irrelevant when disabling — should not be called
|
|
# with the codex_app_server enable-gate signature.
|
|
assert r.new_value == "auto"
|
|
assert r.old_value == "codex_app_server"
|
|
|
|
def test_persist_callback_failure_reported(self):
|
|
cfg = {}
|
|
|
|
def persist_boom(c):
|
|
raise IOError("disk full")
|
|
|
|
with patch.object(crs, "check_codex_binary_ok",
|
|
return_value=(True, "0.130.0")):
|
|
r = crs.apply(cfg, "codex_app_server", persist_callback=persist_boom)
|
|
assert r.success is False
|
|
assert "persist failed" in r.message
|
|
assert "disk full" in r.message
|
|
|
|
def test_enable_triggers_mcp_migration(self):
|
|
"""Enabling codex_app_server should auto-migrate Hermes mcp_servers
|
|
to ~/.codex/config.toml so the spawned subprocess sees them."""
|
|
cfg = {
|
|
"mcp_servers": {
|
|
"filesystem": {"command": "npx", "args": ["-y", "fs-server"]},
|
|
}
|
|
}
|
|
|
|
with patch.object(crs, "check_codex_binary_ok",
|
|
return_value=(True, "0.130.0")), \
|
|
patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
|
|
mig.return_value.migrated = ["filesystem"]
|
|
mig.return_value.errors = []
|
|
mig.return_value.target_path = "/fake/.codex/config.toml"
|
|
r = crs.apply(cfg, "codex_app_server")
|
|
assert r.success
|
|
assert mig.called # migration was triggered
|
|
assert "Migrated 1 MCP server" in r.message
|
|
|
|
def test_disable_does_not_trigger_migration(self):
|
|
"""Switching back to auto must not write to ~/.codex/."""
|
|
cfg = {
|
|
"model": {"openai_runtime": "codex_app_server"},
|
|
"mcp_servers": {"x": {"command": "y"}},
|
|
}
|
|
with patch("hermes_cli.codex_runtime_plugin_migration.migrate") as mig:
|
|
r = crs.apply(cfg, "auto")
|
|
assert r.success
|
|
assert not mig.called # disabling does not migrate
|
|
|
|
def test_migration_failure_does_not_block_enable(self):
|
|
"""If MCP migration raises, the runtime change still proceeds —
|
|
users can manually re-run migration later."""
|
|
cfg = {"mcp_servers": {"x": {"command": "y"}}}
|
|
with patch.object(crs, "check_codex_binary_ok",
|
|
return_value=(True, "0.130.0")), \
|
|
patch("hermes_cli.codex_runtime_plugin_migration.migrate",
|
|
side_effect=RuntimeError("disk full")):
|
|
r = crs.apply(cfg, "codex_app_server")
|
|
assert r.success # change still applied
|
|
assert r.new_value == "codex_app_server"
|
|
assert "MCP migration skipped" in r.message
|
|
assert "disk full" in r.message
|