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