test_save_conversation_location.py
1 """Tests for /save — the conversation snapshot slash command. 2 3 Regression: the old implementation wrote ``hermes_conversation_<ts>.json`` 4 to the current working directory (CWD). Users who ran /save expected the 5 file to be discoverable via ``hermes sessions browse``, but CWD-resident 6 snapshots are not indexed in the state DB and are generally invisible. 7 The fix writes snapshots under ``~/.hermes/sessions/saved/`` and prints 8 the absolute path plus the resume hint for the live session. 9 """ 10 11 from __future__ import annotations 12 13 import json 14 import os 15 import sys 16 from datetime import datetime 17 from pathlib import Path 18 from types import SimpleNamespace 19 20 import pytest 21 22 23 @pytest.fixture 24 def hermes_home(tmp_path, monkeypatch): 25 home = tmp_path / ".hermes" 26 home.mkdir() 27 monkeypatch.setattr(Path, "home", lambda: tmp_path) 28 monkeypatch.setenv("HERMES_HOME", str(home)) 29 # Clear any cached hermes_home computation 30 import hermes_constants 31 if hasattr(hermes_constants, "_hermes_home_cache"): 32 hermes_constants._hermes_home_cache = None 33 return home 34 35 36 def _make_stub_cli(history): 37 """Build a minimal object exposing just what save_conversation uses.""" 38 return SimpleNamespace( 39 conversation_history=history, 40 model="test-model", 41 session_id="20260101_120000_abc123", 42 session_start=datetime(2026, 1, 1, 12, 0, 0), 43 ) 44 45 46 def test_save_conversation_writes_under_hermes_home(hermes_home, tmp_path, monkeypatch, capsys): 47 """Snapshot must land under ~/.hermes/sessions/saved/, not CWD.""" 48 # Change CWD to a different directory to prove the file does NOT go there. 49 work = tmp_path / "somewhere-else" 50 work.mkdir() 51 monkeypatch.chdir(work) 52 53 # Import fresh to pick up the HERMES_HOME fixture 54 for mod in [m for m in sys.modules if m.startswith("cli") or m == "hermes_constants"]: 55 sys.modules.pop(mod, None) 56 57 import cli # noqa: F401 (module under test) 58 59 stub = _make_stub_cli([ 60 {"role": "user", "content": "hi"}, 61 {"role": "assistant", "content": "hello"}, 62 ]) 63 64 # Call the unbound method against our stub. 65 cli.HermesCLI.save_conversation(stub) 66 67 # File must NOT be in CWD 68 cwd_leak = list(work.glob("hermes_conversation_*.json")) 69 assert not cwd_leak, f"snapshot leaked to CWD: {cwd_leak}" 70 71 # File MUST be under ~/.hermes/sessions/saved/ 72 saved_dir = hermes_home / "sessions" / "saved" 73 assert saved_dir.is_dir(), "expected saved/ subdirectory to be created" 74 files = list(saved_dir.glob("hermes_conversation_*.json")) 75 assert len(files) == 1, files 76 77 payload = json.loads(files[0].read_text()) 78 assert payload["model"] == "test-model" 79 assert payload["session_id"] == "20260101_120000_abc123" 80 assert payload["messages"] == [ 81 {"role": "user", "content": "hi"}, 82 {"role": "assistant", "content": "hello"}, 83 ] 84 85 # User-facing message must include the absolute path AND the resume hint. 86 out = capsys.readouterr().out 87 assert str(files[0]) in out, out 88 assert "hermes --resume 20260101_120000_abc123" in out, out 89 90 91 def test_save_conversation_empty_history_does_nothing(hermes_home, capsys): 92 for mod in [m for m in sys.modules if m.startswith("cli") or m == "hermes_constants"]: 93 sys.modules.pop(mod, None) 94 import cli 95 96 stub = _make_stub_cli([]) 97 cli.HermesCLI.save_conversation(stub) 98 99 saved_dir = hermes_home / "sessions" / "saved" 100 assert not saved_dir.exists() or not list(saved_dir.iterdir()) 101 out = capsys.readouterr().out 102 assert "No conversation to save" in out