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:
brooklyn! 2026-05-05 13:42:39 -07:00 committed by GitHub
parent acca3ec3af
commit 794f48766c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1266 additions and 284 deletions

View file

@ -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"

View file

@ -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

View file

@ -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."""