/ tests / hermes_cli / test_tui_resume_flow.py
test_tui_resume_flow.py
  1  from argparse import Namespace
  2  from pathlib import Path
  3  import sys
  4  import types
  5  
  6  import pytest
  7  
  8  
  9  def _args(**overrides):
 10      base = {
 11          "continue_last": None,
 12          "model": None,
 13          "provider": None,
 14          "resume": None,
 15          "toolsets": None,
 16          "tui": True,
 17          "tui_dev": False,
 18      }
 19      base.update(overrides)
 20      return Namespace(**base)
 21  
 22  
 23  @pytest.fixture
 24  def main_mod(monkeypatch):
 25      import hermes_cli.main as mod
 26  
 27      monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True)
 28      return mod
 29  
 30  
 31  def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch, main_mod):
 32      calls = []
 33      captured = {}
 34  
 35      def fake_resolve_last(source="cli"):
 36          calls.append(source)
 37          return "20260408_235959_a1b2c3" if source == "tui" else None
 38  
 39      def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
 40          captured["resume"] = resume_session_id
 41          raise SystemExit(0)
 42  
 43      monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last)
 44      monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val)
 45      monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
 46  
 47      with pytest.raises(SystemExit):
 48          main_mod.cmd_chat(_args(continue_last=True))
 49  
 50      assert calls == ["tui"]
 51      assert captured["resume"] == "20260408_235959_a1b2c3"
 52  
 53  
 54  def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch, main_mod):
 55      calls = []
 56      captured = {}
 57  
 58      def fake_resolve_last(source="cli"):
 59          calls.append(source)
 60          if source == "tui":
 61              return None
 62          if source == "cli":
 63              return "20260408_235959_d4e5f6"
 64          return None
 65  
 66      def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
 67          captured["resume"] = resume_session_id
 68          raise SystemExit(0)
 69  
 70      monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last)
 71      monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val)
 72      monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
 73  
 74      with pytest.raises(SystemExit):
 75          main_mod.cmd_chat(_args(continue_last=True))
 76  
 77      assert calls == ["tui", "cli"]
 78      assert captured["resume"] == "20260408_235959_d4e5f6"
 79  
 80  
 81  def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch, main_mod):
 82      captured = {}
 83  
 84      def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
 85          captured["resume"] = resume_session_id
 86          raise SystemExit(0)
 87  
 88      monkeypatch.setattr(
 89          main_mod, "_resolve_session_by_name_or_id", lambda val: "20260409_000000_aa11bb"
 90      )
 91      monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
 92  
 93      with pytest.raises(SystemExit):
 94          main_mod.cmd_chat(_args(resume="my t0p session"))
 95  
 96      assert captured["resume"] == "20260409_000000_aa11bb"
 97  
 98  
 99  def test_cmd_chat_tui_passes_model_and_provider(monkeypatch, main_mod):
