From e02a6038a420ea3278ad28c342d89546e43159d9 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 3 Jun 2026 10:56:06 -0400 Subject: [PATCH] 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_.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 * 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 --------- Co-authored-by: Cursor --- tests/test_tui_gateway_server.py | 60 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 45 +++++++++++++++++++----- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6c6bb5e62..ef94dc27a 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 651c15d6f..c9de380b6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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))