/ tests / cli / test_tool_progress_scrollback.py
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"}