mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +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
File diff suppressed because it is too large
Load diff
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -157,7 +157,9 @@ _LONG_HANDLERS = frozenset(
|
|||
)
|
||||
|
||||
try:
|
||||
_rpc_pool_workers = max(2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "4"))
|
||||
_rpc_pool_workers = max(
|
||||
2, int(os.environ.get("HERMES_TUI_RPC_POOL_WORKERS") or "4")
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
_rpc_pool_workers = 4
|
||||
_pool = concurrent.futures.ThreadPoolExecutor(
|
||||
|
|
@ -567,7 +569,10 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
|||
register_gateway_notify,
|
||||
load_permanent_allowlist,
|
||||
)
|
||||
register_gateway_notify(key, lambda data: _emit("approval.request", sid, data))
|
||||
|
||||
register_gateway_notify(
|
||||
key, lambda data: _emit("approval.request", sid, data)
|
||||
)
|
||||
notify_registered = True
|
||||
load_permanent_allowlist()
|
||||
except Exception:
|
||||
|
|
@ -598,6 +603,7 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
|||
if notify_registered:
|
||||
try:
|
||||
from tools.approval import unregister_gateway_notify
|
||||
|
||||
unregister_gateway_notify(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -877,6 +883,9 @@ def _load_show_reasoning() -> bool:
|
|||
|
||||
|
||||
def _load_tool_progress_mode() -> str:
|
||||
env = os.environ.get("HERMES_TUI_TOOL_PROGRESS", "").strip().lower()
|
||||
if env in {"off", "new", "all", "verbose"}:
|
||||
return env
|
||||
raw = (_load_cfg().get("display") or {}).get("tool_progress", "all")
|
||||
if raw is False:
|
||||
return "off"
|
||||
|
|
@ -938,7 +947,11 @@ def _load_enabled_toolsets() -> list[str] | None:
|
|||
from hermes_cli.tools_config import _parse_enabled_flag
|
||||
|
||||
raw_cfg = read_raw_config()
|
||||
mcp_servers = raw_cfg.get("mcp_servers") if isinstance(raw_cfg.get("mcp_servers"), dict) else {}
|
||||
mcp_servers = (
|
||||
raw_cfg.get("mcp_servers")
|
||||
if isinstance(raw_cfg.get("mcp_servers"), dict)
|
||||
else {}
|
||||
)
|
||||
for name, server_cfg in mcp_servers.items():
|
||||
if not isinstance(server_cfg, dict):
|
||||
continue
|
||||
|
|
@ -952,7 +965,11 @@ def _load_enabled_toolsets() -> list[str] | None:
|
|||
|
||||
mcp_valid = [name for name in unresolved if name in mcp_names]
|
||||
disabled = [name for name in unresolved if name in mcp_disabled]
|
||||
unknown = [name for name in unresolved if name not in mcp_names and name not in mcp_disabled]
|
||||
unknown = [
|
||||
name
|
||||
for name in unresolved
|
||||
if name not in mcp_names and name not in mcp_disabled
|
||||
]
|
||||
valid = built_in + mcp_valid
|
||||
|
||||
if unknown:
|
||||
|
|
@ -973,7 +990,9 @@ def _load_enabled_toolsets() -> list[str] | None:
|
|||
if valid:
|
||||
return valid
|
||||
|
||||
fallback_notice = "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets"
|
||||
fallback_notice = (
|
||||
"[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets"
|
||||
)
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
|
@ -1715,10 +1734,28 @@ def _apply_personality_to_session(
|
|||
|
||||
|
||||
def _cfg_max_turns(cfg: dict, default: int) -> int:
|
||||
try:
|
||||
env_max = int(os.environ.get("HERMES_TUI_MAX_TURNS", "") or 0)
|
||||
if env_max > 0:
|
||||
return env_max
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
agent_cfg = cfg.get("agent") or {}
|
||||
return int(agent_cfg.get("max_turns") or cfg.get("max_turns") or default)
|
||||
|
||||
|
||||
def _parse_tui_skills_env() -> list[str]:
|
||||
raw = os.environ.get("HERMES_TUI_SKILLS", "")
|
||||
skills: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for part in raw.replace("\n", ",").split(","):
|
||||
item = part.strip()
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
skills.append(item)
|
||||
return skills
|
||||
|
||||
|
||||
def _background_agent_kwargs(agent, task_id: str) -> dict:
|
||||
cfg = _load_cfg()
|
||||
|
||||
|
|
@ -1788,6 +1825,20 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
|
|||
cfg = _load_cfg()
|
||||
agent_cfg = cfg.get("agent") or {}
|
||||
system_prompt = (agent_cfg.get("system_prompt", "") or "").strip()
|
||||
startup_skills = _parse_tui_skills_env()
|
||||
if startup_skills:
|
||||
from agent.skill_commands import build_preloaded_skills_prompt
|
||||
|
||||
skills_prompt, _loaded_skills, missing_skills = build_preloaded_skills_prompt(
|
||||
startup_skills,
|
||||
task_id=session_id or key,
|
||||
)
|
||||
if missing_skills:
|
||||
raise ValueError(f"Unknown skill(s): {', '.join(missing_skills)}")
|
||||
if skills_prompt:
|
||||
system_prompt = "\n\n".join(
|
||||
part for part in (system_prompt, skills_prompt) if part
|
||||
).strip()
|
||||
model, requested_provider = _resolve_startup_runtime()
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=requested_provider,
|
||||
|
|
@ -1812,6 +1863,10 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
|
|||
session_id=session_id or key,
|
||||
session_db=_get_db(),
|
||||
ephemeral_system_prompt=system_prompt or None,
|
||||
checkpoints_enabled=is_truthy_value(os.environ.get("HERMES_TUI_CHECKPOINTS")),
|
||||
pass_session_id=is_truthy_value(os.environ.get("HERMES_TUI_PASS_SESSION_ID")),
|
||||
skip_context_files=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
|
||||
skip_memory=is_truthy_value(os.environ.get("HERMES_IGNORE_RULES")),
|
||||
**_agent_cbs(sid),
|
||||
)
|
||||
|
||||
|
|
@ -1856,10 +1911,8 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
|||
# prompt_toolkit; the TUI has no equivalent print surface, so without
|
||||
# this callback the review would write the skill/memory change silently.
|
||||
try:
|
||||
agent.background_review_callback = (
|
||||
lambda message, _sid=sid: _emit(
|
||||
"review.summary", _sid, {"text": str(message)}
|
||||
)
|
||||
agent.background_review_callback = lambda message, _sid=sid: _emit(
|
||||
"review.summary", _sid, {"text": str(message)}
|
||||
)
|
||||
except Exception:
|
||||
# Bare AIAgents that don't expose the attribute (unlikely, but keep
|
||||
|
|
@ -2269,7 +2322,71 @@ def _(rid, params: dict) -> dict:
|
|||
if err:
|
||||
return err
|
||||
agent = session.get("agent")
|
||||
return _ok(rid, _get_usage(agent) if agent is not None else {"calls": 0, "input": 0, "output": 0, "total": 0})
|
||||
return _ok(
|
||||
rid,
|
||||
(
|
||||
_get_usage(agent)
|
||||
if agent is not None
|
||||
else {"calls": 0, "input": 0, "output": 0, "total": 0}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@method("session.status")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess_nowait(params, rid)
|
||||
if err:
|
||||
return err
|
||||
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
key = session.get("session_key") or params.get("session_id") or ""
|
||||
agent = session.get("agent")
|
||||
meta = {}
|
||||
db = _get_db()
|
||||
if db and key:
|
||||
try:
|
||||
meta = db.get_session(key) or {}
|
||||
except Exception:
|
||||
meta = {}
|
||||
|
||||
def _dt(value, fallback: datetime | None = None) -> datetime:
|
||||
if value:
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value))
|
||||
except Exception:
|
||||
pass
|
||||
return fallback or datetime.now()
|
||||
|
||||
created = _dt(meta.get("started_at"))
|
||||
updated = created
|
||||
for field in ("updated_at", "last_updated_at", "last_activity_at"):
|
||||
if meta.get(field):
|
||||
updated = _dt(meta.get(field), created)
|
||||
break
|
||||
|
||||
usage = _get_usage(agent) if agent is not None else {}
|
||||
provider = getattr(agent, "provider", None) or "unknown"
|
||||
model = getattr(agent, "model", None) or "(unknown)"
|
||||
lines = [
|
||||
"Hermes TUI Status",
|
||||
"",
|
||||
f"Session ID: {key}",
|
||||
f"Path: {display_hermes_home()}",
|
||||
]
|
||||
title = (meta.get("title") or "").strip()
|
||||
if title:
|
||||
lines.append(f"Title: {title}")
|
||||
lines.extend(
|
||||
[
|
||||
f"Model: {model} ({provider})",
|
||||
f"Created: {created.strftime('%Y-%m-%d %H:%M')}",
|
||||
f"Last Activity: {updated.strftime('%Y-%m-%d %H:%M')}",
|
||||
f"Tokens: {int(usage.get('total') or 0):,}",
|
||||
f"Agent Running: {'Yes' if session.get('running') else 'No'}",
|
||||
]
|
||||
)
|
||||
return _ok(rid, {"output": "\n".join(lines)})
|
||||
|
||||
|
||||
@method("session.history")
|
||||
|
|
@ -2375,7 +2492,9 @@ def _(rid, params: dict) -> dict:
|
|||
after_count = len(messages)
|
||||
# Re-read system prompt + tools after compression — _compress_context
|
||||
# may have rebuilt the system prompt (_cached_system_prompt=None).
|
||||
_sys_prompt_after = getattr(_agent, "_cached_system_prompt", "") or _sys_prompt
|
||||
_sys_prompt_after = (
|
||||
getattr(_agent, "_cached_system_prompt", "") or _sys_prompt
|
||||
)
|
||||
_tools_after = getattr(_agent, "tools", None) or _tools
|
||||
after_tokens = (
|
||||
estimate_request_tokens_rough(
|
||||
|
|
@ -2823,7 +2942,15 @@ def _(rid, params: dict) -> dict:
|
|||
def run_after_agent_ready() -> None:
|
||||
err = _wait_agent(session, rid)
|
||||
if err:
|
||||
_emit("error", sid, {"message": err.get("error", {}).get("message", "agent initialization failed")})
|
||||
_emit(
|
||||
"error",
|
||||
sid,
|
||||
{
|
||||
"message": err.get("error", {}).get(
|
||||
"message", "agent initialization failed"
|
||||
)
|
||||
},
|
||||
)
|
||||
with session["history_lock"]:
|
||||
session["running"] = False
|
||||
return
|
||||
|
|
@ -2867,7 +2994,9 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
base_url=getattr(agent, "base_url", "") or "",
|
||||
api_key=getattr(agent, "api_key", "") or "",
|
||||
provider=getattr(agent, "provider", "") or "",
|
||||
config_context_length=getattr(agent, "_config_context_length", None),
|
||||
config_context_length=getattr(
|
||||
agent, "_config_context_length", None
|
||||
),
|
||||
)
|
||||
ctx = preprocess_context_references(
|
||||
prompt,
|
||||
|
|
@ -3024,18 +3153,14 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
# ("✓ Goal achieved" / "⏸ budget exhausted") is surfaced as
|
||||
# a system line so the user sees progress regardless of
|
||||
# outcome. Mirrors gateway/run._post_turn_goal_continuation.
|
||||
if (
|
||||
status == "complete"
|
||||
and isinstance(raw, str)
|
||||
and raw.strip()
|
||||
):
|
||||
if status == "complete" and isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
from hermes_cli.goals import GoalManager
|
||||
|
||||
sid_key = session.get("session_key") or ""
|
||||
if sid_key:
|
||||
try:
|
||||
goals_cfg = (_load_cfg().get("goals") or {})
|
||||
goals_cfg = _load_cfg().get("goals") or {}
|
||||
goal_max_turns = int(goals_cfg.get("max_turns", 20) or 20)
|
||||
except Exception:
|
||||
goal_max_turns = 20
|
||||
|
|
@ -3045,7 +3170,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
)
|
||||
if goal_mgr.is_active():
|
||||
decision = goal_mgr.evaluate_after_turn(
|
||||
raw, user_initiated=True,
|
||||
raw,
|
||||
user_initiated=True,
|
||||
)
|
||||
verdict_msg = decision.get("message") or ""
|
||||
if verdict_msg:
|
||||
|
|
@ -3578,7 +3704,9 @@ def _(rid, params: dict) -> dict:
|
|||
arg = str(value or "").strip().lower()
|
||||
if arg in ("show", "on"):
|
||||
cfg = _load_cfg()
|
||||
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
|
||||
display = (
|
||||
cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
|
||||
)
|
||||
sections = (
|
||||
display.get("sections")
|
||||
if isinstance(display.get("sections"), dict)
|
||||
|
|
@ -3594,7 +3722,9 @@ def _(rid, params: dict) -> dict:
|
|||
return _ok(rid, {"key": key, "value": "show"})
|
||||
if arg in ("hide", "off"):
|
||||
cfg = _load_cfg()
|
||||
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
|
||||
display = (
|
||||
cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
|
||||
)
|
||||
sections = (
|
||||
display.get("sections")
|
||||
if isinstance(display.get("sections"), dict)
|
||||
|
|
@ -3625,7 +3755,9 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 4002, f"unknown details_mode: {value}")
|
||||
cfg = _load_cfg()
|
||||
display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {}
|
||||
sections = display.get("sections") if isinstance(display.get("sections"), dict) else {}
|
||||
sections = (
|
||||
display.get("sections") if isinstance(display.get("sections"), dict) else {}
|
||||
)
|
||||
display["details_mode"] = nv
|
||||
for section in _DETAIL_SECTION_NAMES:
|
||||
sections[section] = nv
|
||||
|
|
@ -3952,6 +4084,7 @@ def _(rid, params: dict) -> dict:
|
|||
if not user_confirm:
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_config
|
||||
|
||||
_cfg = _load_config()
|
||||
_approvals = _cfg.get("approvals") if isinstance(_cfg, dict) else None
|
||||
_confirm_required = True
|
||||
|
|
@ -3965,15 +4098,18 @@ def _(rid, params: dict) -> dict:
|
|||
# Ink's ops.ts reads ``status`` and prints ``message`` to
|
||||
# the transcript; a follow-up invocation with confirm=true
|
||||
# (or an `always` choice that flips the config) proceeds.
|
||||
return _ok(rid, {
|
||||
"status": "confirm_required",
|
||||
"message": (
|
||||
"⚠️ /reload-mcp invalidates the prompt cache (next "
|
||||
"message re-sends full input tokens). Reply `/reload-mcp "
|
||||
"now` to proceed, or `/reload-mcp always` to proceed and "
|
||||
"silence this prompt permanently."
|
||||
),
|
||||
})
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"status": "confirm_required",
|
||||
"message": (
|
||||
"⚠️ /reload-mcp invalidates the prompt cache (next "
|
||||
"message re-sends full input tokens). Reply `/reload-mcp "
|
||||
"now` to proceed, or `/reload-mcp always` to proceed and "
|
||||
"silence this prompt permanently."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools
|
||||
|
||||
|
|
@ -3989,6 +4125,7 @@ def _(rid, params: dict) -> dict:
|
|||
if bool(params.get("always", False)):
|
||||
try:
|
||||
from cli import save_config_value as _save_cfg
|
||||
|
||||
_save_cfg("approvals.mcp_reload_confirm", False)
|
||||
except Exception as _exc:
|
||||
logger.warning("Failed to persist mcp_reload_confirm=false: %s", _exc)
|
||||
|
|
@ -4025,7 +4162,6 @@ _TUI_HIDDEN: frozenset[str] = frozenset(
|
|||
"set-home",
|
||||
"update",
|
||||
"commands",
|
||||
"status",
|
||||
"approve",
|
||||
"deny",
|
||||
}
|
||||
|
|
@ -4051,6 +4187,8 @@ _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
_WORKER_BLOCKED_COMMANDS: frozenset[str] = frozenset({"snapshot", "snap"})
|
||||
|
||||
|
||||
@method("commands.catalog")
|
||||
def _(rid, params: dict) -> dict:
|
||||
|
|
@ -4069,14 +4207,14 @@ def _(rid, params: dict) -> dict:
|
|||
cat_order: list[str] = []
|
||||
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.name in _TUI_HIDDEN or cmd.gateway_only:
|
||||
continue
|
||||
|
||||
c = f"/{cmd.name}"
|
||||
canon[c.lower()] = c
|
||||
for a in cmd.aliases:
|
||||
canon[f"/{a}".lower()] = c
|
||||
|
||||
if cmd.name in _TUI_HIDDEN:
|
||||
continue
|
||||
|
||||
desc = _build_description(cmd)
|
||||
all_pairs.append([c, desc])
|
||||
|
||||
|
|
@ -4373,7 +4511,7 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 4001, "no session key")
|
||||
|
||||
try:
|
||||
goals_cfg = (_load_cfg().get("goals") or {})
|
||||
goals_cfg = _load_cfg().get("goals") or {}
|
||||
max_turns = int(goals_cfg.get("max_turns", 20) or 20)
|
||||
except Exception:
|
||||
max_turns = 20
|
||||
|
|
@ -4431,6 +4569,21 @@ def _(rid, params: dict) -> dict:
|
|||
{"type": "send", "notice": notice, "message": state.goal},
|
||||
)
|
||||
|
||||
if name in ("snapshot", "snap"):
|
||||
subcommand = arg.split(maxsplit=1)[0].lower() if arg else ""
|
||||
if subcommand in {"restore", "rewind"}:
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"type": "exec",
|
||||
"output": (
|
||||
"/snapshot restore is blocked in the TUI because it changes "
|
||||
"config/state on disk while the live agent has cached settings. "
|
||||
"Run it in the classic CLI, then restart the TUI."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")
|
||||
|
||||
|
||||
|
|
@ -4967,6 +5120,7 @@ def _(rid, params: dict) -> dict:
|
|||
|
||||
# Build final list in CANONICAL_PROVIDERS order, merging auth data
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY as _auth_reg
|
||||
|
||||
ordered: list = []
|
||||
for entry in CANONICAL_PROVIDERS:
|
||||
if entry.slug in authed_map:
|
||||
|
|
@ -4974,24 +5128,30 @@ def _(rid, params: dict) -> dict:
|
|||
else:
|
||||
pconfig = _auth_reg.get(entry.slug)
|
||||
auth_type = pconfig.auth_type if pconfig else "api_key"
|
||||
key_env = pconfig.api_key_env_vars[0] if (pconfig and pconfig.api_key_env_vars) else ""
|
||||
key_env = (
|
||||
pconfig.api_key_env_vars[0]
|
||||
if (pconfig and pconfig.api_key_env_vars)
|
||||
else ""
|
||||
)
|
||||
if auth_type == "api_key" and key_env:
|
||||
warning = f"paste {key_env} to activate"
|
||||
else:
|
||||
warning = f"run `hermes model` to configure ({auth_type})"
|
||||
ordered.append({
|
||||
"slug": entry.slug,
|
||||
"name": _PROVIDER_LABELS.get(entry.slug, entry.label),
|
||||
"is_current": entry.slug == current_provider,
|
||||
"is_user_defined": False,
|
||||
"models": [],
|
||||
"total_models": 0,
|
||||
"source": "built-in",
|
||||
"authenticated": False,
|
||||
"auth_type": auth_type,
|
||||
"key_env": key_env,
|
||||
"warning": warning,
|
||||
})
|
||||
ordered.append(
|
||||
{
|
||||
"slug": entry.slug,
|
||||
"name": _PROVIDER_LABELS.get(entry.slug, entry.label),
|
||||
"is_current": entry.slug == current_provider,
|
||||
"is_user_defined": False,
|
||||
"models": [],
|
||||
"total_models": 0,
|
||||
"source": "built-in",
|
||||
"authenticated": False,
|
||||
"auth_type": auth_type,
|
||||
"key_env": key_env,
|
||||
"warning": warning,
|
||||
}
|
||||
)
|
||||
|
||||
# Append user-defined/custom providers not in canonical list
|
||||
ordered.extend(authed_extra)
|
||||
|
|
@ -5037,9 +5197,10 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 4002, f"unknown provider: {slug}")
|
||||
if pconfig.auth_type != "api_key":
|
||||
return _err(
|
||||
rid, 4003,
|
||||
rid,
|
||||
4003,
|
||||
f"{pconfig.name} uses {pconfig.auth_type} auth — "
|
||||
f"run `hermes model` to configure"
|
||||
f"run `hermes model` to configure",
|
||||
)
|
||||
if not pconfig.api_key_env_vars:
|
||||
return _err(rid, 4004, f"no env var defined for {pconfig.name}")
|
||||
|
|
@ -5049,6 +5210,7 @@ def _(rid, params: dict) -> dict:
|
|||
save_env_value(env_var, api_key)
|
||||
# Also set in current process so list_authenticated_providers sees it
|
||||
import os
|
||||
|
||||
os.environ[env_var] = api_key
|
||||
|
||||
# Refresh provider data
|
||||
|
|
@ -5132,11 +5294,14 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 4005, f"no credentials found for {slug}")
|
||||
|
||||
provider_name = pconfig.name if pconfig else slug
|
||||
return _ok(rid, {
|
||||
"slug": slug,
|
||||
"name": provider_name,
|
||||
"disconnected": True,
|
||||
})
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"slug": slug,
|
||||
"name": provider_name,
|
||||
"disconnected": True,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return _err(rid, 5035, str(e))
|
||||
|
||||
|
|
@ -5222,6 +5387,15 @@ def _(rid, params: dict) -> dict:
|
|||
rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}"
|
||||
)
|
||||
|
||||
if _cmd_base in _WORKER_BLOCKED_COMMANDS:
|
||||
subcommand = _cmd_arg.split(maxsplit=1)[0].lower() if _cmd_arg else ""
|
||||
if subcommand in {"restore", "rewind"}:
|
||||
return _err(
|
||||
rid,
|
||||
4018,
|
||||
"snapshot restore mutates live config/state; use command.dispatch for /snapshot restore",
|
||||
)
|
||||
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
|
||||
|
|
@ -5471,8 +5645,17 @@ def _(rid, params: dict) -> dict:
|
|||
voice_cfg = _voice_cfg_dict()
|
||||
threshold = voice_cfg.get("silence_threshold")
|
||||
duration = voice_cfg.get("silence_duration")
|
||||
safe_threshold = threshold if isinstance(threshold, (int, float)) and not isinstance(threshold, bool) else 200
|
||||
safe_duration = duration if isinstance(duration, (int, float)) and not isinstance(duration, bool) else 3.0
|
||||
safe_threshold = (
|
||||
threshold
|
||||
if isinstance(threshold, (int, float))
|
||||
and not isinstance(threshold, bool)
|
||||
else 200
|
||||
)
|
||||
safe_duration = (
|
||||
duration
|
||||
if isinstance(duration, (int, float)) and not isinstance(duration, bool)
|
||||
else 3.0
|
||||
)
|
||||
start_continuous(
|
||||
on_transcript=lambda t: _voice_emit("voice.transcript", {"text": t}),
|
||||
on_status=lambda s: _voice_emit("voice.status", {"state": s}),
|
||||
|
|
@ -5772,7 +5955,9 @@ def _browser_connect(rid, params: dict) -> dict:
|
|||
|
||||
raw_url = params.get("url")
|
||||
if raw_url is not None and not isinstance(raw_url, str):
|
||||
return _err(rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}")
|
||||
return _err(
|
||||
rid, 4015, f"browser url must be a string, got {type(raw_url).__name__}"
|
||||
)
|
||||
url = (raw_url or "").strip() or DEFAULT_BROWSER_CDP_URL
|
||||
|
||||
sid = params.get("session_id") or ""
|
||||
|
|
@ -6225,6 +6410,31 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 5024, str(e))
|
||||
|
||||
|
||||
@method("skills.reload")
|
||||
def _(rid, params: dict) -> dict:
|
||||
try:
|
||||
from agent.skill_commands import reload_skills
|
||||
|
||||
result = reload_skills()
|
||||
added = result.get("added") or []
|
||||
removed = result.get("removed") or []
|
||||
total = int(result.get("total") or 0)
|
||||
|
||||
lines = ["Reloading skills..."]
|
||||
if not added and not removed:
|
||||
lines.append("No new skills detected.")
|
||||
if added:
|
||||
lines.append("Added skills:")
|
||||
lines.extend(f" - {item.get('name', '')}" for item in added)
|
||||
if removed:
|
||||
lines.append("Removed skills:")
|
||||
lines.extend(f" - {item.get('name', '')}" for item in removed)
|
||||
lines.append(f"{total} skill(s) available")
|
||||
return _ok(rid, {"output": "\n".join(lines), "result": result})
|
||||
except Exception as e:
|
||||
return _err(rid, 5025, str(e))
|
||||
|
||||
|
||||
# ── Methods: shell ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,27 @@ describe('createSlashHandler', () => {
|
|||
expect(getOverlayState().picker).toBe(true)
|
||||
})
|
||||
|
||||
it('handles /redraw locally without slash worker fallback', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/redraw')).toBe(true)
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn')
|
||||
})
|
||||
|
||||
it('routes /status to live session.status instead of slash worker', async () => {
|
||||
patchUiState({ sid: 'sid-abc' })
|
||||
const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' }))
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
expect(createSlashHandler(ctx)('/status')).toBe(true)
|
||||
expect(rpc).toHaveBeenCalledWith('session.status', { session_id: 'sid-abc' })
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.page).toHaveBeenCalledWith('Hermes TUI Status', 'Status')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps typed /model switches session-scoped by default', async () => {
|
||||
patchUiState({ sid: 'sid-abc' })
|
||||
|
||||
|
|
@ -157,12 +178,49 @@ describe('createSlashHandler', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('shows usage for an unknown /skills subcommand', () => {
|
||||
it('delegates non-native /skills subcommands to slash.exec', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/skills zzz')
|
||||
createSlashHandler(ctx)('/skills check')
|
||||
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills'))
|
||||
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
|
||||
command: 'skills check',
|
||||
session_id: null
|
||||
})
|
||||
})
|
||||
|
||||
it('passes /new <title> through to the session lifecycle', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
createSlashHandler(ctx)('/new sprint planning')
|
||||
getOverlayState().confirm?.onConfirm()
|
||||
|
||||
expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', 'sprint planning')
|
||||
expect(ctx.gateway.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reloads skills in the live gateway and refreshes the catalog', async () => {
|
||||
const rpc = vi.fn((method: string) => {
|
||||
if (method === 'skills.reload') {
|
||||
return Promise.resolve({ output: '42 skill(s) available' })
|
||||
}
|
||||
if (method === 'commands.catalog') {
|
||||
return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
|
||||
|
||||
createSlashHandler(ctx)('/reload-skills')
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('skills.reload', {})
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.transcript.page).toHaveBeenCalledWith('42 skill(s) available', 'Reload Skills')
|
||||
expect(ctx.local.setCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
|
||||
)
|
||||
})
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Regressions from Copilot review on #19835: /voice output + frontend
|
||||
|
|
@ -192,9 +250,7 @@ describe('createSlashHandler', () => {
|
|||
expect(ctx.transcript.sys).toHaveBeenCalledWith('Voice mode enabled')
|
||||
expect(ctx.transcript.sys).toHaveBeenCalledWith(' Alt+R to start/stop recording')
|
||||
})
|
||||
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ch: 'r', mod: 'alt' })
|
||||
)
|
||||
expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'r', mod: 'alt' }))
|
||||
})
|
||||
|
||||
it('/voice falls back to Ctrl+B when the gateway response omits record_key', async () => {
|
||||
|
|
@ -447,17 +503,17 @@ describe('createSlashHandler', () => {
|
|||
local: {
|
||||
catalog: {
|
||||
canon: {
|
||||
'/status': '/status',
|
||||
'/statusbar': '/statusbar'
|
||||
'/profile': '/profile',
|
||||
'/plugins': '/plugins'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(createSlashHandler(ctx)('/status')).toBe(true)
|
||||
expect(createSlashHandler(ctx)('/profile')).toBe(true)
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
|
||||
command: 'status',
|
||||
command: 'profile',
|
||||
session_id: null
|
||||
})
|
||||
})
|
||||
|
|
@ -675,7 +731,8 @@ const buildLocal = () => ({
|
|||
catalog: null,
|
||||
getHistoryItems: vi.fn(() => []),
|
||||
getLastUserMsg: vi.fn(() => ''),
|
||||
maybeWarn: vi.fn()
|
||||
maybeWarn: vi.fn(),
|
||||
setCatalog: vi.fn()
|
||||
})
|
||||
|
||||
const buildSession = () => ({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js'
|
||||
import { STREAM_BATCH_MS } from '../config/timing.js'
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import { SETUP_REQUIRED_TITLE, buildSetupRequiredSections } from '../content/setup.js'
|
||||
import type {
|
||||
CommandsCatalogResponse,
|
||||
ConfigFullResponse,
|
||||
|
|
@ -64,6 +65,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
let pendingThinkingStatus = ''
|
||||
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
|
||||
let startupPromptSubmitted = false
|
||||
|
||||
// Inject the disk-save callback into turnController so recordMessageComplete
|
||||
// can fire-and-forget a persist without having to plumb a gateway ref around.
|
||||
|
|
@ -146,6 +148,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}, ms)
|
||||
}
|
||||
|
||||
const scheduleStartupPrompt = () => {
|
||||
if (startupPromptSubmitted || (!STARTUP_QUERY && !STARTUP_IMAGE)) {
|
||||
return
|
||||
}
|
||||
|
||||
startupPromptSubmitted = true
|
||||
setTimeout(async () => {
|
||||
let sid = getUiState().sid
|
||||
|
||||
for (let i = 0; !sid && i < 40; i += 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
sid = getUiState().sid
|
||||
}
|
||||
|
||||
if (!sid) {
|
||||
return sys('startup query skipped: no active session')
|
||||
}
|
||||
|
||||
if (STARTUP_IMAGE) {
|
||||
try {
|
||||
await rpc('image.attach', { path: STARTUP_IMAGE, session_id: sid })
|
||||
} catch (e) {
|
||||
sys(`startup image attach failed: ${rpcErrorMessage(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
submitRef.current(STARTUP_QUERY || 'What do you see in this image?')
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// Terminal statuses are never overwritten by late-arriving live events —
|
||||
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
|
||||
// `failed` or `interrupted` terminal state (Copilot review #14045).
|
||||
|
|
@ -181,6 +213,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
if (STARTUP_RESUME_ID) {
|
||||
patchUiState({ status: 'resuming…' })
|
||||
resumeById(STARTUP_RESUME_ID)
|
||||
scheduleStartupPrompt()
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -196,6 +229,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
if (!cfg?.config?.display?.tui_auto_resume_recent) {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
scheduleStartupPrompt()
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -206,17 +240,20 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
if (target) {
|
||||
patchUiState({ status: 'resuming most recent…' })
|
||||
resumeById(target)
|
||||
scheduleStartupPrompt()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
scheduleStartupPrompt()
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
newSession()
|
||||
scheduleStartupPrompt()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export interface InputHandlerActions {
|
|||
die: () => void
|
||||
dispatchSubmission: (full: string) => void
|
||||
guardBusySessionSwitch: (what?: string) => boolean
|
||||
newSession: (msg?: string) => void
|
||||
newSession: (msg?: string, title?: string) => void
|
||||
sys: (text: string) => void
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ export interface GatewayEventHandlerContext {
|
|||
session: {
|
||||
STARTUP_RESUME_ID: string
|
||||
colsRef: MutableRefObject<number>
|
||||
newSession: (msg?: string) => void
|
||||
newSession: (msg?: string, title?: string) => void
|
||||
resetSession: () => void
|
||||
resumeById: (id: string) => void
|
||||
setCatalog: StateSetter<null | SlashCatalog>
|
||||
|
|
@ -272,12 +272,13 @@ export interface SlashHandlerContext {
|
|||
getHistoryItems: () => Msg[]
|
||||
getLastUserMsg: () => string
|
||||
maybeWarn: (value: unknown) => void
|
||||
setCatalog: StateSetter<null | SlashCatalog>
|
||||
}
|
||||
session: {
|
||||
closeSession: (targetSid?: null | string) => Promise<unknown>
|
||||
die: () => void
|
||||
guardBusySessionSwitch: (what?: string) => boolean
|
||||
newSession: (msg?: string) => void
|
||||
newSession: (msg?: string, title?: string) => void
|
||||
resetVisibleHistory: (info?: null | SessionInfo) => void
|
||||
resumeById: (id: string) => void
|
||||
setSessionStartedAt: StateSetter<number>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { forceRedraw } from '@hermes/ink'
|
||||
|
||||
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
|
||||
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
|
||||
import { HOTKEYS } from '../../../content/hotkeys.js'
|
||||
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
|
||||
import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
|
||||
import type {
|
||||
ConfigGetValueResponse,
|
||||
ConfigSetResponse,
|
||||
SessionSaveResponse,
|
||||
SessionStatusResponse,
|
||||
SessionSteerResponse,
|
||||
SessionTitleResponse,
|
||||
SessionUndoResponse
|
||||
|
|
@ -112,16 +115,17 @@ export const coreCommands: SlashCommand[] = [
|
|||
aliases: ['new'],
|
||||
help: 'start a new session',
|
||||
name: 'clear',
|
||||
run: (_arg, ctx, cmd) => {
|
||||
run: (arg, ctx, cmd) => {
|
||||
if (ctx.session.guardBusySessionSwitch('switch sessions')) {
|
||||
return
|
||||
}
|
||||
|
||||
const isNew = cmd.startsWith('/new')
|
||||
const requestedTitle = isNew ? arg.trim() : ''
|
||||
|
||||
const commit = () => {
|
||||
patchUiState({ status: 'forging session…' })
|
||||
ctx.session.newSession(isNew ? 'new session started' : undefined)
|
||||
ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined)
|
||||
}
|
||||
|
||||
if (NO_CONFIRM_DESTRUCTIVE) {
|
||||
|
|
@ -141,6 +145,30 @@ export const coreCommands: SlashCommand[] = [
|
|||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'force a full UI repaint',
|
||||
name: 'redraw',
|
||||
run: (_arg, ctx) => {
|
||||
forceRedraw(process.stdout)
|
||||
ctx.transcript.sys('ui redrawn')
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'show live session info',
|
||||
name: 'status',
|
||||
run: (_arg, ctx) => {
|
||||
if (!ctx.sid) {
|
||||
return ctx.transcript.sys('no active session')
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
.rpc<SessionStatusResponse>('session.status', { session_id: ctx.sid })
|
||||
.then(ctx.guarded<SessionStatusResponse>(r => ctx.transcript.page(r.output || '(no status)', 'Status')))
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'resume a prior session',
|
||||
name: 'resume',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
BrowserManageResponse,
|
||||
CommandsCatalogResponse,
|
||||
DelegationPauseResponse,
|
||||
ProcessStopResponse,
|
||||
ReloadEnvResponse,
|
||||
|
|
@ -56,6 +57,10 @@ interface SkillsBrowseResponse {
|
|||
total_pages?: number
|
||||
}
|
||||
|
||||
interface SkillsReloadResponse {
|
||||
output?: string
|
||||
}
|
||||
|
||||
export const opsCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'stop background processes',
|
||||
|
|
@ -435,10 +440,44 @@ export const opsCommands: SlashCommand[] = [
|
|||
}
|
||||
},
|
||||
|
||||
{
|
||||
aliases: ['reload_skills'],
|
||||
help: 're-scan installed skills in the live TUI gateway',
|
||||
name: 'reload-skills',
|
||||
run: (_arg, ctx) => {
|
||||
ctx.gateway
|
||||
.rpc<SkillsReloadResponse>('skills.reload', {})
|
||||
.then(
|
||||
ctx.guarded<SkillsReloadResponse>(r => {
|
||||
ctx.transcript.page(r.output || 'skills reloaded', 'Reload Skills')
|
||||
ctx.gateway
|
||||
.rpc<CommandsCatalogResponse>('commands.catalog', {})
|
||||
.then(
|
||||
ctx.guarded<CommandsCatalogResponse>(catalog => {
|
||||
if (!catalog?.pairs) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.local.setCatalog({
|
||||
canon: (catalog.canon ?? {}) as Record<string, string>,
|
||||
categories: catalog.categories ?? [],
|
||||
pairs: catalog.pairs as [string, string][],
|
||||
skillCount: (catalog.skill_count ?? 0) as number,
|
||||
sub: (catalog.sub ?? {}) as Record<string, string[]>
|
||||
})
|
||||
})
|
||||
)
|
||||
.catch(() => {})
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'browse, inspect, install skills',
|
||||
name: 'skills',
|
||||
run: (arg, ctx) => {
|
||||
run: (arg, ctx, cmd) => {
|
||||
const text = arg.trim()
|
||||
|
||||
if (!text) {
|
||||
|
|
@ -449,6 +488,22 @@ export const opsCommands: SlashCommand[] = [
|
|||
const query = rest.join(' ').trim()
|
||||
const { rpc } = ctx.gateway
|
||||
const { panel, sys } = ctx.transcript
|
||||
const runViaSlashWorker = () => {
|
||||
ctx.gateway.gw
|
||||
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
|
||||
.then(r => {
|
||||
if (ctx.stale()) {
|
||||
return
|
||||
}
|
||||
|
||||
const body = r?.output || '/skills: no output'
|
||||
const formatted = r?.warning ? `warning: ${r.warning}\n${body}` : body
|
||||
const long = formatted.length > 180 || formatted.split('\n').filter(Boolean).length > 2
|
||||
|
||||
long ? ctx.transcript.page(formatted, 'Skills') : ctx.transcript.sys(formatted)
|
||||
})
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
|
||||
if (sub === 'list') {
|
||||
rpc<SkillsListResponse>('skills.manage', { action: 'list' })
|
||||
|
|
@ -593,7 +648,7 @@ export const opsCommands: SlashCommand[] = [
|
|||
return
|
||||
}
|
||||
|
||||
sys('usage: /skills [list | inspect <n> | install <n> | search <q> | browse [page]]')
|
||||
runViaSlashWorker()
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink'
|
||||
import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
|
|
@ -16,8 +16,8 @@ import type {
|
|||
} from '../gatewayTypes.js'
|
||||
import { useGitBranch } from '../hooks/useGitBranch.js'
|
||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||
import { composerPromptWidth } from '../lib/inputMetrics.js'
|
||||
import { appendTranscriptMessage } from '../lib/messages.js'
|
||||
import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
|
|
@ -631,7 +631,8 @@ export function useMainApp(gw: GatewayClient) {
|
|||
catalog,
|
||||
getHistoryItems: () => historyItemsRef.current,
|
||||
getLastUserMsg: () => lastUserMsgRef.current,
|
||||
maybeWarn
|
||||
maybeWarn,
|
||||
setCatalog
|
||||
},
|
||||
session: {
|
||||
closeSession: session.closeSession,
|
||||
|
|
@ -723,9 +724,12 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const anyPanelVisible = SECTION_NAMES.some(
|
||||
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
)
|
||||
const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const thinkingPanelVisible =
|
||||
sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const toolsPanelVisible =
|
||||
sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
const activityPanelVisible =
|
||||
sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
|
||||
|
||||
const showProgressArea = useTurnSelector(state =>
|
||||
anyPanelVisible
|
||||
|
|
@ -738,7 +742,9 @@ export function useMainApp(gw: GatewayClient) {
|
|||
const hasTrailTools = Boolean(segment.tools?.length)
|
||||
|
||||
if (segment.kind === 'trail' && !segment.text) {
|
||||
return (thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
|
||||
return (
|
||||
(thinkingPanelVisible && hasThinking) || ((toolsPanelVisible || activityPanelVisible) && hasTrailTools)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { writeFileSync } from 'node:fs'
|
|||
|
||||
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||
import { evictInkCaches } from '@hermes/ink'
|
||||
import { type RefObject, useCallback } from 'react'
|
||||
import { useCallback, type RefObject } from 'react'
|
||||
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
|
||||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
SessionCloseResponse,
|
||||
SessionCreateResponse,
|
||||
SessionResumeResponse,
|
||||
SessionTitleResponse,
|
||||
SetupStatusResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
|
@ -122,7 +123,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
)
|
||||
|
||||
const newSession = useCallback(
|
||||
async (msg?: string) => {
|
||||
async (msg?: string, title?: string) => {
|
||||
const setup = await rpc<SetupStatusResponse>('setup.status', {})
|
||||
|
||||
if (setup?.provider_configured === false) {
|
||||
|
|
@ -141,6 +142,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
}
|
||||
|
||||
const info = r.info ?? null
|
||||
const requestedTitle = title?.trim() ?? ''
|
||||
|
||||
resetSession()
|
||||
setSessionStartedAt(Date.now())
|
||||
|
|
@ -168,6 +170,30 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
if (msg) {
|
||||
sys(msg)
|
||||
}
|
||||
|
||||
if (requestedTitle) {
|
||||
rpc<SessionTitleResponse>('session.title', {
|
||||
session_id: r.session_id,
|
||||
title: requestedTitle
|
||||
})
|
||||
.then(result => {
|
||||
if (!result || getUiState().sid !== r.session_id) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextTitle = (result.title ?? requestedTitle).trim()
|
||||
const suffix = result.pending ? ' (queued while session initializes)' : ''
|
||||
sys(`session title set: ${nextTitle}${suffix}`)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (getUiState().sid !== r.session_id) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
sys(`warning: failed to set session title: ${message}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
[closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
|
||||
|
||||
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
||||
export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim()
|
||||
export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
|
||||
export const MOUSE_TRACKING = !truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
|
||||
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)
|
||||
|
||||
|
|
|
|||
|
|
@ -176,6 +176,10 @@ export interface SessionUsageResponse {
|
|||
total?: number
|
||||
}
|
||||
|
||||
export interface SessionStatusResponse {
|
||||
output?: string
|
||||
}
|
||||
|
||||
export interface SessionCompressResponse {
|
||||
after_messages?: number
|
||||
after_tokens?: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue