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:
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user