fix(tui): save TUI /save snapshots under Hermes home with system prompt (#38251)

* fix(tui): save TUI /save snapshots under Hermes home with system prompt

The TUI gateway's session.save RPC wrote hermes_conversation_<ts>.json to
the workspace/project CWD via os.path.abspath(...) and only exported model
and messages. This diverged from the classic CLI /save (which writes under
the Hermes profile home) and from the dashboard save (which includes the
system prompt).

Write the snapshot under get_hermes_home()/sessions/saved/ and include
system_prompt, session_id, and session_start so the TUI export matches the
CLI and dashboard behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tui): prefer agent.session_start for /save export; assert it in test

Address review feedback: derive session_start from the agent's session_start
datetime (matching the classic CLI export) and fall back to the gateway
session's created_at only when unavailable. Assert session_start in the
regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett
2026-06-03 10:56:06 -04:00
committed by GitHub
parent 12ea7fc7e3
commit e02a6038a4
2 changed files with 97 additions and 8 deletions

View File

@ -4,6 +4,7 @@ import sys
import threading
import time
import types
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
@ -5473,3 +5474,62 @@ def test_notification_poller_requeues_when_busy(monkeypatch):
assert requeued["session_id"] == "proc_busy_test"
finally:
server._sessions.pop("sid_busy", None)
def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, tmp_path):
"""TUI /save (session.save RPC) must snapshot under the Hermes profile
home — not the project/workspace CWD — and include the system prompt,
mirroring the classic CLI /save and the dashboard save export.
Regression: the gateway handler wrote ``hermes_conversation_*.json`` to
``os.path.abspath(...)`` (the workspace CWD) and only exported ``model``
and ``messages``, so ``system_prompt`` was missing.
"""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Run from a different CWD to prove the snapshot does NOT leak there.
work = tmp_path / "workspace"
work.mkdir()
monkeypatch.chdir(work)
sid = "save-sid"
agent = types.SimpleNamespace(
model="hermes-test",
session_id="20260101_120000_abc123",
session_start=datetime(2026, 1, 1, 12, 0, 0),
_cached_system_prompt="You are Hermes.",
)
history = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
server._sessions[sid] = {
"agent": agent,
"session_key": "save-key",
"history": history,
"history_lock": threading.Lock(),
"created_at": 1735732800.0,
}
try:
resp = server._methods["session.save"]("1", {"session_id": sid})
finally:
server._sessions.pop(sid, None)
assert "result" in resp, resp
saved_file = Path(resp["result"]["file"])
# Must NOT leak into the workspace/project CWD.
assert not list(work.glob("hermes_conversation_*.json"))
saved_dir = home / "sessions" / "saved"
assert saved_file.parent == saved_dir
assert saved_file.exists()
payload = json.loads(saved_file.read_text())
assert payload["model"] == "hermes-test"
assert payload["session_id"] == "20260101_120000_abc123"
assert payload["session_start"] == "2026-01-01T12:00:00"
assert payload["system_prompt"] == "You are Hermes."
assert payload["messages"] == history

View File

@ -3485,23 +3485,52 @@ def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
import time as _time
filename = os.path.abspath(
f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json"
)
agent = session["agent"]
# Mirror the classic CLI /save: snapshot under the Hermes profile home
# (~/.hermes/sessions/saved/) rather than the project/workspace CWD, and
# include the system prompt so the export matches the dashboard save.
saved_dir = get_hermes_home() / "sessions" / "saved"
try:
with open(filename, "w", encoding="utf-8") as f:
saved_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
return _err(rid, 5011, f"failed to create save directory {saved_dir}: {e}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
path = saved_dir / f"hermes_conversation_{timestamp}.json"
with session["history_lock"]:
messages = list(session.get("history", []))
session_id = getattr(agent, "session_id", None) or session.get("session_key") or ""
# Prefer the agent's session_start datetime (matches the classic CLI export);
# fall back to the gateway session's created_at timestamp.
agent_start = getattr(agent, "session_start", None)
if isinstance(agent_start, datetime):
session_start = agent_start.isoformat()
else:
created_at = session.get("created_at")
session_start = (
datetime.fromtimestamp(created_at).isoformat()
if isinstance(created_at, (int, float))
else ""
)
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(
{
"model": getattr(session["agent"], "model", ""),
"messages": session.get("history", []),
"model": getattr(agent, "model", ""),
"session_id": session_id,
"session_start": session_start,
"system_prompt": getattr(agent, "_cached_system_prompt", "") or "",
"messages": messages,
},
f,
indent=2,
ensure_ascii=False,
)
return _ok(rid, {"file": filename})
return _ok(rid, {"file": str(path)})
except Exception as e:
return _err(rid, 5011, str(e))