from argparse import Namespace import os from pathlib import Path import subprocess import sys import textwrap import types import pytest def _args(**overrides): base = { "continue_last": None, "model": None, "provider": None, "resume": None, "toolsets": None, "tui": True, "tui_dev": False, } base.update(overrides) return Namespace(**base) def _raise_exit(rc): raise SystemExit(rc) @pytest.fixture def main_mod(monkeypatch): import hermes_cli.main as mod monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True) # Reset the idempotency guard so each test starts fresh. monkeypatch.setattr(mod, "_oneshot_cleanup_done", False) return mod def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod): calls = [] captured = {} def fake_resolve_last(source="cli"): calls.append(source) return "20260408_235959_a1b2c3" if source == "tui" else None def fake_launch( resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None, **kwargs, ): captured["resume"] = resume_session_id raise SystemExit(0) monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last) monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val) monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) with pytest.raises(SystemExit): main_mod.cmd_chat(_args(continue_last=True)) assert calls == ["tui"] assert captured["resume"] == "20260408_235959_a1b2c3" def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, main_mod): calls = [] captured = {} def fake_resolve_last(source="cli"): calls.append(source) if source == "tui": return None if source == "cli": return "20260408_235959_d4e5f6" return None def fake_launch( resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None, **kwargs, ): captured["resume"] = resume_session_id raise SystemExit(0) monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last) monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val) monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) with pytest.raises(SystemExit): main_mod.cmd_chat(_args(continue_last=True)) assert calls == ["tui", "cli"] assert captured["resume"] == "20260408_235959_d4e5f6" def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod): captured = {} def fake_launch( resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None, **kwargs, ): captured["resume"] = resume_session_id raise SystemExit(0) monkeypatch.setattr( main_mod, "_resolve_session_by_name_or_id", lambda val: "20260409_000000_aa11bb" ) monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) with pytest.raises(SystemExit): main_mod.cmd_chat(_args(resume="my t0p session")) assert captured["resume"] == "20260409_000000_aa11bb" def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod): captured = {} def fake_launch( resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None, **kwargs, ): captured.update( { "model": model, "provider": provider, "resume": resume_session_id, "toolsets": toolsets, "tui_dev": tui_dev, } ) raise SystemExit(0) monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) with pytest.raises(SystemExit): main_mod.cmd_chat( _args(model="anthropic/claude-sonnet-4.6", provider="anthropic") ) assert captured == { "model": "anthropic/claude-sonnet-4.6", "provider": "anthropic", "resume": None, "toolsets": None, "tui_dev": False, } def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod): captured = {} def fake_launch( resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None, **kwargs, ): captured["toolsets"] = toolsets raise SystemExit(0) monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) with pytest.raises(SystemExit): main_mod.cmd_chat(_args(toolsets="web,terminal")) assert captured["toolsets"] == "web,terminal" def test_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.setattr(config_mod, "load_config", lambda: {}) monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None) monkeypatch.setitem( sys.modules, "agent.shell_hooks", types.SimpleNamespace( register_from_config=lambda _cfg, accept_hooks=False: None ), ) monkeypatch.setattr( main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}), ) main_mod.main() assert captured == {"toolsets": "web,terminal", "tui": True} def test_termux_fast_tui_launch_uses_light_parser(monkeypatch, main_mod): captured = {} monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.setattr( sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"] ) monkeypatch.setattr( main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}), ) assert main_mod._try_termux_fast_tui_launch() is True assert captured == {"toolsets": "web,terminal", "tui": True} def test_termux_fast_tui_launch_skips_help(monkeypatch, main_mod): monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--help"]) assert main_mod._try_termux_fast_tui_launch() is False def test_fast_tui_launch_is_termux_only(monkeypatch, main_mod): monkeypatch.delenv("TERMUX_VERSION", raising=False) monkeypatch.setenv("PREFIX", "/usr") monkeypatch.setattr(sys, "argv", ["hermes", "--tui"]) assert main_mod._try_termux_fast_tui_launch() is False def test_termux_fast_cli_launch_chat_uses_light_parser(monkeypatch, main_mod): captured = {} prepared = [] monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.delenv("HERMES_TUI", raising=False) monkeypatch.setattr( sys, "argv", ["hermes", "chat", "-q", "hello", "--toolsets", "web,terminal"] ) monkeypatch.setattr( main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command) ) monkeypatch.setattr( main_mod, "cmd_chat", lambda args: captured.update( {"query": args.query, "toolsets": args.toolsets, "command": args.command} ), ) assert main_mod._try_termux_fast_cli_launch() is True assert prepared == ["chat"] assert captured == { "query": "hello", "toolsets": "web,terminal", "command": "chat", } def test_termux_fast_cli_launch_bare_defers_agent_startup(monkeypatch, main_mod): captured = {} prepared = [] monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.delenv("HERMES_TUI", raising=False) monkeypatch.delenv("HERMES_DEFER_AGENT_STARTUP", raising=False) monkeypatch.delenv("HERMES_FAST_STARTUP_BANNER", raising=False) monkeypatch.setattr(sys, "argv", ["hermes"]) monkeypatch.setattr( main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command) ) monkeypatch.setattr( main_mod, "cmd_chat", lambda args: captured.update( { "query": args.query, "command": args.command, "compact": getattr(args, "compact", False), } ), ) assert main_mod._try_termux_fast_cli_launch() is True assert prepared == [] assert captured == {"query": None, "command": None, "compact": True} assert os.environ["HERMES_DEFER_AGENT_STARTUP"] == "1" assert os.environ["HERMES_FAST_STARTUP_BANNER"] == "1" def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod): captured = {} prepared = [] monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.delenv("HERMES_TUI", raising=False) monkeypatch.setattr( sys, "argv", [ "hermes", "-z", "hello", "--model", "gpt-test", "--provider", "openai", "--usage-file", "usage.json", ], ) monkeypatch.setattr( main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command) ) monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace( run_oneshot=lambda prompt, **kwargs: captured.update( {"prompt": prompt, **kwargs} ) or 17 ), ) monkeypatch.setattr( main_mod, "_exit_after_oneshot", _raise_exit, ) with pytest.raises(SystemExit) as exc: main_mod._try_termux_fast_cli_launch() assert exc.value.code == 17 assert prepared == [None] assert captured == { "prompt": "hello", "model": "gpt-test", "provider": "openai", "toolsets": None, "usage_file": "usage.json", } def test_termux_fast_cli_launch_version_skips_update_check(monkeypatch, main_mod): captured = [] monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.delenv("HERMES_TUI", raising=False) monkeypatch.setattr(sys, "argv", ["hermes", "version"]) monkeypatch.setattr( main_mod, "_print_version_info", lambda *, check_updates: captured.append(check_updates) ) assert main_mod._try_termux_fast_cli_launch() is True assert captured == [False] def test_termux_ultrafast_version_runs_before_heavy_startup( monkeypatch, capsys, main_mod ): monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.delenv("HERMES_TERMUX_DISABLE_FAST_CLI", raising=False) monkeypatch.setattr(sys, "argv", ["hermes", "--version"]) assert main_mod._try_termux_ultrafast_version() is True out = capsys.readouterr().out assert "Hermes Agent v" in out assert "Install directory:" in out assert "Python:" in out assert "OpenAI SDK:" in out def test_read_openai_version_fast(monkeypatch, tmp_path, main_mod): package_dir = tmp_path / "openai" package_dir.mkdir() (package_dir / "_version.py").write_text( '__version__ = "9.8.7" # x-release-please-version\n', encoding="utf-8", ) monkeypatch.setattr(sys, "path", [str(tmp_path)]) assert main_mod._read_openai_version_fast() == "9.8.7" def test_termux_fast_cli_launch_skips_help(monkeypatch, main_mod): monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.delenv("HERMES_TUI", raising=False) monkeypatch.setattr(sys, "argv", ["hermes", "chat", "--help"]) assert main_mod._try_termux_fast_cli_launch() is False def test_termux_fast_cli_launch_can_be_disabled(monkeypatch, main_mod): monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.setenv("HERMES_TERMUX_DISABLE_FAST_CLI", "1") monkeypatch.delenv("HERMES_TUI", raising=False) monkeypatch.setattr(sys, "argv", ["hermes", "version"]) assert main_mod._try_termux_fast_cli_launch() is False def test_termux_bundled_skills_stamp_controls_sync(monkeypatch, tmp_path, main_mod): monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path) monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1") assert main_mod._termux_bundled_skills_sync_needed() is True main_mod._mark_termux_bundled_skills_synced() assert main_mod._termux_bundled_skills_sync_needed() is False monkeypatch.setenv("HERMES_TERMUX_FORCE_SKILLS_SYNC", "1") assert main_mod._termux_bundled_skills_sync_needed() is True def test_termux_skips_bundled_skill_sync_when_stamp_fresh(monkeypatch, tmp_path, main_mod): calls = [] monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path) monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1") main_mod._mark_termux_bundled_skills_synced() monkeypatch.setitem( sys.modules, "tools.skills_sync", types.SimpleNamespace(sync_skills=lambda quiet: calls.append(quiet)), ) assert main_mod._sync_bundled_skills_for_startup() is False assert calls == [] def test_termux_forced_bundled_skill_sync_runs(monkeypatch, tmp_path, main_mod): calls = [] monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.setenv("HERMES_TERMUX_FORCE_SKILLS_SYNC", "1") monkeypatch.setattr(main_mod, "get_hermes_home", lambda: tmp_path) monkeypatch.setattr(main_mod, "_termux_bundled_skills_fingerprint", lambda: "fp1") monkeypatch.setitem( sys.modules, "tools.skills_sync", types.SimpleNamespace(sync_skills=lambda quiet: calls.append(quiet)), ) assert main_mod._sync_bundled_skills_for_startup() is True assert calls == [True] def test_read_git_revision_fingerprint_resolves_packed_refs(tmp_path, main_mod): repo = tmp_path / "repo" git_dir = repo / ".git" git_dir.mkdir(parents=True) (git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") packed_sha = "1234567890abcdef1234567890abcdef12345678" (git_dir / "packed-refs").write_text( "# pack-refs with: peeled fully-peeled sorted\n" f"{packed_sha} refs/heads/main\n" "abcdef0000000000000000000000000000000000 refs/tags/v1.0\n" "^99999999aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", encoding="utf-8", ) fingerprint = main_mod._read_git_revision_fingerprint(repo) assert fingerprint == f"git:refs/heads/main:{packed_sha}" def test_read_git_revision_fingerprint_packed_refs_in_worktree_common_dir( tmp_path, main_mod ): main_repo = tmp_path / "repo" common_git = main_repo / ".git" common_git.mkdir(parents=True) packed_sha = "fedcba9876543210fedcba9876543210fedcba98" (common_git / "packed-refs").write_text( f"{packed_sha} refs/heads/main\n", encoding="utf-8", ) worktree = tmp_path / "wt" worktree.mkdir() wt_gitdir = common_git / "worktrees" / "wt" wt_gitdir.mkdir(parents=True) (wt_gitdir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8") (wt_gitdir / "commondir").write_text("../..\n", encoding="utf-8") (worktree / ".git").write_text(f"gitdir: {wt_gitdir}\n", encoding="utf-8") fingerprint = main_mod._read_git_revision_fingerprint(worktree) assert fingerprint == f"git:refs/heads/main:{packed_sha}" def test_read_git_revision_fingerprint_loose_ref_in_worktree_common_dir( tmp_path, main_mod ): """`git worktree add -b NAME` writes the new branch ref to the common dir, not the per-worktree gitdir. The fingerprint must still resolve it.""" main_repo = tmp_path / "repo" common_git = main_repo / ".git" common_git.mkdir(parents=True) loose_sha = "0123456789abcdef0123456789abcdef01234567" (common_git / "refs" / "heads").mkdir(parents=True) (common_git / "refs" / "heads" / "feature").write_text( loose_sha + "\n", encoding="utf-8" ) worktree = tmp_path / "wt" worktree.mkdir() wt_gitdir = common_git / "worktrees" / "wt" wt_gitdir.mkdir(parents=True) (wt_gitdir / "HEAD").write_text("ref: refs/heads/feature\n", encoding="utf-8") (wt_gitdir / "commondir").write_text("../..\n", encoding="utf-8") (worktree / ".git").write_text(f"gitdir: {wt_gitdir}\n", encoding="utf-8") fingerprint = main_mod._read_git_revision_fingerprint(worktree) assert fingerprint == f"git:refs/heads/feature:{loose_sha}" def test_read_git_revision_fingerprint_unresolved_ref_is_stable(tmp_path, main_mod): repo = tmp_path / "repo" git_dir = repo / ".git" git_dir.mkdir(parents=True) (git_dir / "HEAD").write_text("ref: refs/heads/missing\n", encoding="utf-8") fingerprint = main_mod._read_git_revision_fingerprint(repo) assert fingerprint == "git:refs/heads/missing:unresolved" def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod): captured = {} import hermes_cli.config as config_mod monkeypatch.setattr( sys, "argv", [ "hermes", "-z", "hello", "--toolsets", "web,terminal", "--usage-file", "usage.json", ], ) monkeypatch.setitem( sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None), ) monkeypatch.setitem( sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None), ) monkeypatch.setattr(config_mod, "load_config", lambda: {}) monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None) monkeypatch.setitem( sys.modules, "agent.shell_hooks", types.SimpleNamespace( register_from_config=lambda _cfg, accept_hooks=False: None ), ) monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace( run_oneshot=lambda prompt, **kwargs: captured.update( {"prompt": prompt, **kwargs} ) or 0 ), ) monkeypatch.setattr( main_mod, "_exit_after_oneshot", _raise_exit, ) 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", "usage_file": "usage.json", } def test_exit_after_oneshot_flushes_stdio_and_calls_os_exit( monkeypatch, main_mod ): flushed = [] exits = [] class FakeStream: def __init__(self, name): self.name = name def flush(self): flushed.append(self.name) def fake_exit(rc): exits.append(rc) raise SystemExit(rc) monkeypatch.setattr(main_mod.sys, "stdout", FakeStream("stdout")) monkeypatch.setattr(main_mod.sys, "stderr", FakeStream("stderr")) monkeypatch.setattr(main_mod.os, "_exit", fake_exit) monkeypatch.setattr("logging.shutdown", lambda: None) with pytest.raises(SystemExit) as exc: main_mod._exit_after_oneshot(17) assert exc.value.code == 17 assert exits == [17] assert flushed == ["stdout", "stderr"] def test_exit_after_oneshot_invokes_logging_shutdown_in_order( monkeypatch, main_mod ): events = [] class FakeStream: def __init__(self, name): self.name = name def flush(self): events.append(f"flush:{self.name}") def fake_exit(rc): events.append(f"exit:{rc}") raise SystemExit(rc) monkeypatch.setattr(main_mod.sys, "stdout", FakeStream("stdout")) monkeypatch.setattr(main_mod.sys, "stderr", FakeStream("stderr")) monkeypatch.setattr("logging.shutdown", lambda: events.append("shutdown")) monkeypatch.setattr(main_mod.os, "_exit", fake_exit) with pytest.raises(SystemExit) as exc: main_mod._exit_after_oneshot(0) assert exc.value.code == 0 assert events == ["flush:stdout", "flush:stderr", "shutdown", "exit:0"] def test_exit_after_oneshot_exits_even_if_logging_shutdown_raises( monkeypatch, main_mod ): exits = [] def fake_exit(rc): exits.append(rc) raise SystemExit(rc) monkeypatch.setattr( main_mod.sys, "stdout", types.SimpleNamespace(flush=lambda: None) ) monkeypatch.setattr( main_mod.sys, "stderr", types.SimpleNamespace(flush=lambda: None) ) monkeypatch.setattr( "logging.shutdown", lambda: (_ for _ in ()).throw(RuntimeError("shutdown failed")), ) monkeypatch.setattr(main_mod.os, "_exit", fake_exit) with pytest.raises(SystemExit) as exc: main_mod._exit_after_oneshot(1) assert exc.value.code == 1 assert exits == [1] def test_exit_after_oneshot_flushes_stderr_when_stdout_flush_fails( monkeypatch, main_mod ): flushed = [] exits = [] class BadStdout: def flush(self): raise BrokenPipeError("pipe closed") class FakeStderr: def flush(self): flushed.append("stderr") def fake_exit(rc): exits.append(rc) raise SystemExit(rc) monkeypatch.setattr(main_mod.sys, "stdout", BadStdout()) monkeypatch.setattr(main_mod.sys, "stderr", FakeStderr()) monkeypatch.setattr(main_mod.os, "_exit", fake_exit) monkeypatch.setattr("logging.shutdown", lambda: None) with pytest.raises(SystemExit) as exc: main_mod._exit_after_oneshot(2) assert exc.value.code == 2 assert exits == [2] assert flushed == ["stderr"] def test_exit_after_oneshot_normalizes_non_int_exit_code(monkeypatch, main_mod): exits = [] def fake_exit(rc): exits.append(rc) raise SystemExit(rc) monkeypatch.setattr(main_mod.os, "_exit", fake_exit) monkeypatch.setattr("logging.shutdown", lambda: None) with pytest.raises(SystemExit) as exc: main_mod._exit_after_oneshot(None) assert exc.value.code == 0 assert exits == [0] def test_run_and_exit_oneshot_routes_system_exit_to_hard_exit(monkeypatch, main_mod): exits = [] def fake_run_oneshot(*_args, **_kwargs): raise SystemExit(2) monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace(run_oneshot=fake_run_oneshot), ) monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) main_mod._run_and_exit_oneshot("hello") assert exits == [2] def test_run_and_exit_oneshot_routes_bare_system_exit_to_zero(monkeypatch, main_mod): exits = [] def fake_run_oneshot(*_args, **_kwargs): raise SystemExit monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace(run_oneshot=fake_run_oneshot), ) monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) main_mod._run_and_exit_oneshot("hello") assert exits == [None] def test_run_and_exit_oneshot_prints_system_exit_message( monkeypatch, capsys, main_mod ): exits = [] def fake_run_oneshot(*_args, **_kwargs): raise SystemExit("fatal") monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace(run_oneshot=fake_run_oneshot), ) monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) main_mod._run_and_exit_oneshot("hello") assert exits == [1] captured = capsys.readouterr() assert captured.out == "" assert captured.err == "fatal\n" def test_run_and_exit_oneshot_cleans_global_runtime_before_hard_exit( monkeypatch, main_mod ): events = [] def _mod(name, **attrs): fake = types.ModuleType(name) for key, value in attrs.items(): setattr(fake, key, value) return fake monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace(run_oneshot=lambda *_args, **_kwargs: events.append("run") or 0), ) monkeypatch.setitem( sys.modules, "tools.terminal_tool", _mod("tools.terminal_tool", cleanup_all_environments=lambda: events.append("terminal")), ) monkeypatch.setitem( sys.modules, "tools.async_delegation", _mod("tools.async_delegation", interrupt_all=lambda **kw: events.append("delegation")), ) monkeypatch.setitem( sys.modules, "tools.browser_tool", _mod("tools.browser_tool", _emergency_cleanup_all_sessions=lambda: events.append("browser")), ) monkeypatch.setitem( sys.modules, "tools.mcp_tool", _mod("tools.mcp_tool", shutdown_mcp_servers=lambda: events.append("mcp")), ) monkeypatch.setitem( sys.modules, "agent.auxiliary_client", _mod("agent.auxiliary_client", shutdown_cached_clients=lambda: events.append("aux")), ) monkeypatch.setattr( main_mod, "_exit_after_oneshot", lambda rc: events.append(f"exit:{rc}") ) main_mod._run_and_exit_oneshot("hello") assert events == ["run", "terminal", "delegation", "browser", "mcp", "aux", "exit:0"] def test_run_and_exit_oneshot_still_exits_when_global_cleanup_raises( monkeypatch, main_mod ): events = [] def _raise_mcp(): raise RuntimeError("mcp boom") monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace(run_oneshot=lambda *_args, **_kwargs: 0), ) monkeypatch.setitem( sys.modules, "tools.terminal_tool", types.SimpleNamespace(cleanup_all_environments=lambda: events.append("terminal")), ) monkeypatch.setitem( sys.modules, "tools.async_delegation", types.SimpleNamespace(interrupt_all=lambda **kw: events.append("delegation")), ) monkeypatch.setitem( sys.modules, "tools.browser_tool", types.SimpleNamespace(_emergency_cleanup_all_sessions=lambda: events.append("browser")), ) monkeypatch.setitem( sys.modules, "tools.mcp_tool", types.SimpleNamespace(shutdown_mcp_servers=_raise_mcp), ) monkeypatch.setitem( sys.modules, "agent.auxiliary_client", types.SimpleNamespace(shutdown_cached_clients=lambda: events.append("aux")), ) monkeypatch.setattr( main_mod, "_exit_after_oneshot", lambda rc: events.append(f"exit:{rc}") ) main_mod._run_and_exit_oneshot("hello") assert events == ["terminal", "delegation", "browser", "aux", "exit:0"] def test_run_and_exit_oneshot_hard_exits_when_cleanup_is_interrupted( monkeypatch, main_mod ): def _raise_keyboard_interrupt(): raise KeyboardInterrupt monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace(run_oneshot=lambda *_args, **_kwargs: 0), ) monkeypatch.setattr( main_mod, "_cleanup_oneshot_runtime", _raise_keyboard_interrupt ) monkeypatch.setattr(main_mod, "_exit_after_oneshot", _raise_exit) with pytest.raises(SystemExit) as exc: main_mod._run_and_exit_oneshot("hello") assert exc.value.code == 0 def test_run_and_exit_oneshot_routes_keyboard_interrupt_to_130( monkeypatch, main_mod ): exits = [] def fake_run_oneshot(*_args, **_kwargs): raise KeyboardInterrupt monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace(run_oneshot=fake_run_oneshot), ) monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) main_mod._run_and_exit_oneshot("hello") assert exits == [130] def test_run_and_exit_oneshot_hard_exits_on_unexpected_exception( monkeypatch, main_mod, capsys ): # ``run_oneshot`` is contracted to convert agent failures into an int and # only re-raise KeyboardInterrupt / SystemExit. If it ever malfunctions and # lets another exception escape, the one-shot path must still hard-exit # (rc 1) rather than fall through to interpreter teardown — the exact path # that SIGABRTs on AL2023. exits = [] cleaned = [] def fake_run_oneshot(*_args, **_kwargs): raise RuntimeError("run_oneshot itself blew up") monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace(run_oneshot=fake_run_oneshot), ) monkeypatch.setattr( main_mod, "_cleanup_oneshot_runtime", lambda: cleaned.append(True) ) monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) main_mod._run_and_exit_oneshot("hello") assert exits == [1] # Global cleanup still runs on the defensive path — resources must not leak # just because run_oneshot malfunctioned. assert cleaned == [True] # The failure is surfaced on stderr, never swallowed silently. assert "run_oneshot itself blew up" in capsys.readouterr().err def test_run_and_exit_oneshot_hard_exits_when_oneshot_import_fails( monkeypatch, main_mod, capsys ): import builtins exits = [] cleaned = [] real_import = builtins.__import__ def fake_import(name, globals=None, locals=None, fromlist=(), level=0): if name == "hermes_cli.oneshot" and "run_oneshot" in (fromlist or ()): raise RuntimeError("oneshot import blew up") return real_import(name, globals, locals, fromlist, level) monkeypatch.setattr(builtins, "__import__", fake_import) monkeypatch.setattr( main_mod, "_cleanup_oneshot_runtime", lambda: cleaned.append(True) ) monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) main_mod._run_and_exit_oneshot("hello") assert exits == [1] assert cleaned == [True] assert "oneshot import blew up" in capsys.readouterr().err def test_oneshot_subprocess_exits_without_teardown_abort(): program = textwrap.dedent( """ import hermes_cli.oneshot as oneshot from hermes_cli.main import _exit_after_oneshot oneshot._run_agent = lambda *args, **kwargs: ("ok", {"final_response": "ok"}) _exit_after_oneshot(oneshot.run_oneshot("hello")) """ ) result = subprocess.run( [sys.executable, "-c", program], cwd=Path(__file__).resolve().parents[2], capture_output=True, timeout=10, check=False, ) assert result.returncode == 0 assert result.stdout == b"ok\n" # Don't demand byte-empty stderr — an import-time warning from the heavy # CLI import chain shouldn't fail this. What matters is no crash traceback. assert b"Traceback" not in result.stderr def test_exit_after_oneshot_bypasses_late_atexit_abort(): program = textwrap.dedent( """ import atexit import os import sys from hermes_cli.main import _exit_after_oneshot atexit.register(os.abort) sys.stdout.write("done\\n") _exit_after_oneshot(0) """ ) result = subprocess.run( [sys.executable, "-c", program], cwd=Path(__file__).resolve().parents[2], capture_output=True, timeout=10, check=False, ) assert result.returncode == 0 assert result.stdout == b"done\n" def test_run_and_exit_oneshot_passes_through_nonzero_return(monkeypatch, main_mod): # A non-zero rc from run_oneshot (e.g. provider-without-model → 2, or the # empty-response guard → 1) must reach os._exit unchanged. exits = [] monkeypatch.setitem( sys.modules, "hermes_cli.oneshot", types.SimpleNamespace(run_oneshot=lambda *a, **k: 2), ) monkeypatch.setattr(main_mod, "_cleanup_oneshot_runtime", lambda: None) monkeypatch.setattr(main_mod, "_exit_after_oneshot", lambda rc: exits.append(rc)) main_mod._run_and_exit_oneshot("hi") assert exits == [2] def test_main_oneshot_path_bypasses_late_atexit_abort(): # End-to-end through the real top-level ``main()`` ``-z`` path: a valid # response prints, then a late atexit handler that would abort is bypassed # by the hard exit, so the process reports success (#43055). program = textwrap.dedent( """ import atexit import os import sys import types import hermes_cli.main as main_mod sys.argv = ["hermes", "-z", "hello"] main_mod._prepare_agent_startup = lambda args: None def _fake_run_oneshot(prompt, **kwargs): print("ok") return 0 sys.modules["hermes_cli.oneshot"] = types.SimpleNamespace( run_oneshot=_fake_run_oneshot ) atexit.register(os.abort) main_mod.main() """ ) result = subprocess.run( [sys.executable, "-c", program], cwd=Path(__file__).resolve().parents[2], capture_output=True, timeout=30, check=False, ) assert result.returncode == 0 assert result.stdout == b"ok\n" assert b"Traceback" not in result.stderr def test_oneshot_run_agent_closes_agent_after_chat(monkeypatch): import hermes_cli.oneshot as oneshot_mod closed = [] shutdown_messages = [] class FakeAgent: def __init__(self, **_kwargs): self.suppress_status_output = False self.stream_delta_callback = object() self.tool_gen_callback = object() self._session_messages = [{"role": "user", "content": "hello"}] def run_conversation(self, prompt, **_kwargs): assert prompt == "hello" return {"final_response": "done"} def shutdown_memory_provider(self, messages=None): shutdown_messages.append(messages) def close(self): closed.append(True) monkeypatch.setitem( sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent) ) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: {"model": {"default": "gpt-test", "provider": "openai"}}, ) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", lambda **_kwargs: { "api_key": "key", "base_url": "https://example.invalid", "provider": "openai", "api_mode": "chat_completions", "credential_pool": None, }, ) monkeypatch.setattr(oneshot_mod, "_create_session_db_for_oneshot", lambda: None) assert ( oneshot_mod._run_agent( "hello", model="gpt-test", provider="openai", use_config_toolsets=False ) == ("done", {"final_response": "done"}) ) assert closed == [True] assert shutdown_messages == [[{"role": "user", "content": "hello"}]] def test_oneshot_run_agent_closes_agent_when_chat_raises(monkeypatch): import hermes_cli.oneshot as oneshot_mod closed = [] shutdowns = [] class FakeAgent: def __init__(self, **_kwargs): self.suppress_status_output = False self.stream_delta_callback = object() self.tool_gen_callback = object() def run_conversation(self, _prompt, **_kwargs): raise RuntimeError("boom") def shutdown_memory_provider(self, messages=None): shutdowns.append(messages) def close(self): closed.append(True) monkeypatch.setitem( sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent) ) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: {"model": {"default": "gpt-test", "provider": "openai"}}, ) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", lambda **_kwargs: { "api_key": "key", "base_url": "https://example.invalid", "provider": "openai", "api_mode": "chat_completions", "credential_pool": None, }, ) monkeypatch.setattr(oneshot_mod, "_create_session_db_for_oneshot", lambda: None) with pytest.raises(RuntimeError, match="boom"): oneshot_mod._run_agent( "hello", model="gpt-test", provider="openai", use_config_toolsets=False ) assert closed == [True] assert shutdowns == [None] def test_oneshot_run_agent_closes_session_db(monkeypatch): # The one-shot exit path hard-exits via os._exit and skips finalizers, so # the recall SQLite store it opens must be closed explicitly (checkpointing # its WAL) rather than left to interpreter teardown. import hermes_cli.oneshot as oneshot_mod db_closed = [] class FakeAgent: def __init__(self, **_kwargs): self.suppress_status_output = False self.stream_delta_callback = object() self.tool_gen_callback = object() self._session_messages = [] def run_conversation(self, _prompt, **_kwargs): return {"final_response": "done"} def shutdown_memory_provider(self, messages=None): pass def close(self): pass class FakeSessionDB: def close(self): db_closed.append(True) monkeypatch.setitem( sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent) ) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: {"model": {"default": "gpt-test", "provider": "openai"}}, ) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", lambda **_kwargs: { "api_key": "key", "base_url": "https://example.invalid", "provider": "openai", "api_mode": "chat_completions", "credential_pool": None, }, ) monkeypatch.setattr( oneshot_mod, "_create_session_db_for_oneshot", lambda: FakeSessionDB() ) assert ( oneshot_mod._run_agent( "hello", model="gpt-test", provider="openai", use_config_toolsets=False ) == ("done", {"final_response": "done"}) ) assert db_closed == [True] def test_oneshot_run_agent_closes_session_db_when_agent_init_raises(monkeypatch): # The recall store is opened before AIAgent is constructed. If construction # raises (bad provider/config/model), the store must still be closed — the # one-shot exit hard-exits via os._exit and skips finalizers, so an # un-closed connection would leave a stale WAL behind. import hermes_cli.oneshot as oneshot_mod db_closed = [] class FakeSessionDB: def close(self): db_closed.append(True) class FakeAgent: def __init__(self, **_kwargs): raise RuntimeError("init boom") monkeypatch.setitem( sys.modules, "run_agent", types.SimpleNamespace(AIAgent=FakeAgent) ) monkeypatch.setattr( "hermes_cli.config.load_config", lambda: {"model": {"default": "gpt-test", "provider": "openai"}}, ) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", lambda **_kwargs: { "api_key": "key", "base_url": "https://example.invalid", "provider": "openai", "api_mode": "chat_completions", "credential_pool": None, }, ) monkeypatch.setattr( oneshot_mod, "_create_session_db_for_oneshot", lambda: FakeSessionDB() ) with pytest.raises(RuntimeError, match="init boom"): oneshot_mod._run_agent( "hello", model="gpt-test", provider="openai", use_config_toolsets=False ) assert db_closed == [True] def _stub_plugin_discovery(monkeypatch): monkeypatch.setitem( sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None), ) def test_oneshot_rejects_invalid_only_toolsets(monkeypatch, capsys): _stub_plugin_discovery(monkeypatch) from hermes_cli.oneshot import run_oneshot assert run_oneshot("hello", toolsets="nope") == 2 err = capsys.readouterr().err assert "nope" in err assert "did not contain any valid toolsets" in err def test_oneshot_fails_closed_on_empty_final_response(monkeypatch, capsys): _stub_plugin_discovery(monkeypatch) import hermes_cli.oneshot as oneshot_mod monkeypatch.setattr(oneshot_mod, "_run_agent", lambda *_args, **_kwargs: ("", {})) assert oneshot_mod.run_oneshot("hello") == 1 captured = capsys.readouterr() assert captured.out == "" assert "no final response" in captured.err def test_oneshot_prints_nonempty_final_response(monkeypatch, capsys): _stub_plugin_discovery(monkeypatch) import hermes_cli.oneshot as oneshot_mod monkeypatch.setattr(oneshot_mod, "_run_agent", lambda *_args, **_kwargs: ("done", {})) assert oneshot_mod.run_oneshot("hello") == 0 captured = capsys.readouterr() assert captured.out == "done\n" assert captured.err == "" def test_oneshot_fails_closed_on_agent_exception(monkeypatch, capsys): _stub_plugin_discovery(monkeypatch) import hermes_cli.oneshot as oneshot_mod def _boom(*_args, **_kwargs): raise OSError("not a TTY") monkeypatch.setattr(oneshot_mod, "_run_agent", _boom) assert oneshot_mod.run_oneshot("hello") == 1 captured = capsys.readouterr() assert captured.out == "" assert "agent failed" in captured.err assert "not a TTY" in captured.err def test_oneshot_exit_code_when_failed_without_response(monkeypatch): from hermes_cli.oneshot import run_oneshot monkeypatch.setattr( "hermes_cli.oneshot._run_agent", lambda *_a, **_k: ("", {"failed": True, "partial": False}), ) assert run_oneshot("hi") == 2 def test_oneshot_exit_code_zero_when_failed_with_error_text(monkeypatch, capsys): from hermes_cli.oneshot import run_oneshot monkeypatch.setattr( "hermes_cli.oneshot._run_agent", lambda *_a, **_k: ( "API call failed after 3 retries: HTTP 404: model not found", {"failed": True, "partial": False}, ), ) assert run_oneshot("hi") == 0 assert "HTTP 404" in capsys.readouterr().out def test_oneshot_reraises_keyboard_interrupt(monkeypatch): _stub_plugin_discovery(monkeypatch) import hermes_cli.oneshot as oneshot_mod import pytest as _pytest def _interrupt(*_args, **_kwargs): raise KeyboardInterrupt monkeypatch.setattr(oneshot_mod, "_run_agent", _interrupt) with _pytest.raises(KeyboardInterrupt): oneshot_mod.run_oneshot("hello") def test_oneshot_filters_invalid_toolsets_before_redirect(monkeypatch, capsys): _stub_plugin_discovery(monkeypatch) from hermes_cli.oneshot import _validate_explicit_toolsets valid, error = _validate_explicit_toolsets("web,nope") assert valid == ["web"] assert error is None assert "nope" in capsys.readouterr().err def test_oneshot_all_toolsets_means_all_not_configured_cli(): from hermes_cli.oneshot import _validate_explicit_toolsets valid, error = _validate_explicit_toolsets("all") assert valid is None assert error is None def test_oneshot_all_toolsets_warns_about_ignored_extra_entries(monkeypatch, capsys): _stub_plugin_discovery(monkeypatch) from hermes_cli.oneshot import _validate_explicit_toolsets valid, error = _validate_explicit_toolsets("all,nope") assert valid is None assert error is None assert "ignoring additional entries: nope" in capsys.readouterr().err def test_oneshot_accepts_plugin_toolset_after_discovery(monkeypatch): import toolsets from hermes_cli.oneshot import _validate_explicit_toolsets discovered = {"ready": False} original_validate = toolsets.validate_toolset def fake_validate(name): return name == "plugin_demo" and discovered["ready"] or original_validate(name) monkeypatch.setattr(toolsets, "validate_toolset", fake_validate) monkeypatch.setitem( sys.modules, "hermes_cli.plugins", types.SimpleNamespace( discover_plugins=lambda: discovered.update({"ready": True}) ), ) valid, error = _validate_explicit_toolsets("plugin_demo") assert valid == ["plugin_demo"] assert error is None def test_oneshot_rejects_disabled_mcp_toolset(monkeypatch, capsys): _stub_plugin_discovery(monkeypatch) import hermes_cli.config as config_mod from hermes_cli.oneshot import _validate_explicit_toolsets monkeypatch.setattr( config_mod, "read_raw_config", lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}}, ) valid, error = _validate_explicit_toolsets("mcp-off") assert valid is None assert error == "hermes -z: --toolsets did not contain any valid toolsets.\n" err = capsys.readouterr().err assert "ignoring disabled MCP servers" in err assert "mcp-off" in err def test_oneshot_distinguishes_disabled_mcp_from_unknown(monkeypatch, capsys): _stub_plugin_discovery(monkeypatch) import hermes_cli.config as config_mod from hermes_cli.oneshot import _validate_explicit_toolsets monkeypatch.setattr( config_mod, "read_raw_config", lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}}, ) valid, error = _validate_explicit_toolsets("web,mcp-off,nope") assert valid == ["web"] assert error is None err = capsys.readouterr().err assert "ignoring unknown --toolsets entries: nope" in err assert "ignoring disabled MCP servers" in err assert "mcp-off" in err def test_oneshot_wires_session_db_for_recall(monkeypatch): """hermes -z bypasses HermesCLI, but recall still needs SessionDB.""" from hermes_cli.oneshot import _run_agent captured = {} sentinel_db = object() class FakeAgent: def __init__(self, **kwargs): captured.update(kwargs) self.suppress_status_output = False self.stream_delta_callback = object() self.tool_gen_callback = object() def run_conversation(self, prompt, **_kwargs): captured["prompt"] = prompt return {"final_response": "ok", "failed": False, "partial": False} class FakeSessionDB: def __new__(cls): return sentinel_db def mod(name, **attrs): module = types.ModuleType(name) for key, value in attrs.items(): setattr(module, key, value) return module monkeypatch.setitem(sys.modules, "run_agent", mod("run_agent", AIAgent=FakeAgent)) monkeypatch.setitem(sys.modules, "hermes_state", mod("hermes_state", SessionDB=FakeSessionDB)) monkeypatch.setitem( sys.modules, "hermes_cli.config", mod("hermes_cli.config", load_config=lambda: {"model": {"default": "m"}}), ) monkeypatch.setitem( sys.modules, "hermes_cli.models", mod("hermes_cli.models", detect_provider_for_model=lambda *_args, **_kwargs: None), ) monkeypatch.setitem( sys.modules, "hermes_cli.runtime_provider", mod( "hermes_cli.runtime_provider", resolve_runtime_provider=lambda **_kwargs: { "api_key": "k", "base_url": "u", "provider": "p", "api_mode": "chat_completions", "credential_pool": None, }, ), ) monkeypatch.setitem( sys.modules, "hermes_cli.tools_config", mod("hermes_cli.tools_config", _get_platform_tools=lambda *_args, **_kwargs: {"session_search"}), ) text, result = _run_agent("recall this") assert text == "ok" assert not result.get("failed") assert captured["session_db"] is sentinel_db assert captured["enabled_toolsets"] == ["session_search"] assert captured["prompt"] == "recall this" def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod): captured = {} active_path_during_call = None monkeypatch.setattr( main_mod, "_make_tui_argv", lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), ) def fake_call(argv, cwd=None, env=None): nonlocal active_path_during_call captured.update({"argv": argv, "cwd": cwd, "env": env}) active_path_during_call = Path(env["HERMES_TUI_ACTIVE_SESSION_FILE"]) assert active_path_during_call.exists() return 1 monkeypatch.setattr(main_mod.subprocess, "call", fake_call) with pytest.raises(SystemExit): main_mod._launch_tui( model="nous/hermes-test", provider="nous", toolsets="web, terminal" ) env = captured["env"] assert env["HERMES_MODEL"] == "nous/hermes-test" assert env["HERMES_INFERENCE_MODEL"] == "nous/hermes-test" assert env["HERMES_TUI_PROVIDER"] == "nous" assert env["HERMES_INFERENCE_PROVIDER"] == "nous" assert env["HERMES_TUI_TOOLSETS"] == "web,terminal" active_path = Path(env["HERMES_TUI_ACTIVE_SESSION_FILE"]) assert active_path.name.startswith("hermes-tui-active-session-") assert active_path.suffix == ".json" assert active_path_during_call == active_path assert not active_path.exists() assert env["NODE_ENV"] == "production" def test_launch_tui_worktree_validates_relative_python_against_final_cwd( monkeypatch, main_mod, tmp_path ): import cli as cli_mod parent_cwd = tmp_path / "parent" parent_cwd.mkdir() worktree = tmp_path / "worktree" relative_python = Path(".review-venv") / "bin" / Path(sys.executable).name python_path = worktree / relative_python python_path.parent.mkdir(parents=True) # copy2, not os.link: tmp_path may sit on a different filesystem than # the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV. import shutil shutil.copy2(sys.executable, python_path) captured = {} monkeypatch.setenv("HERMES_CWD", str(parent_cwd)) monkeypatch.setenv("HERMES_PYTHON", str(relative_python)) monkeypatch.setattr(cli_mod, "_git_repo_root", lambda: None) monkeypatch.setattr(cli_mod, "_prune_stale_worktrees", lambda _repo: None) monkeypatch.setattr(cli_mod, "_setup_worktree", lambda: {"path": str(worktree)}) monkeypatch.setattr(cli_mod, "_cleanup_worktree", lambda _info: None) monkeypatch.setattr( main_mod, "_make_tui_argv", lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), ) monkeypatch.setattr( main_mod.subprocess, "call", lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1, ) with pytest.raises(SystemExit): main_mod._launch_tui(worktree=True) assert captured["env"]["HERMES_CWD"] == str(worktree) assert captured["env"]["HERMES_PYTHON"] == str(relative_python) def test_launch_tui_applies_terminal_backend_config( monkeypatch, main_mod, _isolate_hermes_home ): captured = {} config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml" config_path.write_text( "\n".join( [ "terminal:", " backend: docker", " docker_image: example/hermes-tools:latest", " docker_extra_args:", " - --network=host", ] ), encoding="utf-8", ) monkeypatch.delenv("TERMINAL_ENV", raising=False) monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False) monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False) monkeypatch.setattr( main_mod, "_make_tui_argv", lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), ) monkeypatch.setattr( main_mod.subprocess, "call", lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1, ) with pytest.raises(SystemExit): main_mod._launch_tui() assert captured["env"]["TERMINAL_ENV"] == "docker" assert captured["env"]["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest" assert captured["env"]["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]' def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod): from unittest.mock import patch monkeypatch.setattr( main_mod, "_make_tui_argv", lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), ) monkeypatch.setattr(main_mod.subprocess, "call", lambda *args, **kwargs: 42) with patch("hermes_cli.relaunch.relaunch") as mock_relaunch: with pytest.raises(SystemExit) as exc: main_mod._launch_tui() assert exc.value.code == 42 mock_relaunch.assert_called_once_with(["update"], preserve_inherited=False) def test_launch_tui_drops_stale_resume_env_without_resume_arg(monkeypatch, main_mod): captured = {} monkeypatch.setenv("HERMES_TUI_RESUME", "stale-missing-session") monkeypatch.setattr( main_mod, "_make_tui_argv", lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), ) monkeypatch.setattr( main_mod.subprocess, "call", lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1, ) with pytest.raises(SystemExit): main_mod._launch_tui() assert "HERMES_TUI_RESUME" not in captured["env"] def test_launch_tui_sets_resume_env_from_resume_arg(monkeypatch, main_mod): captured = {} monkeypatch.setenv("HERMES_TUI_RESUME", "stale-missing-session") monkeypatch.setattr( main_mod, "_make_tui_argv", lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), ) monkeypatch.setattr( main_mod.subprocess, "call", lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1, ) with pytest.raises(SystemExit): main_mod._launch_tui(resume_session_id="20260518_000000_goodid") assert captured["env"]["HERMES_TUI_RESUME"] == "20260518_000000_goodid" def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path): tui_dir = tmp_path / "ui-tui" tsx = tui_dir / "node_modules" / ".bin" / "tsx" ink_dir = tui_dir / "packages" / "hermes-ink" tsx.parent.mkdir(parents=True) ink_dir.mkdir(parents=True) tsx.write_text("#!/usr/bin/env node\n", encoding="utf-8") monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None) monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _tui_dir: False) monkeypatch.delenv("HERMES_TUI_DIR", raising=False) monkeypatch.setattr(main_mod.shutil, "which", lambda bin_name: f"/usr/bin/{bin_name}") calls = [] def fake_run(cmd, cwd=None, **_kwargs): calls.append((cmd, cwd)) return types.SimpleNamespace(returncode=0, stdout="", stderr="") monkeypatch.setattr(main_mod.subprocess, "run", fake_run) argv, cwd = main_mod._make_tui_argv(tui_dir, tui_dev=True) assert argv == [str(tsx), "src/entry.tsx"] assert cwd == tui_dir assert calls == [(["/usr/bin/npm", "run", "build"], str(ink_dir))] def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys): import hermes_cli.main as main_mod class _FakeDB: def get_session(self, session_id): assert session_id == "20260409_000001_abc123" return { "message_count": 2, "input_tokens": 10, "output_tokens": 6, "cache_read_tokens": 2, "cache_write_tokens": 2, "reasoning_tokens": 1, } def get_session_title(self, _session_id): return "demo title" def close(self): return None monkeypatch.setitem( sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB()) ) main_mod._print_tui_exit_summary("20260409_000001_abc123") out = capsys.readouterr().out assert "Resume this session with:" in out assert "hermes --tui --resume 20260409_000001_abc123" in out assert 'hermes --tui -c "demo title"' in out assert "Tokens: 21 (in 10, out 6, cache 4, reasoning 1)" in out def test_print_tui_exit_summary_prefers_actual_active_session_file( monkeypatch, capsys, tmp_path ): import hermes_cli.main as main_mod seen = [] class _FakeDB: def get_session(self, session_id): seen.append(session_id) return { "message_count": 1, "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0, "reasoning_tokens": 0, } def get_session_title(self, _session_id): return "actual" def close(self): return None active = tmp_path / "active.json" active.write_text('{"session_id":"actual_session"}', encoding="utf-8") monkeypatch.setitem( sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB()) ) main_mod._print_tui_exit_summary("startup_resume", str(active)) out = capsys.readouterr().out assert seen == ["actual_session"] assert "hermes --tui --resume actual_session" in out assert "startup_resume" not in out