"""Tests for ``/focus`` — the display-only reduced-output view. Focus view composes with the existing ``/verbose`` tool-progress machinery rather than adding a second suppression mechanism. These tests cover: * the on/off/status toggle state machine (``resolve_focus_arg``); * the hidden-line counter and its formatter (which must respect the pre-focus ``/verbose`` mode so it never over-claims); * status-bar segment composition in both CLI renderers; * the CLI command handler's stash/restore of ``tool_progress_mode``; * **the prompt-cache invariant** — a real fake turn is dispatched through ``agent.tool_executor`` with focus on and with focus off, and the resulting model-facing ``messages`` lists must be byte-identical. """ import json from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from hermes_cli.focus_view import ( FOCUS_CONFIG_KEY, FOCUS_STATUSBAR_LABEL, FOCUS_TOOL_PROGRESS_MODE, effective_tool_progress_mode, focus_statusbar_segment, format_focus_status, format_focus_toggle_message, format_hidden_line, normalize_tool_progress_mode, resolve_focus_arg, would_display_tool_line, ) from hermes_cli.cli_commands_mixin import CLICommandsMixin # ========================================================================= # Toggle state machine — on | off | status | bare | garbage # ========================================================================= class TestToggleStateMachine: def test_bare_toggles_from_off_to_on(self): assert resolve_focus_arg("", False) == ("set", True) def test_bare_toggles_from_on_to_off(self): assert resolve_focus_arg("", True) == ("set", False) def test_explicit_toggle_word_behaves_like_bare(self): assert resolve_focus_arg("toggle", False) == ("set", True) assert resolve_focus_arg("toggle", True) == ("set", False) @pytest.mark.parametrize("word", ["on", "ON", " on ", "enable", "true", "yes", "1"]) def test_on_words(self, word): assert resolve_focus_arg(word, False) == ("set", True) @pytest.mark.parametrize("word", ["off", "OFF", "disable", "false", "no", "0"]) def test_off_words(self, word): assert resolve_focus_arg(word, True) == ("set", False) @pytest.mark.parametrize("word", ["status", "show", "?", "STATUS"]) def test_status_words_never_mutate(self, word): action, target = resolve_focus_arg(word, True) assert action == "status" assert target is None @pytest.mark.parametrize("word", ["sideways", "onn", "--global", "2"]) def test_garbage_reports_usage(self, word): assert resolve_focus_arg(word, False) == ("usage", None) def test_explicit_set_is_idempotent(self): # /focus on while already on stays on (no accidental toggle). assert resolve_focus_arg("on", True) == ("set", True) assert resolve_focus_arg("off", False) == ("set", False) # ========================================================================= # Suppression respects the existing /verbose modes # ========================================================================= class TestComposesWithVerboseModes: def test_focus_on_snaps_to_the_existing_off_mode(self): # Focus view must reuse the tool_progress "off" path, not invent a mode. assert FOCUS_TOOL_PROGRESS_MODE == "off" for configured in ("off", "new", "all", "verbose"): assert effective_tool_progress_mode(True, configured) == "off" @pytest.mark.parametrize("configured", ["off", "new", "all", "verbose"]) def test_focus_off_leaves_the_configured_verbose_mode_untouched(self, configured): assert effective_tool_progress_mode(False, configured) == configured def test_yaml_boolean_off_is_normalised(self): # YAML 1.1 parses a bare `off` as False. assert normalize_tool_progress_mode(False) == "off" assert normalize_tool_progress_mode(True) == "all" assert normalize_tool_progress_mode(None) == "all" assert normalize_tool_progress_mode("bogus") == "all" assert normalize_tool_progress_mode("log") == "log" @pytest.mark.parametrize("mode", ["new", "all", "verbose"]) def test_counts_lines_that_the_mode_would_have_shown(self, mode): assert would_display_tool_line(mode, "terminal") is True def test_does_not_count_when_verbose_was_already_off(self): # A user who already ran /verbose off is hiding nothing extra — focus # view must not claim credit for suppressing lines nobody would see. assert would_display_tool_line("off", "terminal") is False assert would_display_tool_line(False, "terminal") is False def test_new_mode_skips_consecutive_repeats_like_the_renderer(self): assert would_display_tool_line("new", "terminal", "terminal") is False assert would_display_tool_line("new", "read_file", "terminal") is True # "all" always counts, even repeats. assert would_display_tool_line("all", "terminal", "terminal") is True def test_empty_tool_name_never_counts(self): assert would_display_tool_line("all", "") is False # ========================================================================= # Hidden-count formatter + recovery line # ========================================================================= class TestHiddenCountFormatter: def test_zero_and_negative_produce_no_line(self): assert format_hidden_line(0) is None assert format_hidden_line(-3) is None def test_singular_noun(self): assert format_hidden_line(1) == "⋯ 1 tool line hidden · /focus off to show" def test_plural_noun(self): assert format_hidden_line(7) == "⋯ 7 tool lines hidden · /focus off to show" def test_line_always_names_the_recovery_command(self): assert "/focus off" in format_hidden_line(2) def test_non_numeric_is_tolerated(self): assert format_hidden_line(None) is None assert format_hidden_line("many") is None class _FocusHost(CLICommandsMixin): """Minimal host exposing only the attributes the focus helpers read.""" def __init__(self, *, enabled=False, saved="all", tool_progress="all"): self._focus_view_enabled = enabled self._focus_saved_tool_progress = saved self._focus_hidden_lines = 0 self._focus_last_counted_tool = None self.tool_progress_mode = tool_progress self.agent = None class TestHiddenCounterAccumulation: def test_counts_each_suppressed_tool_line(self): host = _FocusHost(enabled=True, saved="all") for name in ("terminal", "read_file", "web_search"): host._note_focus_hidden_line(name) assert host._focus_hidden_lines == 3 def test_counts_nothing_when_focus_is_off(self): host = _FocusHost(enabled=False, saved="all") host._note_focus_hidden_line("terminal") assert host._focus_hidden_lines == 0 def test_counts_nothing_when_verbose_was_already_off(self): host = _FocusHost(enabled=True, saved="off") for _ in range(5): host._note_focus_hidden_line("terminal") assert host._focus_hidden_lines == 0 def test_new_mode_dedupes_consecutive_repeats(self): host = _FocusHost(enabled=True, saved="new") host._note_focus_hidden_line("terminal") host._note_focus_hidden_line("terminal") host._note_focus_hidden_line("read_file") assert host._focus_hidden_lines == 2 def test_recovery_line_is_emitted_then_counter_resets(self): host = _FocusHost(enabled=True, saved="all") for name in ("terminal", "read_file"): host._note_focus_hidden_line(name) with patch("cli._cprint") as printer: host._emit_focus_recovery_line() assert printer.call_count == 1 assert "2 tool lines hidden" in printer.call_args[0][0] assert "/focus off" in printer.call_args[0][0] # Reset so the next turn starts from zero. assert host._focus_hidden_lines == 0 assert host._focus_last_counted_tool is None def test_no_recovery_line_when_nothing_was_hidden(self): host = _FocusHost(enabled=True, saved="all") with patch("cli._cprint") as printer: host._emit_focus_recovery_line() printer.assert_not_called() def test_no_recovery_line_when_focus_is_off(self): host = _FocusHost(enabled=False, saved="all") host._focus_hidden_lines = 4 with patch("cli._cprint") as printer: host._emit_focus_recovery_line() printer.assert_not_called() assert host._focus_hidden_lines == 0 # ========================================================================= # CLI command handler — stash / restore / persistence # ========================================================================= class TestFocusCommandHandler: def test_on_stashes_the_verbose_mode_and_snaps_to_off(self): host = _FocusHost(enabled=False, saved=None, tool_progress="verbose") with patch("cli.save_config_value", return_value=True) as saver, \ patch("cli._cprint"): host._handle_focus_command("/focus on") assert host._focus_view_enabled is True assert host.tool_progress_mode == "off" assert host._focus_saved_tool_progress == "verbose" saver.assert_called_once_with(FOCUS_CONFIG_KEY, True) def test_off_restores_the_stashed_verbose_mode(self): host = _FocusHost(enabled=True, saved="new", tool_progress="off") with patch("cli.save_config_value", return_value=True) as saver, \ patch("cli._cprint"): host._handle_focus_command("/focus off") assert host._focus_view_enabled is False assert host.tool_progress_mode == "new" assert host._focus_saved_tool_progress is None saver.assert_called_once_with(FOCUS_CONFIG_KEY, False) def test_round_trip_returns_to_the_original_mode(self): host = _FocusHost(enabled=False, saved=None, tool_progress="verbose") with patch("cli.save_config_value", return_value=True), patch("cli._cprint"): host._handle_focus_command("/focus") assert host.tool_progress_mode == "off" host._handle_focus_command("/focus") assert host.tool_progress_mode == "verbose" assert host._focus_view_enabled is False def test_status_never_writes_config_or_changes_mode(self): host = _FocusHost(enabled=True, saved="all", tool_progress="off") with patch("cli.save_config_value") as saver, patch("cli._cprint") as printer: host._handle_focus_command("/focus status") saver.assert_not_called() assert host.tool_progress_mode == "off" assert host._focus_view_enabled is True assert "Focus view" in printer.call_args[0][0] def test_garbage_argument_prints_usage_and_changes_nothing(self): host = _FocusHost(enabled=False, saved=None, tool_progress="all") with patch("cli.save_config_value") as saver, patch("cli._cprint") as printer: host._handle_focus_command("/focus sideways") saver.assert_not_called() assert host._focus_view_enabled is False assert host.tool_progress_mode == "all" assert "Usage: /focus" in printer.call_args[0][0] def test_idempotent_on_does_not_reclobber_the_stash(self): host = _FocusHost(enabled=True, saved="verbose", tool_progress="off") with patch("cli.save_config_value") as saver, patch("cli._cprint"): host._handle_focus_command("/focus on") saver.assert_not_called() # The stash still points at the real pre-focus mode, not "off". assert host._focus_saved_tool_progress == "verbose" def test_live_agent_mode_is_synced(self): host = _FocusHost(enabled=False, saved=None, tool_progress="all") host.agent = SimpleNamespace(tool_progress_mode="all") with patch("cli.save_config_value", return_value=True), patch("cli._cprint"): host._handle_focus_command("/focus on") # tool_executor gates on the AGENT copy — syncing it is what makes the # suppression take effect this turn instead of after an agent rebuild. assert host.agent.tool_progress_mode == "off" def test_status_text_names_the_mode_focus_off_will_restore(self): body = format_focus_status(True, "verbose") assert "ON" in body assert "VERBOSE" in body off_body = format_focus_status(False, "new") assert "OFF" in off_body assert "NEW" in off_body def test_toggle_messages_mirror_claude_code_wording(self): assert "enabled" in format_focus_toggle_message(True, "all") assert "disabled" in format_focus_toggle_message(False, "all") assert "ALL" in format_focus_toggle_message(False, "all") # ========================================================================= # Status-bar segment composition # ========================================================================= class TestStatusBarSegment: def test_segment_present_only_when_enabled(self): assert focus_statusbar_segment(True) == FOCUS_STATUSBAR_LABEL assert focus_statusbar_segment(False) == "" def test_snapshot_exposes_focus_label(self): from cli import HermesCLI host = HermesCLI.__new__(HermesCLI) host.model = "anthropic/claude-opus-4.6" from datetime import datetime host.session_start = datetime.now() host.conversation_history = [] host.agent = None host._focus_view_enabled = True snapshot = HermesCLI._get_status_bar_snapshot(host) assert snapshot["focus_label"] == FOCUS_STATUSBAR_LABEL host._focus_view_enabled = False assert HermesCLI._get_status_bar_snapshot(host)["focus_label"] == "" @pytest.mark.parametrize("width", [40, 60, 120]) def test_text_renderer_includes_the_badge_at_every_width_tier(self, width): from cli import HermesCLI host = HermesCLI.__new__(HermesCLI) host.model = "opus" host._focus_view_enabled = True snapshot = { "model_name": "opus", "model_short": "opus", "duration": "1m", "context_percent": 12, "context_tokens": 1000, "context_length": 200000, "compressions": 0, "active_background_tasks": 0, "active_background_processes": 0, "active_background_subagents": 0, "battery_label": "", "battery_category": "dim", "focus_label": FOCUS_STATUSBAR_LABEL, "prompt_elapsed": "", "idle_since": "", } with patch.object(HermesCLI, "_get_status_bar_snapshot", return_value=snapshot), \ patch.object(HermesCLI, "_is_session_yolo_active", return_value=False): text = HermesCLI._build_status_bar_text(host, width=width) assert "focus" in text @pytest.mark.parametrize("width", [40, 60, 120]) def test_fragment_renderer_includes_the_badge_at_every_width_tier(self, width): from cli import HermesCLI host = HermesCLI.__new__(HermesCLI) host.model = "opus" host._status_bar_visible = True host._model_picker_state = None host._focus_view_enabled = True snapshot = { "model_name": "opus", "model_short": "opus", "duration": "1m", "context_percent": 12, "context_tokens": 1000, "context_length": 200000, "compressions": 0, "active_background_tasks": 0, "active_background_processes": 0, "active_background_subagents": 0, "battery_label": "", "battery_category": "dim", "focus_label": FOCUS_STATUSBAR_LABEL, "prompt_elapsed": "", "idle_since": "", } with patch.object(HermesCLI, "_get_status_bar_snapshot", return_value=snapshot), \ patch.object(HermesCLI, "_get_tui_terminal_width", return_value=width), \ patch.object(HermesCLI, "_is_session_yolo_active", return_value=False): frags = HermesCLI._get_status_bar_fragments(host) rendered = "".join(text for _, text in frags) assert "focus" in rendered def test_badge_absent_from_fragments_when_focus_is_off(self): from cli import HermesCLI host = HermesCLI.__new__(HermesCLI) host.model = "opus" host._status_bar_visible = True host._model_picker_state = None host._focus_view_enabled = False snapshot = { "model_name": "opus", "model_short": "opus", "duration": "1m", "context_percent": 12, "context_tokens": 1000, "context_length": 200000, "compressions": 0, "active_background_tasks": 0, "active_background_processes": 0, "active_background_subagents": 0, "battery_label": "", "battery_category": "dim", "focus_label": "", "prompt_elapsed": "", "idle_since": "", } with patch.object(HermesCLI, "_get_status_bar_snapshot", return_value=snapshot), \ patch.object(HermesCLI, "_get_tui_terminal_width", return_value=120), \ patch.object(HermesCLI, "_is_session_yolo_active", return_value=False): frags = HermesCLI._get_status_bar_fragments(host) assert "focus" not in "".join(text for _, text in frags) # ========================================================================= # PROMPT-CACHE INVARIANT: model-facing messages are identical either way # ========================================================================= def _make_agent(tool_progress_mode: str): """Build a real AIAgent whose display mode is the only difference.""" from run_agent import AIAgent tool_defs = [ { "type": "function", "function": { "name": "web_search", "description": "search", "parameters": {"type": "object", "properties": {}}, }, } ] with ( patch("run_agent.get_tool_definitions", return_value=tool_defs), patch("run_agent.check_toolset_requirements", return_value={}), patch("hermes_cli.config.load_config", return_value={}), patch("run_agent.OpenAI"), ): agent = AIAgent( api_key="test-key-1234567890", base_url="https://openrouter.ai/api/v1", # quiet_mode False so the display gate is genuinely exercised — # with quiet_mode True the tool_progress gate would be moot. quiet_mode=False, skip_context_files=True, skip_memory=True, tool_progress_mode=tool_progress_mode, ) agent.client = MagicMock() agent.tool_delay = 0 agent._flush_messages_to_session_db = MagicMock() return agent def _tool_call(call_id: str, query: str): return SimpleNamespace( id=call_id, type="function", function=SimpleNamespace( name="web_search", arguments=json.dumps({"query": query}) ), ) def _run_fake_turn(tool_progress_mode: str, dispatch_mode: str = "sequential"): """Dispatch an identical fake turn and return the model-facing messages.""" agent = _make_agent(tool_progress_mode) assistant_message = SimpleNamespace( content="", tool_calls=[ _tool_call("call-1", "alpha"), _tool_call("call-2", "beta"), _tool_call("call-3", "gamma"), ], ) messages: list = [ {"role": "system", "content": "you are hermes"}, {"role": "user", "content": "find three things"}, ] def fake_dispatch(name, args, task_id, *positional, **kwargs): return json.dumps({"ok": args["query"]}) with ( patch("run_agent.handle_function_call", side_effect=fake_dispatch), patch.object(agent, "_invoke_tool", side_effect=fake_dispatch), patch( "agent.tool_executor.maybe_persist_tool_result", side_effect=lambda **kwargs: kwargs["content"], ), # Swallow display writes so the test doesn't spam stdout; the point is # what lands in `messages`, not what prints. patch("builtins.print"), ): execute = getattr(agent, f"_execute_tool_calls_{dispatch_mode}") execute(assistant_message, messages, "task-focus") return messages class TestModelFacingMessagesUnchanged: """Focus view is display-only: the request payload must not shift a byte.""" @pytest.mark.parametrize("dispatch_mode", ["sequential", "concurrent"]) def test_model_facing_messages_identical_with_focus_on_vs_off(self, dispatch_mode): # Focus ON == the existing tool_progress "off" suppression path. focus_on = _run_fake_turn(FOCUS_TOOL_PROGRESS_MODE, dispatch_mode) # Focus OFF == the default noisy display mode. focus_off = _run_fake_turn("all", dispatch_mode) assert focus_on == focus_off, ( "focus view altered the model-facing messages — display-only " "invariant violated (prompt cache would break)" ) # Sanity: the turn really did produce tool results to compare. assert [m["role"] for m in focus_on].count("tool") == 3 assert json.loads(focus_on[-1]["content"]) == {"ok": "gamma"} def test_every_verbose_mode_produces_the_same_messages(self): # /focus composes with /verbose, so no tool-progress mode may change # the payload — otherwise the composition itself would be unsafe. baseline = _run_fake_turn("all") for mode in ("off", "new", "verbose"): assert _run_fake_turn(mode) == baseline, f"mode {mode} altered messages" def test_toggling_focus_does_not_touch_conversation_history(self): host = _FocusHost(enabled=False, saved=None, tool_progress="all") history = [ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}, ] host.conversation_history = history snapshot = json.dumps(history) with patch("cli.save_config_value", return_value=True), patch("cli._cprint"): host._handle_focus_command("/focus on") host._note_focus_hidden_line("terminal") host._emit_focus_recovery_line() host._handle_focus_command("/focus off") assert json.dumps(host.conversation_history) == snapshot # ========================================================================= # Registry wiring # ========================================================================= class TestCommandRegistration: def test_focus_is_registered_with_the_sibling_toggle_convention(self): from hermes_cli.commands import resolve_command cmd = resolve_command("focus") assert cmd is not None assert cmd.category == "Configuration" assert cmd.args_hint == "[on|off|status]" assert set(cmd.subcommands) == {"on", "off", "status"} def test_verbose_cycle_releases_focus_view(self): # /verbose is the explicit tool-progress control; cycling it must clear # the focus badge so the indicator can never contradict the display. from cli import HermesCLI host = HermesCLI.__new__(HermesCLI) host.tool_progress_mode = "off" host._focus_view_enabled = True host._focus_saved_tool_progress = "all" host._focus_hidden_lines = 3 host._focus_last_counted_tool = "terminal" host.agent = None with patch("cli.save_config_value", return_value=True), patch("cli._cprint"): HermesCLI._toggle_verbose(host) assert host._focus_view_enabled is False assert host._focus_saved_tool_progress is None assert host._focus_hidden_lines == 0 assert host.tool_progress_mode == "new"