100      captured = {}
101  
102      def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
103          captured.update(
104              {
105                  "model": model,
106                  "provider": provider,
107                  "resume": resume_session_id,
108                  "toolsets": toolsets,
109                  "tui_dev": tui_dev,
110              }
111          )
112          raise SystemExit(0)
113  
114      monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
115  
116      with pytest.raises(SystemExit):
117          main_mod.cmd_chat(
118              _args(model="anthropic/claude-sonnet-4.6", provider="anthropic")
119          )
120  
121      assert captured == {
122          "model": "anthropic/claude-sonnet-4.6",
123          "provider": "anthropic",
124          "resume": None,
125          "toolsets": None,
126          "tui_dev": False,
127      }
128  
129  
130  def test_cmd_chat_tui_passes_toolsets(monkeypatch, main_mod):
131      captured = {}
132  
133      def fake_launch(resume_session_id=None, tui_dev=False, model=None, provider=None, toolsets=None):
134          captured["toolsets"] = toolsets
135          raise SystemExit(0)
136  
137      monkeypatch.setattr(main_mod, "_launch_tui", fake_launch)
138  
139      with pytest.raises(SystemExit):
140          main_mod.cmd_chat(_args(toolsets="web,terminal"))
141  
142      assert captured["toolsets"] == "web,terminal"
143  
144  
145  def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod):
146      captured = {}
147  
148      import hermes_cli.config as config_mod
149  
150      monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"])
151      monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
152      monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
153      monkeypatch.setattr(config_mod, "load_config", lambda: {})
154      monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
155      monkeypatch.setitem(
156          sys.modules,
157          "agent.shell_hooks",
158          types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
159      )
160      monkeypatch.setattr(main_mod, "cmd_chat", lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}))
161  
162      main_mod.main()
163  
164      assert captured == {"toolsets": "web,terminal", "tui": True}
165  
166  
167  def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
168      captured = {}
169  
170      import hermes_cli.config as config_mod
171  
172      monkeypatch.setattr(sys, "argv", ["hermes", "-z", "hello", "--toolsets", "web,terminal"])
173      monkeypatch.setitem(sys.modules, "hermes_cli.plugins", types.SimpleNamespace(discover_plugins=lambda: None))
174      monkeypatch.setitem(sys.modules, "tools.mcp_tool", types.SimpleNamespace(discover_mcp_tools=lambda: None))
175      monkeypatch.setattr(config_mod, "load_config", lambda: {})
176      monkeypatch.setattr(config_mod, "get_container_exec_info", lambda: None)
177      monkeypatch.setitem(
178          sys.modules,
179          "agent.shell_hooks",
180          types.SimpleNamespace(register_from_config=lambda _cfg, accept_hooks=False: None),
181      )
182      monkeypatch.setitem(
183          sys.modules,
184          "hermes_cli.oneshot",
185          types.SimpleNamespace(run_oneshot=lambda prompt, **kwargs: captured.update({"prompt": prompt, **kwargs}) or 0),
186      )
187  
188      with pytest.raises(SystemExit) as exc:
189          main_mod.main()
190  
191      assert exc.value.code == 0
192      assert captured == {"prompt": "hello", "model": None, "provider": None, "toolsets": "web,terminal"}
193  
194  
195  def _stub_plugin_discovery(monkeypatch):
196      monkeypatch.setitem(
197          sys.modules,
198          "hermes_cli.plugins",
199          types.SimpleNamespace(discover_plugins=lambda: None),
200      )
201  
202  
203  def test_oneshot_rejects_invalid_only_toolsets(monkeypatch, capsys):
204      _stub_plugin_discovery(monkeypatch)
205      from hermes_cli.oneshot import run_oneshot
206  
207      assert run_oneshot("hello", toolsets="nope") == 2
208      err = capsys.readouterr().err
209      assert "nope" in err
210      assert "did not contain any valid toolsets" in err
211  
212  
213  def test_oneshot_filters_invalid_toolsets_before_redirect(monkeypatch, capsys):
214      _stub_plugin_discovery(monkeypatch)
215      from hermes_cli.oneshot import _validate_explicit_toolsets
216  
217      valid, error = _validate_explicit_toolsets("web,nope")
218  
219      assert valid == ["web"]
220      assert error is None
221      assert "nope" in capsys.readouterr().err
222  
223  
224  def test_oneshot_all_toolsets_means_all_not_configured_cli():
225      from hermes_cli.oneshot import _validate_explicit_toolsets
226  
227      valid, error = _validate_explicit_toolsets("all")
228  
229      assert valid is None
230      assert error is None
231  
232  
233  def test_oneshot_all_toolsets_warns_about_ignored_extra_entries(monkeypatch, capsys):
234      _stub_plugin_discovery(monkeypatch)
235      from hermes_cli.oneshot import _validate_explicit_toolsets
236  
237      valid, error = _validate_explicit_toolsets("all,nope")
238  
239      assert valid is None
240      assert error is None
241      assert "ignoring additional entries: nope" in capsys.readouterr().err
242  
243  
244  def test_oneshot_accepts_plugin_toolset_after_discovery(monkeypatch):
245      import toolsets
246  
247      from hermes_cli.oneshot import _validate_explicit_toolsets
248  
249      discovered = {"ready": False}
250      original_validate = toolsets.validate_toolset
251  
252      def fake_validate(name):
253          return name == "plugin_demo" and discovered["ready"] or original_validate(name)
254  
255      monkeypatch.setattr(toolsets, "validate_toolset", fake_validate)
256      monkeypatch.setitem(
257          sys.modules,
258          "hermes_cli.plugins",
259          types.SimpleNamespace(discover_plugins=lambda: discovered.update({"ready": True})),
260      )
261  
262      valid, error = _validate_explicit_toolsets("plugin_demo")
263  
264      assert valid == ["plugin_demo"]
265      assert error is None
266  
267  
268  def test_oneshot_rejects_disabled_mcp_toolset(monkeypatch, capsys):
269      _stub_plugin_discovery(monkeypatch)
270      import hermes_cli.config as config_mod
271  
272      from hermes_cli.oneshot import _validate_explicit_toolsets
273  
274      monkeypatch.setattr(
275          config_mod,
276          "read_raw_config",
277          lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
278      )
279  
280      valid, error = _validate_explicit_toolsets("mcp-off")
281  
282      assert valid is None
283      assert error == "hermes -z: --toolsets did not contain any valid toolsets.\n"
284      err = capsys.readouterr().err
285      assert "ignoring disabled MCP servers" in err
286      assert "mcp-off" in err
287  
288  
289  def test_oneshot_distinguishes_disabled_mcp_from_unknown(monkeypatch, capsys):
290      _stub_plugin_discovery(monkeypatch)
291      import hermes_cli.config as config_mod
292  
293      from hermes_cli.oneshot import _validate_explicit_toolsets
294  
295      monkeypatch.setattr(
296          config_mod,
297          "read_raw_config",
298          lambda: {"mcp_servers": {"mcp-off": {"enabled": False}}},
299      )
300  
301      valid, error = _validate_explicit_toolsets("web,mcp-off,nope")
302  
303      assert valid == ["web"]
304      assert error is None
305      err = capsys.readouterr().err
306      assert "ignoring unknown --toolsets entries: nope" in err
307      assert "ignoring disabled MCP servers" in err
308      assert "mcp-off" in err
309  
310  
311  def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
312      captured = {}
313      active_path_during_call = None
314  
315      monkeypatch.setattr(
316          main_mod,
317          "_make_tui_argv",
318          lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
319      )
320  
321      def fake_call(argv, cwd=None, env=None):
322          nonlocal active_path_during_call
323          captured.update({"argv": argv, "cwd": cwd, "env": env})
324          active_path_during_call = Path(env["HERMES_TUI_ACTIVE_SESSION_FILE"])
325          assert active_path_during_call.exists()
326          return 1
327  
328      monkeypatch.setattr(main_mod.subprocess, "call", fake_call)
329  
330      with pytest.raises(SystemExit):
331          main_mod._launch_tui(model="nous/hermes-test", provider="nous", toolsets="web, terminal")
332  
333      env = captured["env"]
334      assert env["HERMES_MODEL"] == "nous/hermes-test"
335      assert env["HERMES_INFERENCE_MODEL"] == "nous/hermes-test"
336      assert env["HERMES_TUI_PROVIDER"] == "nous"
337      assert env["HERMES_INFERENCE_PROVIDER"] == "nous"
338      assert env["HERMES_TUI_TOOLSETS"] == "web,terminal"
339      active_path = Path(env["HERMES_TUI_ACTIVE_SESSION_FILE"])
340      assert active_path.name.startswith("hermes-tui-active-session-")
341      assert active_path.suffix == ".json"
342      assert active_path_during_call == active_path
343      assert not active_path.exists()
344      assert env["NODE_ENV"] == "production"
345  
346  
347  def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys):
348      import hermes_cli.main as main_mod
349  
350      class _FakeDB:
351          def get_session(self, session_id):
352              assert session_id == "20260409_000001_abc123"
353              return {
354                  "message_count": 2,
355                  "input_tokens": 10,
356                  "output_tokens": 6,
357                  "cache_read_tokens": 2,
358                  "cache_write_tokens": 2,
359                  "reasoning_tokens": 1,
360              }
361  
362          def get_session_title(self, _session_id):
363              return "demo title"
364  
365          def close(self):
366              return None
367  
368      monkeypatch.setitem(
369          sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB())
370      )
371  
372      main_mod._print_tui_exit_summary("20260409_000001_abc123")
373      out = capsys.readouterr().out
374  
375      assert "Resume this session with:" in out
376      assert "hermes --tui --resume 20260409_000001_abc123" in out
377      assert 'hermes --tui -c "demo title"' in out
378      assert "Tokens:         21 (in 10, out 6, cache 4, reasoning 1)" in out
379  
380  
381  def test_print_tui_exit_summary_prefers_actual_active_session_file(
382      monkeypatch, capsys, tmp_path
383  ):
384      import hermes_cli.main as main_mod
385  
386      seen = []
387  
388      class _FakeDB:
389          def get_session(self, session_id):
390              seen.append(session_id)
391              return {
392                  "message_count": 1,
393                  "input_tokens": 0,
394                  "output_tokens": 0,
395                  "cache_read_tokens": 0,
396                  "cache_write_tokens": 0,
397                  "reasoning_tokens": 0,
398              }
399  
400          def get_session_title(self, _session_id):
401              return "actual"
402  
403          def close(self):
404              return None
405  
406      active = tmp_path / "active.json"
407      active.write_text('{"session_id":"actual_session"}', encoding="utf-8")
408      monkeypatch.setitem(
409          sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB())
410      )
411  
412      main_mod._print_tui_exit_summary("startup_resume", str(active))
413      out = capsys.readouterr().out
414  
415      assert seen == ["actual_session"]
416      assert "hermes --tui --resume actual_session" in out
417      assert "startup_resume" not in out