mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-12 03:42:08 +00:00
fix(tui): close slash parity gaps with CLI (#20339)
* fix(tui): close slash parity gaps with CLI Route unsupported /skills subcommands through slash.exec, support /new <name> titles, and handle /redraw natively so TUI behavior matches classic CLI. Also filter gateway-only commands out of the TUI catalog while keeping /status discoverable. * fix(tui): run remaining CLI parity paths natively Forward chat launch flags into the TUI runtime and handle live-session status and skill reloads in the gateway process so TUI state no longer depends on the slash worker's stale CLI instance. * fix(tui): block stale snapshot restores Prevent snapshot restore from running through the isolated slash worker because it mutates disk state without refreshing the live TUI agent. * chore: uptick * fix(tui): guard async session title updates Handle failures from the fire-and-forget session.title RPC so title-setting errors do not surface as unhandled promise rejections while preserving session-scoped messaging.
This commit is contained in:
parent
acca3ec3af
commit
794f48766c
14 changed files with 1266 additions and 284 deletions
|
|
@ -36,7 +36,14 @@ def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod):
|
|||
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):
|
||||
def fake_launch(
|
||||
resume_session_id=None,
|
||||
tui_dev=False,
|
||||
model=None,
|
||||
provider=None,
|
||||
toolsets=None,
|
||||
**kwargs,
|
||||
):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
|
|
@ -63,7 +70,14 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
|
|||
return "20260408_235959_d4e5f6"
|
||||
return None
|
||||
|
||||
def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
|
||||
def fake_launch(
|
||||
resume_session_id=None,
|
||||
tui_dev=False,
|
||||
model=None,
|
||||
provider=None,
|
||||
toolsets=None,
|
||||
**kwargs,
|
||||
):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
|
|
@ -81,7 +95,14 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, mai
|
|||
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):
|
||||
def fake_launch(
|
||||
resume_session_id=None,
|
||||
tui_dev=False,
|
||||
model=None,
|
||||
provider=None,
|
||||
toolsets=None,
|
||||
**kwargs,
|
||||
):
|
||||
captured["resume"] = resume_session_id
|
||||
raise SystemExit(0)
|
||||
|
||||
|
|
@ -99,7 +120,14 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod)
|
|||
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):
|
||||
def fake_launch(
|
||||
resume_session_id=None,
|
||||
tui_dev=False,
|
||||
model=None,
|
||||
provider=None,
|
||||
toolsets=None,
|
||||
**kwargs,
|
||||
):
|
||||
captured.update(
|
||||
{
|
||||
"model": model,
|
||||
|
|
@ -130,7 +158,14 @@ def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
|
|||
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):
|
||||
def fake_launch(
|
||||
resume_session_id=None,
|
||||
tui_dev=False,
|
||||
model=None,
|
||||
provider=None,
|
||||
toolsets=None,
|
||||
**kwargs,
|
||||
):
|
||||
captured["toolsets"] = toolsets
|
||||
raise SystemExit(0)
|
||||
|
||||
|
|
@ -142,22 +177,74 @@ def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod):
|
|||
assert captured["toolsets"] == "web,terminal"
|
||||
|
||||
|
||||
def test_cmd_chat_tui_forwards_chat_flags(monkeypatch, main_mod):
|
||||
captured = {}
|
||||
|
||||
def fake_launch(resume_session_id=None, **kwargs):
|
||||
captured["resume_session_id"] = resume_session_id
|
||||
captured.update(kwargs)
|
||||
raise SystemExit(0)
|
||||
|
||||
monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_chat(
|
||||
_args(
|
||||
skills=["foo,bar"],
|
||||
verbose=True,
|
||||
quiet=True,
|
||||
query="hello",
|
||||
image="/tmp/cat.png",
|
||||
worktree=True,
|
||||
checkpoints=True,
|
||||
pass_session_id=True,
|
||||
max_turns=7,
|
||||
accept_hooks=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["skills"] == ["foo,bar"]
|
||||
assert captured["verbose"] is True
|
||||
assert captured["quiet"] is True
|
||||
assert captured["query"] == "hello"
|
||||
assert captured["image"] == "/tmp/cat.png"
|
||||
assert captured["worktree"] is True
|
||||
assert captured["checkpoints"] is True
|
||||
assert captured["pass_session_id"] is True
|
||||
assert captured["max_turns"] == 7
|
||||
assert captured["accept_hooks"] is True
|
||||
|
||||
|
||||
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.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),
|
||||
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}),
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}))
|
||||
|
||||
main_mod.main()
|
||||
|
||||
|
|
@ -169,27 +256,49 @@ def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
|
|||
|
||||
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(
|
||||
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),
|
||||
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),
|
||||
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"}
|
||||
assert captured == {
|
||||
"prompt": "hello",
|
||||
"model": None,
|
||||
"provider": None,
|
||||
"toolsets": "web,terminal",
|
||||
}
|
||||
|
||||
|
||||
def _stub_plugin_discovery(monkeypatch):
|
||||
|
|
@ -256,7 +365,9 @@ def test_oneshot_accepts_plugin_toolset_after_discovery(monkeypatch):
|
|||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
|
||||
types.SimpleNamespace(
|
||||
discover_plugins=lambda: discovered.update({"ready": True})
|
||||
),
|
||||
)
|
||||
|
||||
valid, error = _validate_explicit_toolsets("plugin_demo")
|
||||
|
|
@ -328,7 +439,9 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
|
|||
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")
|
||||
main_mod._launch_tui(
|
||||
model="nous/hermes-test", provider="nous", toolsets="web, terminal"
|
||||
)
|
||||
|
||||
env = captured["env"]
|
||||
assert env["HERMES_MODEL"] == "nous/hermes-test"
|
||||
|
|
|
|||
|
|
@ -70,9 +70,7 @@ def test_dispatch_rejects_non_object_request():
|
|||
|
||||
|
||||
def test_dispatch_rejects_non_object_params():
|
||||
resp = server.dispatch(
|
||||
{"id": "1", "method": "session.create", "params": []}
|
||||
)
|
||||
resp = server.dispatch({"id": "1", "method": "session.create", "params": []})
|
||||
|
||||
assert resp == {
|
||||
"jsonrpc": "2.0",
|
||||
|
|
@ -133,12 +131,16 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
|
|||
monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
|
||||
|
||||
status_resp = server.dispatch(
|
||||
{"id": "voice-status", "method": "voice.toggle", "params": {"action": "status"}}
|
||||
{
|
||||
"id": "voice-status",
|
||||
"method": "voice.toggle",
|
||||
"params": {"action": "status"},
|
||||
}
|
||||
)
|
||||
|
||||
assert status_resp["result"]["record_key"] == "ctrl+b", (
|
||||
f"voice.record_key fell back to default for voice={bad!r}"
|
||||
)
|
||||
assert (
|
||||
status_resp["result"]["record_key"] == "ctrl+b"
|
||||
), f"voice.record_key fell back to default for voice={bad!r}"
|
||||
|
||||
# Round-4 follow-up: the YAML root itself may be a non-dict. A
|
||||
# hand-edit that collapses config.yaml to a scalar / list would
|
||||
|
|
@ -148,12 +150,16 @@ def test_voice_toggle_handles_non_dict_voice_cfg(monkeypatch):
|
|||
monkeypatch.setattr(server, "_load_cfg", lambda r=bad_root: r)
|
||||
|
||||
status_resp = server.dispatch(
|
||||
{"id": "voice-status-root", "method": "voice.toggle", "params": {"action": "status"}}
|
||||
{
|
||||
"id": "voice-status-root",
|
||||
"method": "voice.toggle",
|
||||
"params": {"action": "status"},
|
||||
}
|
||||
)
|
||||
|
||||
assert status_resp["result"]["record_key"] == "ctrl+b", (
|
||||
f"voice.record_key fell back to default for root={bad_root!r}"
|
||||
)
|
||||
assert (
|
||||
status_resp["result"]["record_key"] == "ctrl+b"
|
||||
), f"voice.record_key fell back to default for root={bad_root!r}"
|
||||
|
||||
|
||||
def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
|
||||
|
|
@ -174,7 +180,9 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
|
|||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.voice",
|
||||
types.SimpleNamespace(start_continuous=fake_start_continuous, stop_continuous=lambda: None),
|
||||
types.SimpleNamespace(
|
||||
start_continuous=fake_start_continuous, stop_continuous=lambda: None
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_VOICE", "1")
|
||||
|
||||
|
|
@ -183,10 +191,16 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
|
|||
monkeypatch.setattr(server, "_load_cfg", lambda b=bad: {"voice": b})
|
||||
|
||||
resp = server.dispatch(
|
||||
{"id": "voice-record", "method": "voice.record", "params": {"action": "start"}}
|
||||
{
|
||||
"id": "voice-record",
|
||||
"method": "voice.record",
|
||||
"params": {"action": "start"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "result" in resp, f"voice.record raised for voice={bad!r}: {resp.get('error')}"
|
||||
assert (
|
||||
"result" in resp
|
||||
), f"voice.record raised for voice={bad!r}: {resp.get('error')}"
|
||||
assert resp["result"]["status"] == "recording"
|
||||
assert captured["silence_threshold"] == 200
|
||||
assert captured["silence_duration"] == 3.0
|
||||
|
|
@ -204,16 +218,20 @@ def test_voice_record_start_handles_non_dict_voice_cfg(monkeypatch):
|
|||
monkeypatch.setattr(server, "_load_cfg", lambda c=bad_bool_cfg: {"voice": c})
|
||||
|
||||
resp = server.dispatch(
|
||||
{"id": "voice-record-bool", "method": "voice.record", "params": {"action": "start"}}
|
||||
{
|
||||
"id": "voice-record-bool",
|
||||
"method": "voice.record",
|
||||
"params": {"action": "start"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "result" in resp, f"voice.record raised for bool cfg={bad_bool_cfg!r}"
|
||||
assert captured["silence_threshold"] == 200, (
|
||||
f"bool silence_threshold leaked through for {bad_bool_cfg!r}"
|
||||
)
|
||||
assert captured["silence_duration"] == 3.0, (
|
||||
f"bool silence_duration leaked through for {bad_bool_cfg!r}"
|
||||
)
|
||||
assert (
|
||||
captured["silence_threshold"] == 200
|
||||
), f"bool silence_threshold leaked through for {bad_bool_cfg!r}"
|
||||
assert (
|
||||
captured["silence_duration"] == 3.0
|
||||
), f"bool silence_duration leaked through for {bad_bool_cfg!r}"
|
||||
|
||||
|
||||
def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch):
|
||||
|
|
@ -281,7 +299,9 @@ def test_load_enabled_toolsets_accepts_plugin_env_after_discovery(monkeypatch):
|
|||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
|
||||
types.SimpleNamespace(
|
||||
discover_plugins=lambda: discovered.update({"ready": True})
|
||||
),
|
||||
)
|
||||
|
||||
assert server._load_enabled_toolsets() == ["plugin_demo"]
|
||||
|
|
@ -302,7 +322,9 @@ def test_load_enabled_toolsets_rejects_disabled_mcp_env(monkeypatch, capsys):
|
|||
"read_raw_config",
|
||||
lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
|
||||
)
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}})
|
||||
monkeypatch.setattr(
|
||||
config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
|
||||
)
|
||||
|
||||
# Sorted: ["kanban", "memory"]. `kanban` is auto-recovered by
|
||||
# _get_platform_tools because it's a non-configurable platform toolset
|
||||
|
|
@ -324,7 +346,9 @@ def test_load_enabled_toolsets_falls_back_when_tui_env_invalid(monkeypatch, caps
|
|||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}})
|
||||
monkeypatch.setattr(
|
||||
config_mod, "load_config", lambda: {"platform_toolsets": {"cli": ["memory"]}}
|
||||
)
|
||||
|
||||
assert server._load_enabled_toolsets() == ["kanban", "memory"]
|
||||
assert "using configured CLI toolsets" in capsys.readouterr().err
|
||||
|
|
@ -340,7 +364,9 @@ def test_load_enabled_toolsets_warns_when_config_fallback_fails(monkeypatch, cap
|
|||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
monkeypatch.setattr(
|
||||
config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
)
|
||||
|
||||
assert server._load_enabled_toolsets() is None
|
||||
assert "could not be loaded" in capsys.readouterr().err
|
||||
|
|
@ -351,7 +377,9 @@ def test_load_enabled_toolsets_honors_builtin_env_if_config_fails(monkeypatch):
|
|||
|
||||
import hermes_cli.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
monkeypatch.setattr(
|
||||
config_mod, "load_config", lambda: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
)
|
||||
|
||||
assert server._load_enabled_toolsets() == ["web"]
|
||||
|
||||
|
|
@ -362,7 +390,9 @@ def test_load_enabled_toolsets_all_env_means_all(monkeypatch):
|
|||
assert server._load_enabled_toolsets() is None
|
||||
|
||||
|
||||
def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(monkeypatch, capsys):
|
||||
def test_load_enabled_toolsets_all_env_warns_about_ignored_extra_entries(
|
||||
monkeypatch, capsys
|
||||
):
|
||||
monkeypatch.setenv("HERMES_TUI_TOOLSETS", "all,nope")
|
||||
|
||||
assert server._load_enabled_toolsets() is None
|
||||
|
|
@ -1801,9 +1831,7 @@ def test_session_compress_uses_compress_helper(monkeypatch):
|
|||
emit.assert_any_call("session.info", "sid", {"model": "x"})
|
||||
# Final status.update clears the pinned "compressing" indicator so the
|
||||
# status bar can revert to the neutral state when compaction finishes.
|
||||
emit.assert_any_call(
|
||||
"status.update", "sid", {"kind": "status", "text": "ready"}
|
||||
)
|
||||
emit.assert_any_call("status.update", "sid", {"kind": "status", "text": "ready"})
|
||||
|
||||
|
||||
def test_session_compress_syncs_session_key_after_rotation(monkeypatch):
|
||||
|
|
@ -2050,6 +2078,120 @@ def test_commands_catalog_includes_tui_mouse_command():
|
|||
assert "/mouse" in tui_pairs
|
||||
|
||||
|
||||
def test_commands_catalog_filters_gateway_only_commands_and_keeps_status_visible():
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "commands.catalog", "params": {}}
|
||||
)
|
||||
|
||||
pairs = dict(resp["result"]["pairs"])
|
||||
canon = resp["result"]["canon"]
|
||||
|
||||
assert "/status" in pairs
|
||||
assert canon["/status"] == "/status"
|
||||
|
||||
assert "/topic" not in pairs
|
||||
assert "/approve" not in pairs
|
||||
assert "/deny" not in pairs
|
||||
assert "/sethome" not in pairs
|
||||
|
||||
assert "/topic" not in canon
|
||||
assert "/approve" not in canon
|
||||
assert "/deny" not in canon
|
||||
assert "/set-home" not in canon
|
||||
|
||||
|
||||
def test_session_status_reads_live_gateway_agent(monkeypatch):
|
||||
agent = types.SimpleNamespace(
|
||||
model="live-model",
|
||||
provider="live-provider",
|
||||
session_total_tokens=1234,
|
||||
)
|
||||
server._sessions["sid"] = _session(agent=agent, running=True)
|
||||
|
||||
class _DB:
|
||||
def get_session(self, key):
|
||||
assert key == "session-key"
|
||||
return {
|
||||
"title": "Live TUI",
|
||||
"started_at": 1_700_000_000,
|
||||
"updated_at": 1_700_000_060,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(server, "_get_db", lambda: _DB())
|
||||
try:
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "session.status", "params": {"session_id": "sid"}}
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
out = resp["result"]["output"]
|
||||
assert "Hermes TUI Status" in out
|
||||
assert "Session ID: session-key" in out
|
||||
assert "Title: Live TUI" in out
|
||||
assert "Model: live-model (live-provider)" in out
|
||||
assert "Tokens: 1,234" in out
|
||||
assert "Agent Running: Yes" in out
|
||||
|
||||
|
||||
def test_skills_reload_runs_in_gateway_process(monkeypatch):
|
||||
import agent.skill_commands as skill_commands
|
||||
|
||||
called = {}
|
||||
monkeypatch.setattr(
|
||||
skill_commands,
|
||||
"reload_skills",
|
||||
lambda: called.setdefault(
|
||||
"result",
|
||||
{
|
||||
"added": [{"name": "new-skill", "description": "demo"}],
|
||||
"removed": [],
|
||||
"total": 42,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
resp = server.handle_request({"id": "1", "method": "skills.reload", "params": {}})
|
||||
|
||||
assert called["result"]["total"] == 42
|
||||
assert "new-skill" in resp["result"]["output"]
|
||||
assert "42 skill(s) available" in resp["result"]["output"]
|
||||
|
||||
|
||||
def test_snapshot_restore_is_blocked_from_tui_worker():
|
||||
server._sessions["sid"] = _session()
|
||||
try:
|
||||
worker_resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "slash.exec",
|
||||
"params": {"command": "snapshot restore latest", "session_id": "sid"},
|
||||
}
|
||||
)
|
||||
dispatch_resp = server.handle_request(
|
||||
{
|
||||
"id": "2",
|
||||
"method": "command.dispatch",
|
||||
"params": {
|
||||
"arg": "restore latest",
|
||||
"name": "snapshot",
|
||||
"session_id": "sid",
|
||||
},
|
||||
}
|
||||
)
|
||||
finally:
|
||||
server._sessions.pop("sid", None)
|
||||
|
||||
assert worker_resp["error"]["code"] == 4018
|
||||
assert (
|
||||
"snapshot restore mutates live config/state" in worker_resp["error"]["message"]
|
||||
)
|
||||
assert dispatch_resp["result"]["type"] == "exec"
|
||||
assert (
|
||||
"/snapshot restore is blocked in the TUI" in dispatch_resp["result"]["output"]
|
||||
)
|
||||
|
||||
|
||||
def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
server,
|
||||
|
|
@ -4161,9 +4303,7 @@ def test_reload_env_rpc_calls_hermes_cli_reload_env(monkeypatch):
|
|||
|
||||
fake = types.SimpleNamespace(reload_env=_fake_reload)
|
||||
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "reload.env", "params": {}}
|
||||
)
|
||||
resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}})
|
||||
|
||||
assert resp["result"] == {"updated": 7}
|
||||
assert calls["n"] == 1
|
||||
|
|
@ -4175,9 +4315,7 @@ def test_reload_env_rpc_surfaces_errors(monkeypatch):
|
|||
|
||||
fake = types.SimpleNamespace(reload_env=_broken)
|
||||
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "reload.env", "params": {}}
|
||||
)
|
||||
resp = server.handle_request({"id": "1", "method": "reload.env", "params": {}})
|
||||
|
||||
assert "error" in resp
|
||||
assert "env path locked" in resp["error"]["message"]
|
||||
|
|
@ -4188,7 +4326,9 @@ def test_reload_env_rpc_surfaces_errors(monkeypatch):
|
|||
|
||||
def _setup_make_agent_mocks(monkeypatch, cfg):
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: cfg)
|
||||
monkeypatch.setattr(server, "_resolve_startup_runtime", lambda: ("test-model", None))
|
||||
monkeypatch.setattr(
|
||||
server, "_resolve_startup_runtime", lambda: ("test-model", None)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda requested=None, target_model=None: {
|
||||
|
|
@ -4219,7 +4359,9 @@ def test_make_agent_reads_nested_max_turns(monkeypatch):
|
|||
|
||||
|
||||
def test_make_agent_nested_max_turns_takes_priority(monkeypatch):
|
||||
_setup_make_agent_mocks(monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100})
|
||||
_setup_make_agent_mocks(
|
||||
monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100}
|
||||
)
|
||||
|
||||
with patch("run_agent.AIAgent") as mock_agent:
|
||||
server._make_agent("sid1", "key1")
|
||||
|
|
@ -4309,6 +4451,8 @@ def test_config_show_displays_nested_max_turns(monkeypatch):
|
|||
|
||||
resp = server.handle_request({"id": "1", "method": "config.show", "params": {}})
|
||||
sections = resp["result"]["sections"]
|
||||
agent_rows = next(section["rows"] for section in sections if section["title"] == "Agent")
|
||||
agent_rows = next(
|
||||
section["rows"] for section in sections if section["title"] == "Agent"
|
||||
)
|
||||
|
||||
assert ["Max Turns", "120"] in agent_rows
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Without resolve_runtime_provider(), bare-slug models in config
|
|||
provider/base_url/api_key empty in AIAgent, causing HTTP 404.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
|
|
@ -97,6 +98,48 @@ def test_make_agent_ignores_display_personality_without_system_prompt():
|
|||
assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None
|
||||
|
||||
|
||||
def test_make_agent_honors_tui_launch_env_flags():
|
||||
fake_runtime = {
|
||||
"provider": "openrouter",
|
||||
"base_url": "https://api.synthetic.new/v1",
|
||||
"api_key": "sk-test",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": None,
|
||||
"credential_pool": None,
|
||||
}
|
||||
fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "glm-5"}}
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HERMES_TUI_MAX_TURNS": "7",
|
||||
"HERMES_TUI_CHECKPOINTS": "1",
|
||||
"HERMES_TUI_PASS_SESSION_ID": "1",
|
||||
"HERMES_IGNORE_RULES": "1",
|
||||
},
|
||||
),
|
||||
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
|
||||
patch("tui_gateway.server._get_db", return_value=MagicMock()),
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=fake_runtime,
|
||||
),
|
||||
patch("run_agent.AIAgent") as mock_agent,
|
||||
):
|
||||
from tui_gateway.server import _make_agent
|
||||
|
||||
_make_agent("sid-env", "key-env")
|
||||
|
||||
kwargs = mock_agent.call_args.kwargs
|
||||
assert kwargs["max_iterations"] == 7
|
||||
assert kwargs["checkpoints_enabled"] is True
|
||||
assert kwargs["pass_session_id"] is True
|
||||
assert kwargs["skip_context_files"] is True
|
||||
assert kwargs["skip_memory"] is True
|
||||
|
||||
|
||||
def test_probe_config_health_flags_null_sections():
|
||||
"""Bare YAML keys (`agent:` with no value) parse as None and silently
|
||||
drop nested settings; probe must surface them so users can fix."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue