test_tool_progress_scrollback.py
1 """Tests for stacked tool progress scrollback lines in the CLI TUI. 2 3 When tool_progress_mode is "all" or "new", _on_tool_progress should print 4 persistent lines to scrollback on tool.completed, restoring the stacked 5 tool history that was lost when the TUI switched to a single-line spinner. 6 """ 7 8 import os 9 import sys 10 import importlib 11 from unittest.mock import MagicMock, patch 12 13 sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) 14 15 # Module-level reference to the cli module (set by _make_cli on first call) 16 _cli_mod = None 17 18 19 def _make_cli(tool_progress="all"): 20 """Create a HermesCLI instance with minimal mocking.""" 21 global _cli_mod 22 _clean_config = { 23 "model": { 24 "default": "anthropic/claude-opus-4.6", 25 "base_url": "https://openrouter.ai/api/v1", 26 "provider": "auto", 27 }, 28 "display": {"compact": False, "tool_progress": tool_progress}, 29 "agent": {}, 30 "terminal": {"env_type": "local"}, 31 } 32 clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} 33 prompt_toolkit_stubs = { 34 "prompt_toolkit": MagicMock(), 35 "prompt_toolkit.history": MagicMock(), 36 "prompt_toolkit.styles": MagicMock(), 37 "prompt_toolkit.patch_stdout": MagicMock(), 38 "prompt_toolkit.application": MagicMock(), 39 "prompt_toolkit.layout": MagicMock(), 40 "prompt_toolkit.layout.processors": MagicMock(), 41 "prompt_toolkit.filters": MagicMock(), 42 "prompt_toolkit.layout.dimension": MagicMock(), 43 "prompt_toolkit.layout.menus": MagicMock(), 44 "prompt_toolkit.widgets": MagicMock(), 45 "prompt_toolkit.key_binding": MagicMock(), 46 "prompt_toolkit.completion": MagicMock(), 47 "prompt_toolkit.formatted_text": MagicMock(), 48 "prompt_toolkit.auto_suggest": MagicMock(), 49 } 50 with patch.dict(sys.modules, prompt_toolkit_stubs), \ 51 patch.dict("os.environ", clean_env, clear=False): 52 import cli as mod 53 mod = importlib.reload(mod) 54 _cli_mod = mod 55 with patch.object(mod, "get_tool_definitions", return_value=[]), \ 56 patch.dict(mod.__dict__, {"CLI_CONFIG": _clean_config}): 57 return mod.HermesCLI() 58 59 60 class TestToolProgressScrollback: 61 """Stacked scrollback lines for 'all' and 'new' modes.""" 62 63 def test_all_mode_prints_scrollback_on_completed(self): 64 """In 'all' mode, tool.completed prints a stacked line.""" 65 cli = _make_cli(tool_progress="all") 66 # Simulate tool.started 67 cli._on_tool_progress("tool.started", "terminal", "git log", {"command": "git log"}) 68 # Simulate tool.completed 69 with patch.object(_cli_mod, "_cprint") as mock_print: 70 cli._on_tool_progress("tool.completed", "terminal", None, None, duration=1.5, is_error=False) 71 72 mock_print.assert_called_once() 73 line = mock_print.call_args[0][0] 74 # Should contain tool info (the cute message format has "git log" for terminal) 75 assert "git log" in line or "$" in line 76 77 def test_all_mode_prints_every_call(self): 78 """In 'all' mode, consecutive calls to the same tool each get a line.""" 79 cli = _make_cli(tool_progress="all") 80 with patch.object(_cli_mod, "_cprint") as mock_print: 81 # First call 82 cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"}) 83 cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False) 84 # Second call (same tool) 85 cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"}) 86 cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False) 87 88 assert mock_print.call_count == 2 89 90 def test_new_mode_skips_consecutive_repeats(self): 91 """In 'new' mode, consecutive calls to the same tool only print once.""" 92 cli = _make_cli(tool_progress="new") 93 with patch.object(_cli_mod, "_cprint") as mock_print: 94 cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"}) 95 cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False) 96 cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"}) 97 cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False) 98 99 assert mock_print.call_count == 1 # Only the first read_file 100 101 def test_new_mode_prints_when_tool_changes(self): 102 """In 'new' mode, a different tool name triggers a new line.""" 103 cli = _make_cli(tool_progress="new") 104 with patch.object(_cli_mod, "_cprint") as mock_print: 105 cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"}) 106 cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False) 107 cli._on_tool_progress("tool.started", "search_files", "pattern", {"pattern": "test"}) 108 cli._on_tool_progress("tool.completed", "search_files", None, None, duration=0.3, is_error=False) 109 cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"}) 110 cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False) 111 112 # read_file, search_files, read_file (3rd prints because search_files broke the streak) 113 assert mock_print.call_count == 3 114 115 def test_off_mode_no_scrollback(self): 116 """In 'off' mode, no stacked lines are printed.""" 117 cli = _make_cli(tool_progress="off") 118 with patch.object(_cli_mod, "_cprint") as mock_print: 119 cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) 120 cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False) 121 122 mock_print.assert_not_called() 123 124 def test_error_suffix_on_failed_tool(self): 125 """When is_error=True, the stacked line includes [error].""" 126 cli = _make_cli(tool_progress="all") 127 cli._on_tool_progress("tool.started", "terminal", "bad cmd", {"command": "bad cmd"}) 128 with patch.object(_cli_mod, "_cprint") as mock_print: 129 cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=True) 130 131 line = mock_print.call_args[0][0] 132 assert "[error]" in line 133 134 def test_spinner_still_updates_on_started(self): 135 """tool.started still updates the spinner text for live display.""" 136 cli = _make_cli(tool_progress="all") 137 cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"}) 138 assert "git status" in cli._spinner_text 139 140 def test_spinner_timer_clears_on_completed(self): 141 """tool.completed still clears the tool timer.""" 142 cli = _make_cli(tool_progress="all") 143 cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"}) 144 assert cli._tool_start_time > 0 145 with patch.object(_cli_mod, "_cprint"): 146 cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False) 147 assert cli._tool_start_time == 0.0 148 149 def test_concurrent_tools_produce_stacked_lines(self): 150 """Multiple tool.started followed by multiple tool.completed all produce lines.""" 151 cli = _make_cli(tool_progress="all") 152 with patch.object(_cli_mod, "_cprint") as mock_print: 153 # All start first (concurrent pattern) 154 cli._on_tool_progress("tool.started", "web_search", "query 1", {"query": "test 1"}) 155 cli._on_tool_progress("tool.started", "web_search", "query 2", {"query": "test 2"}) 156 # All complete 157 cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.0, is_error=False) 158 cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.5, is_error=False) 159 160 assert mock_print.call_count == 2 161 162 def test_verbose_mode_no_duplicate_scrollback(self): 163 """In 'verbose' mode, scrollback lines are NOT printed (run_agent handles verbose output).""" 164 cli = _make_cli(tool_progress="verbose") 165 with patch.object(_cli_mod, "_cprint") as mock_print: 166 cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) 167 cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False) 168 169 mock_print.assert_not_called() 170 171 def test_pending_info_stores_on_started(self): 172 """tool.started stores args for later use by tool.completed.""" 173 cli = _make_cli(tool_progress="all") 174 cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) 175 assert "terminal" in cli._pending_tool_info 176 assert len(cli._pending_tool_info["terminal"]) == 1 177 assert cli._pending_tool_info["terminal"][0] == {"command": "ls"} 178 179 def test_pending_info_consumed_on_completed(self): 180 """tool.completed consumes stored args (FIFO for concurrent).""" 181 cli = _make_cli(tool_progress="all") 182 cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) 183 cli._on_tool_progress("tool.started", "terminal", "pwd", {"command": "pwd"}) 184 assert len(cli._pending_tool_info["terminal"]) == 2 185 with patch.object(_cli_mod, "_cprint"): 186 cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False) 187 # First entry consumed, second remains 188 assert len(cli._pending_tool_info.get("terminal", [])) == 1 189 assert cli._pending_tool_info["terminal"][0] == {"command": "pwd"}