from types import SimpleNamespace import pytest import cli @pytest.fixture(autouse=True) def reset_single_query_finalize_state(monkeypatch): monkeypatch.setattr(cli, "_single_query_finalize_attempted_session_ids", set()) monkeypatch.setattr(cli, "_cleanup_done", False) def test_finalize_single_query_runs_cleanup_without_reemitting_finalize_before_release(monkeypatch): calls = [] fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append(("release", {}))) def cleanup(**kwargs): calls.append(("cleanup", kwargs)) monkeypatch.setattr( cli, "_notify_single_query_session_finalize", lambda _cli: calls.append(("finalize", {})), ) monkeypatch.setattr(cli, "_run_cleanup", cleanup) cli._finalize_single_query(fake_cli) assert calls == [ ("finalize", {}), ("cleanup", {"notify_session_finalize": False}), ("release", {}), ] def test_finalize_single_query_releases_session_when_cleanup_fails(monkeypatch): calls = [] fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append("release")) def cleanup(**kwargs): calls.append("cleanup") raise RuntimeError("cleanup failed") monkeypatch.setattr( cli, "_notify_single_query_session_finalize", lambda _cli: calls.append("finalize"), ) monkeypatch.setattr(cli, "_run_cleanup", cleanup) with pytest.raises(RuntimeError, match="cleanup failed"): cli._finalize_single_query(fake_cli) assert calls == ["finalize", "cleanup", "release"] def test_finalize_single_query_runs_cleanup_when_finalize_hook_fails(monkeypatch): calls = [] fake_agent = SimpleNamespace(session_id="agent-session", platform="cli") fake_cli = SimpleNamespace( agent=fake_agent, session_id="cli-session", _release_active_session=lambda: calls.append("release"), ) def invoke_hook(name, **kwargs): calls.append("finalize") raise RuntimeError("hook failed") monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook) monkeypatch.setattr(cli, "_run_cleanup", lambda **kwargs: calls.append("cleanup")) cli._finalize_single_query(fake_cli) assert calls == ["finalize", "cleanup", "release"] def test_finalize_single_query_signal_window_does_not_reemit_during_atexit(monkeypatch): calls = [] fake_agent = SimpleNamespace(session_id="agent-session", platform="cli") fake_cli = SimpleNamespace( agent=fake_agent, session_id="cli-session", _release_active_session=lambda: calls.append(("release", {})), ) def invoke_hook(name, **kwargs): calls.append((name, kwargs)) def interrupted_cleanup(**_kwargs): raise KeyboardInterrupt() expected_finalize = ( "on_session_finalize", { "session_id": "agent-session", "platform": "cli", "reason": "shutdown", }, ) original_run_cleanup = cli._run_cleanup monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook) monkeypatch.setattr(cli, "_run_cleanup", interrupted_cleanup) with pytest.raises(KeyboardInterrupt): cli._finalize_single_query(fake_cli) assert calls == [expected_finalize, ("release", {})] # Simulate later atexit cleanup after the interrupted one-shot path. The # active agent may already be unavailable by then. monkeypatch.setattr(cli, "_run_cleanup", original_run_cleanup) monkeypatch.setattr(cli, "_active_agent_ref", None) monkeypatch.setattr(cli, "_reset_terminal_input_modes_on_exit", lambda: None) monkeypatch.setattr(cli, "_cleanup_all_terminals", lambda: None) monkeypatch.setattr(cli, "_cleanup_all_browsers", lambda: None) monkeypatch.setattr("tools.mcp_tool.shutdown_mcp_servers", lambda: None) monkeypatch.setattr("agent.auxiliary_client.shutdown_cached_clients", lambda: None) cli._run_cleanup() assert calls == [expected_finalize, ("release", {})] def test_notify_single_query_session_finalize_uses_agent_session(monkeypatch): calls = [] fake_agent = SimpleNamespace(session_id="agent-session", platform="cli") fake_cli = SimpleNamespace(agent=fake_agent, session_id="cli-session") def invoke_hook(name, **kwargs): calls.append((name, kwargs)) monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook) cli._notify_single_query_session_finalize(fake_cli) assert calls == [ ( "on_session_finalize", { "session_id": "agent-session", "platform": "cli", "reason": "shutdown", }, ) ] def test_human_single_query_main_finalizes_after_query(monkeypatch): calls = [] import cli as cli_mod class _Console: def print(self, *_args, **_kwargs): calls.append("query-label") class FakeCLI: def __init__(self, **_kwargs): self.console = _Console() self.session_id = "single-query-session" self.agent = SimpleNamespace( session_id="single-query-session", platform="cli", ) def _claim_active_session(self, surface, *, stderr=False): calls.append(("claim", surface, stderr)) return True def _show_security_advisories(self): calls.append("advisories") def chat(self, query, images=None): calls.append(("chat", query, images)) return "done" def _print_exit_summary(self): calls.append("summary") monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI) monkeypatch.setattr(cli_mod.atexit, "register", lambda *_args, **_kwargs: None) monkeypatch.setattr( cli_mod, "_finalize_single_query", lambda fake_cli: calls.append(("finalize", fake_cli.session_id)), ) cli_mod.main(query="hello", quiet=False, toolsets="terminal") assert calls == [ ("claim", "cli", False), "query-label", "advisories", ("chat", "hello", None), "summary", ("finalize", "single-query-session"), ] def test_quiet_single_query_main_finalizes_while_preserving_exit_code(monkeypatch): calls = [] import cli as cli_mod def run_conversation(*, user_message, conversation_history): calls.append(("run", user_message, conversation_history)) return { "final_response": "", "error": "provider failed", "failed": True, } class FakeCLI: def __init__(self, **_kwargs): self.provider = "test-provider" self.model = "test-model" self.session_id = "quiet-session" self.conversation_history = [] self._active_agent_route_signature = "same-route" self.agent = SimpleNamespace( session_id="quiet-session", platform="cli", quiet_mode=False, suppress_status_output=False, stream_delta_callback=object(), tool_gen_callback=object(), run_conversation=run_conversation, ) def _claim_active_session(self, surface, *, stderr=False): calls.append(("claim", surface, stderr)) return True def _ensure_runtime_credentials(self): calls.append("credentials") return True def _resolve_turn_agent_config(self, effective_query): calls.append(("resolve", effective_query)) return { "signature": "same-route", "model": None, "runtime": None, "request_overrides": None, } def _init_agent(self, **kwargs): calls.append(("init", kwargs)) return True monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False) monkeypatch.setattr(cli_mod, "HermesCLI", FakeCLI) monkeypatch.setattr(cli_mod.atexit, "register", lambda *_args, **_kwargs: None) monkeypatch.setattr( cli_mod, "_finalize_single_query", lambda fake_cli: calls.append(("finalize", fake_cli.session_id)), ) with pytest.raises(SystemExit) as exc_info: cli_mod.main(query="hello", quiet=True, toolsets="terminal") assert exc_info.value.code == 1 assert ("claim", "cli", True) in calls assert ("run", "hello", []) in calls assert calls[-1] == ("finalize", "quiet-session")