/ tests / cli / test_save_conversation_location.py
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