mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
* fix(tui): honor launch toolsets Carry chat --toolsets through the TUI launcher so TUI sessions use the same per-session tool scope as the classic CLI. * fix(tui): parse top-level toolsets flag Allow top-level hermes --tui --toolsets to reach the implicit chat session, matching chat subcommand behavior. * fix(tui): validate launch toolsets Filter invalid HERMES_TUI_TOOLSETS entries and fall back to configured CLI toolsets when the override contains no valid toolsets. * fix(tui): avoid config load for builtin toolsets Honor built-in HERMES_TUI_TOOLSETS values before loading config and treat all/* as the all-toolsets sentinel. * fix(cli): honor toolsets in oneshot mode Forward top-level --toolsets into oneshot agent construction so the flag is not silently ignored outside the TUI path. * fix(cli): validate oneshot toolsets Reject invalid-only oneshot toolset overrides before output redirection and clarify TUI fallback warnings. * fix(cli): preserve all-toolsets sentinel Map explicit all/* oneshot toolset overrides to the all-toolsets sentinel and replace locals() checks in TUI toolset loading. * fix(cli): warn on extra all-toolset entries Warn when all/* toolset overrides include additional ignored entries so typos are still visible. * fix(tui): honor plugin toolset overrides Discover plugin toolsets before rejecting unresolved explicit toolset overrides and read raw config for MCP name validation. * fix(tui): reuse toolset argument normalizer Share top-level TUI toolset argument parsing with the oneshot path to avoid duplicate normalization logic. * fix(cli): reject disabled mcp toolsets Validate explicit toolset overrides against enabled MCP servers only and clarify top-level toolset flag help. * fix(cli): distinguish disabled mcp from unknown toolsets Report disabled MCP servers separately from unknown toolset entries and stub plugin discovery in invalid-name tests for determinism.
417 lines
13 KiB
Python
417 lines
13 KiB
Python
from argparse import Namespace
|
|
from pathlib import Path
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
|
|
def _args(**overrides):
|
|
base = {
|
|
"continue_last": None,
|
|
"model": None,
|
|
"provider": None,
|
|
"resume": None,
|
|
"toolsets": None,
|
|
"tui": True,
|
|
"tui_dev": False,
|
|
}
|
|
base.update(overrides)
|
|
return Namespace(**base)
|
|
|
|
|
|
@pytest.fixture
|
|
def main_mod(monkeypatch):
|
|
import hermes_cli.main as mod
|
|
|
|
monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
|
|
return mod
|
|
|
|
|
|
def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod):
|
|
calls = []
|
|
captured = {}
|
|
|
|
def fake_resolve_last(source="cli"):
|
|
calls.append(source)
|
|
return "20260408_235959_a1b2c3" if source == "tui" else None
|
|
|
|
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
|
captured["resume"] = resume_session_id
|
|
raise SystemExit(0)
|
|
|
|
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last)
|
|
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val)
|
|
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
|
|
|
with pytest.raises(SystemExit):
|
|
main_mod.cmd_chat(_args(continue_last=True))
|
|
|
|
assert calls == ["tui"]
|
|
assert captured["resume"] == "20260408_235959_a1b2c3"
|
|
|
|
|
|
def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, main_mod):
|
|
calls = []
|
|
captured = {}
|
|
|
|
def fake_resolve_last(source="cli"):
|
|
calls.append(source)
|
|
if source == "tui":
|
|
return None
|
|
if source == "cli":
|
|
return "20260408_235959_d4e5f6"
|
|
return None
|
|
|
|
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
|
captured["resume"] = resume_session_id
|
|
raise SystemExit(0)
|
|
|
|
monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last)
|
|
monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val)
|
|
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
|
|
|
with pytest.raises(SystemExit):
|
|
main_mod.cmd_chat(_args(continue_last=True))
|
|
|
|
assert calls == ["tui", "cli"]
|
|
assert captured["resume"] == "20260408_235959_d4e5f6"
|
|
|
|
|
|
def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod):
|
|
captured = {}
|
|
|
|
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
|
captured["resume"] = resume_session_id
|
|
raise SystemExit(0)
|
|
|
|
monkeypatch.setattr(
|
|
main_mod, "_resolve_session_by_name_or_id", lambda val: "20260409_000000_aa11bb"
|
|
)
|
|
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
|
|
|
with pytest.raises(SystemExit):
|
|
main_mod.cmd_chat(_args(resume="my t0p session"))
|
|
|
|
assert captured["resume"] == "20260409_000000_aa11bb"
|
|
|
|
|
|
def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
|
|
captured = {}
|
|
|
|
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
|
captured.update(
|
|
{
|
|
"model": model,
|
|
"provider": provider,
|
|
"resume": resume_session_id,
|
|
"toolsets": toolsets,
|
|
"tui_dev": tui_dev,
|
|
}
|
|
)
|
|
raise SystemExit(0)
|
|
|
|
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
|
|
|
with pytest.raises(SystemExit):
|
|
main_mod.cmd_chat(
|
|
_args(model="anthropic/claude-sonnet-4.6", provider="anthropic")
|
|
)
|
|
|
|
assert captured == {
|
|
"model": "anthropic/claude-sonnet-4.6",
|
|
"provider": "anthropic",
|
|
"resume": None,
|
|
"toolsets": None,
|
|
"tui_dev": False,
|
|
}
|
|
|
|
|
|
def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod):
|
|
captured = {}
|
|
|
|
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
|
captured["toolsets"] = toolsets
|
|
raise SystemExit(0)
|
|
|
|
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
|
|
|
with pytest.raises(SystemExit):
|
|
main_mod.cmd_chat(_args(toolsets="web,terminal"))
|
|
|
|
assert captured["toolsets"] == "web,terminal"
|
|
|
|
|
|
def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod):
|
|
captured = {}
|
|
|
|
import hermes_cli.config as config_mod
|
|
|
|
monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"])
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
|
|
monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
|
|
monkeypatch.setattr(config_mod, "load_config", lambda: {})
|
|
monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"agent.shell_hooks",
|
|
types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
|
|
)
|
|
monkeypatch.setattr(main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}))
|
|
|
|
main_mod.main()
|
|
|
|
assert captured == {"toolsets": "web,terminal", "tui": True}
|
|
|
|
|
|
def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
|
|
captured = {}
|
|
|
|
import hermes_cli.config as config_mod
|
|
|
|
monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"])
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
|
|
monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
|
|
monkeypatch.setattr(config_mod, "load_config", lambda: {})
|
|
monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"agent.shell_hooks",
|
|
types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
|
|
)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"hermes_cli.oneshot",
|
|
types.SimpleNamespace(run_oneshot=lambda prompt, **kwargs: captured.update({"prompt": prompt, **kwargs}) or 0),
|
|
)
|
|
|
|
with pytest.raises(SystemExit) as exc:
|
|
main_mod.main()
|
|
|
|
assert exc.value.code == 0
|
|
assert captured == {"prompt": "hello", "model": None, "provider": None, "toolsets": "web,terminal"}
|
|
|
|
|
|
def _stub_plugin_discovery(monkeypatch):
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"hermes_cli.plugins",
|
|
types.SimpleNamespace(discover_plugins=lambda: None),
|
|
)
|
|
|
|
|
|
def test_oneshot_rejects_invalid_only_toolsets(monkeypatch, capsys):
|
|
_stub_plugin_discovery(monkeypatch)
|
|
from hermes_cli.oneshot import run_oneshot
|
|
|
|
assert run_oneshot("hello", toolsets="nope") == 2
|
|
err = capsys.readouterr().err
|
|
assert "nope" in err
|
|
assert "did not contain any valid toolsets" in err
|
|
|
|
|
|
def test_oneshot_filters_invalid_toolsets_before_redirect(monkeypatch, capsys):
|
|
_stub_plugin_discovery(monkeypatch)
|
|
from hermes_cli.oneshot import _validate_explicit_toolsets
|
|
|
|
valid, error = _validate_explicit_toolsets("web,nope")
|
|
|
|
assert valid == ["web"]
|
|
assert error is None
|
|
assert "nope" in capsys.readouterr().err
|
|
|
|
|
|
def test_oneshot_all_toolsets_means_all_not_configured_cli():
|
|
from hermes_cli.oneshot import _validate_explicit_toolsets
|
|
|
|
valid, error = _validate_explicit_toolsets("all")
|
|
|
|
assert valid is None
|
|
assert error is None
|
|
|
|
|
|
def test_oneshot_all_toolsets_warns_about_ignored_extra_entries(monkeypatch, capsys):
|
|
_stub_plugin_discovery(monkeypatch)
|
|
from hermes_cli.oneshot import _validate_explicit_toolsets
|
|
|
|
valid, error = _validate_explicit_toolsets("all,nope")
|
|
|
|
assert valid is None
|
|
assert error is None
|
|
assert "ignoring additional entries: nope" in capsys.readouterr().err
|
|
|
|
|
|
def test_oneshot_accepts_plugin_toolset_after_discovery(monkeypatch):
|
|
import toolsets
|
|
|
|
from hermes_cli.oneshot import _validate_explicit_toolsets
|
|
|
|
discovered = {"ready": False}
|
|
original_validate = toolsets.validate_toolset
|
|
|
|
def fake_validate(name):
|
|
return name == "plugin_demo" and discovered["ready"] or original_validate(name)
|
|
|
|
monkeypatch.setattr(toolsets, "validate_toolset", fake_validate)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"hermes_cli.plugins",
|
|
types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
|
|
)
|
|
|
|
valid, error = _validate_explicit_toolsets("plugin_demo")
|
|
|
|
assert valid == ["plugin_demo"]
|
|
assert error is None
|
|
|
|
|
|
def test_oneshot_rejects_disabled_mcp_toolset(monkeypatch, capsys):
|
|
_stub_plugin_discovery(monkeypatch)
|
|
import hermes_cli.config as config_mod
|
|
|
|
from hermes_cli.oneshot import _validate_explicit_toolsets
|
|
|
|
monkeypatch.setattr(
|
|
config_mod,
|
|
"read_raw_config",
|
|
lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
|
|
)
|
|
|
|
valid, error = _validate_explicit_toolsets("mcp-off")
|
|
|
|
assert valid is None
|
|
assert error == "hermes -z: --toolsets did not contain any valid toolsets.\n"
|
|
err = capsys.readouterr().err
|
|
assert "ignoring disabled MCP servers" in err
|
|
assert "mcp-off" in err
|
|
|
|
|
|
def test_oneshot_distinguishes_disabled_mcp_from_unknown(monkeypatch, capsys):
|
|
_stub_plugin_discovery(monkeypatch)
|
|
import hermes_cli.config as config_mod
|
|
|
|
from hermes_cli.oneshot import _validate_explicit_toolsets
|
|
|
|
monkeypatch.setattr(
|
|
config_mod,
|
|
"read_raw_config",
|
|
lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
|
|
)
|
|
|
|
valid, error = _validate_explicit_toolsets("web,mcp-off,nope")
|
|
|
|
assert valid == ["web"]
|
|
assert error is None
|
|
err = capsys.readouterr().err
|
|
assert "ignoring unknown --toolsets entries: nope" in err
|
|
assert "ignoring disabled MCP servers" in err
|
|
assert "mcp-off" in err
|
|
|
|
|
|
def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
|
|
captured = {}
|
|
active_path_during_call = None
|
|
|
|
monkeypatch.setattr(
|
|
main_mod,
|
|
"_make_tui_argv",
|
|
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
|
|
)
|
|
|
|
def fake_call(argv, cwd=None, env=None):
|
|
nonlocal active_path_during_call
|
|
captured.update({"argv": argv, "cwd": cwd, "env": env})
|
|
active_path_during_call = Path(env["HERMES_TUI_ACTIVE_SESSION_FILE"])
|
|
assert active_path_during_call.exists()
|
|
return 1
|
|
|
|
monkeypatch.setattr(main_mod.subprocess, "call", fake_call)
|
|
|
|
with pytest.raises(SystemExit):
|
|
main_mod._launch_tui(model="nous/hermes-test", provider="nous", toolsets="web, terminal")
|
|
|
|
env = captured["env"]
|
|
assert env["HERMES_MODEL"] == "nous/hermes-test"
|
|
assert env["HERMES_INFERENCE_MODEL"] == "nous/hermes-test"
|
|
assert env["HERMES_TUI_PROVIDER"] == "nous"
|
|
assert env["HERMES_INFERENCE_PROVIDER"] == "nous"
|
|
assert env["HERMES_TUI_TOOLSETS"] == "web,terminal"
|
|
active_path = Path(env["HERMES_TUI_ACTIVE_SESSION_FILE"])
|
|
assert active_path.name.startswith("hermes-tui-active-session-")
|
|
assert active_path.suffix == ".json"
|
|
assert active_path_during_call == active_path
|
|
assert not active_path.exists()
|
|
assert env["NODE_ENV"] == "production"
|
|
|
|
|
|
def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys):
|
|
import hermes_cli.main as main_mod
|
|
|
|
class _FakeDB:
|
|
def get_session(self, session_id):
|
|
assert session_id == "20260409_000001_abc123"
|
|
return {
|
|
"message_count": 2,
|
|
"input_tokens": 10,
|
|
"output_tokens": 6,
|
|
"cache_read_tokens": 2,
|
|
"cache_write_tokens": 2,
|
|
"reasoning_tokens": 1,
|
|
}
|
|
|
|
def get_session_title(self, _session_id):
|
|
return "demo title"
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
monkeypatch.setitem(
|
|
sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB())
|
|
)
|
|
|
|
main_mod._print_tui_exit_summary("20260409_000001_abc123")
|
|
out = capsys.readouterr().out
|
|
|
|
assert "Resume this session with:" in out
|
|
assert "hermes --tui --resume 20260409_000001_abc123" in out
|
|
assert 'hermes --tui -c "demo title"' in out
|
|
assert "Tokens: 21 (in 10, out 6, cache 4, reasoning 1)" in out
|
|
|
|
|
|
def test_print_tui_exit_summary_prefers_actual_active_session_file(
|
|
monkeypatch, capsys, tmp_path
|
|
):
|
|
import hermes_cli.main as main_mod
|
|
|
|
seen = []
|
|
|
|
class _FakeDB:
|
|
def get_session(self, session_id):
|
|
seen.append(session_id)
|
|
return {
|
|
"message_count": 1,
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"cache_read_tokens": 0,
|
|
"cache_write_tokens": 0,
|
|
"reasoning_tokens": 0,
|
|
}
|
|
|
|
def get_session_title(self, _session_id):
|
|
return "actual"
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
active = tmp_path / "active.json"
|
|
active.write_text('{"session_id":"actual_session"}', encoding="utf-8")
|
|
monkeypatch.setitem(
|
|
sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB())
|
|
)
|
|
|
|
main_mod._print_tui_exit_summary("startup_resume", str(active))
|
|
out = capsys.readouterr().out
|
|
|
|
assert seen == ["actual_session"]
|
|
assert "hermes --tui --resume actual_session" in out
|
|
assert "startup_resume" not in out
|