Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions hermes_cli/codex_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,12 @@ def _check_binary_cached() -> tuple[bool, Optional[str]]:
codex_version=ver if ok else None,
)

# No change requested
if new_value == current:
reapply_codex_app_server = new_value == current == "codex_app_server"

# No change requested. Re-applying codex_app_server is not a pure no-op:
# the persisted config value can already be correct while ~/.codex still
# lacks Hermes' managed MCP/plugin migration block.
if new_value == current and not reapply_codex_app_server:
return CodexRuntimeStatus(
success=True,
new_value=current,
Expand All @@ -172,8 +176,9 @@ def _check_binary_cached() -> tuple[bool, Optional[str]]:
codex_version=None,
)

set_runtime(config, new_value)
if persist_callback is not None:
if not reapply_codex_app_server:
set_runtime(config, new_value)
if persist_callback is not None and not reapply_codex_app_server:
try:
persist_callback(config)
except Exception as exc:
Expand All @@ -185,9 +190,15 @@ def _check_binary_cached() -> tuple[bool, Optional[str]]:
message=f"updated config in memory but persist failed: {exc}",
)

msg_lines = [
f"openai_runtime: {current} → {new_value}",
]
if reapply_codex_app_server:
msg_lines = [
"openai_runtime already set to codex_app_server - "
"re-applying migration",
]
else:
msg_lines = [
f"openai_runtime: {current} → {new_value}",
]
if new_value == "codex_app_server":
ok, ver = _check_binary_cached()
if ok:
Expand Down
14 changes: 10 additions & 4 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -2108,24 +2108,30 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
if project_root is None:
project_root = PROJECT_ROOT

def _is_dir(path: Path) -> bool:
try:
return path.is_dir()
except OSError:
return False

candidates = []

venv_bin = project_root / "venv" / "bin"
if venv_bin.is_dir():
if _is_dir(venv_bin):
candidates.append(str(venv_bin))
elif sys.prefix != sys.base_prefix:
candidates.append(str(Path(sys.prefix) / "bin"))

node_bin = project_root / "node_modules" / ".bin"
if node_bin.is_dir():
if _is_dir(node_bin):
candidates.append(str(node_bin))

hermes_home = get_hermes_home()
hermes_node = hermes_home / "node" / "bin"
if hermes_node.is_dir():
if _is_dir(hermes_node):
candidates.append(str(hermes_node))
hermes_nm = hermes_home / "node_modules" / ".bin"
if hermes_nm.is_dir():
if _is_dir(hermes_nm):
candidates.append(str(hermes_nm))

return candidates
Expand Down
36 changes: 36 additions & 0 deletions tests/hermes_cli/test_codex_runtime_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,42 @@ def test_no_change_when_already_set(self):
assert r.success
assert r.message == "openai_runtime already set to auto"

def test_reapply_codex_app_server_runs_migration_without_persist(self):
cfg = {
"model": {"openai_runtime": "codex_app_server"},
"mcp_servers": {
"filesystem": {"command": "npx", "args": ["-y", "fs-server"]},
},
}
persisted = False

def persist(c):
nonlocal persisted
persisted = True

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", "hermes-tools"]
mig.return_value.migrated_plugins = []
mig.return_value.plugin_query_error = None
mig.return_value.wrote_permissions_default = ":workspace"
mig.return_value.errors = []
mig.return_value.target_path = "/fake/.codex/config.toml"
r = crs.apply(cfg, "codex_app_server", persist_callback=persist)

assert r.success
assert r.new_value == "codex_app_server"
assert r.old_value == "codex_app_server"
assert r.requires_new_session is True
assert persisted is False
mig.assert_called_once_with(cfg)
assert cfg["model"]["openai_runtime"] == "codex_app_server"
assert "already set to codex_app_server" in r.message
assert "re-applying migration" in r.message
assert "Migrated 1 MCP server" in r.message
assert "via MCP" in r.message

def test_enable_blocked_when_codex_missing(self):
cfg = {}
with patch.object(crs, "check_codex_binary_ok",
Expand Down
22 changes: 18 additions & 4 deletions tests/run_agent/test_provider_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,12 @@ def test_original_messages_not_mutated(self, monkeypatch):
assert messages[0]["role"] == "system"

def test_developer_role_via_nous_portal(self, monkeypatch):
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
agent.model = "gpt-5"
agent = _make_agent(
monkeypatch,
"nous",
base_url="https://inference-api.nousresearch.com/v1",
model="gpt-5",
)
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "hi"},
Expand Down Expand Up @@ -346,14 +350,24 @@ def test_includes_tools(self, monkeypatch):
class TestBuildApiKwargsNousPortal:
def test_includes_nous_product_tags(self, monkeypatch):
from agent.portal_tags import nous_portal_tags
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
agent = _make_agent(
monkeypatch,
"nous",
base_url="https://inference-api.nousresearch.com/v1",
model="gpt-5",
)
messages = [{"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages)
extra = kwargs.get("extra_body", {})
assert extra.get("tags") == nous_portal_tags()

def test_uses_chat_completions_format(self, monkeypatch):
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
agent = _make_agent(
monkeypatch,
"nous",
base_url="https://inference-api.nousresearch.com/v1",
model="gpt-5",
)
messages = [{"role": "user", "content": "hi"}]
kwargs = agent._build_api_kwargs(messages)
assert "messages" in kwargs
Expand Down
27 changes: 27 additions & 0 deletions tests/tools/test_transcription_dotenv_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,33 @@ def test_import_after_config_env_patch_uses_restored_dotenv_loader(self):
finally:
importlib.reload(tt)

def test_xai_resolver_import_after_config_env_patch_uses_restored_dotenv_loader(self):
"""xAI HTTP auth must not cache a temporarily patched env helper."""
import importlib
import hermes_cli.config as config_mod
from tools import xai_http

with pytest.MonkeyPatch.context() as mp:
mp.setattr(config_mod, "get_env_value", lambda name, default=None: "")
xai_http = importlib.reload(xai_http)

try:
with patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=RuntimeError("no oauth"),
), patch(
"hermes_cli.auth.resolve_xai_oauth_runtime_credentials",
return_value={},
), patch(
"hermes_cli.config.load_env",
return_value={"XAI_API_KEY": "dotenv-secret"},
):
creds = xai_http.resolve_xai_http_credentials()
finally:
importlib.reload(xai_http)

assert creds["api_key"] == "dotenv-secret"

def test_explicit_groq_sees_dotenv(self):
from tools import transcription_tools as tt

Expand Down
12 changes: 5 additions & 7 deletions tools/xai_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,21 @@
import os
from typing import Dict

try:
from hermes_cli.config import get_env_value as _hermes_get_env_value
except Exception:
_hermes_get_env_value = None


def get_env_value(name: str, default=None):
"""Read ``name`` from ``~/.hermes/.env`` first, then ``os.environ``.

Wraps :func:`hermes_cli.config.get_env_value` so tests can patch
``tools.xai_http.get_env_value`` to inject dotenv-only secrets into the
xAI credential resolver.
"""
if _hermes_get_env_value is not None:
try:
from hermes_cli.config import get_env_value as _hermes_get_env_value

value = _hermes_get_env_value(name)
if value is not None:
return value
except Exception:
pass
return os.environ.get(name, default)


Expand Down
Loading