From 1519c4d477a19bcd426af8b6a70c6cbc6f4edeb4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:04:28 -0700 Subject: [PATCH] fix(session): add /resume CLI handler, session log truncation guard, reopen_session API (#3315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements salvaged from PR #3225 by Mibayy: 1. Add /resume slash command handler in CLI process_command(). The command was registered in the commands registry but had no handler, so typing /resume produced 'Unknown command'. The handler resolves by title or session ID, ends the current session cleanly, loads conversation history from SQLite, re-opens the target session, and syncs the AIAgent instance. Follows the same pattern as new_session(). 2. Add truncation guard in _save_session_log(). When resuming a session whose messages weren't fully written to SQLite, the agent starts with partial history and the first save would overwrite the full JSON log on disk. The guard reads the existing file and skips the write if it already has more messages than the current batch. 3. Add reopen_session() method to SessionDB. Proper API for clearing ended_at/end_reason instead of reaching into _conn directly. Note: Bug 1 from the original PR (INSERT OR IGNORE + _session_db = None) is already fixed on main — skipped as redundant. Closes #3123. --- cli.py | 78 +++++++++++++++++++++++++++++++++++++++++++++++++ hermes_state.py | 9 ++++++ run_agent.py | 17 +++++++++++ 3 files changed, 104 insertions(+) diff --git a/cli.py b/cli.py index 8a1d7caa8..bb5c94db6 100644 --- a/cli.py +++ b/cli.py @@ -2949,6 +2949,82 @@ class HermesCLI: if not silent: print("(^_^)v New session started!") + def _handle_resume_command(self, cmd_original: str) -> None: + """Handle /resume — switch to a previous session mid-conversation.""" + parts = cmd_original.split(None, 1) + target = parts[1].strip() if len(parts) > 1 else "" + + if not target: + _cprint(" Usage: /resume ") + _cprint(" Tip: Use /history or `hermes sessions list` to find sessions.") + return + + if not self._session_db: + _cprint(" Session database not available.") + return + + # Resolve title or ID + from hermes_cli.main import _resolve_session_by_name_or_id + resolved = _resolve_session_by_name_or_id(target) + target_id = resolved or target + + session_meta = self._session_db.get_session(target_id) + if not session_meta: + _cprint(f" Session not found: {target}") + _cprint(" Use /history or `hermes sessions list` to see available sessions.") + return + + if target_id == self.session_id: + _cprint(" Already on that session.") + return + + # End current session + try: + self._session_db.end_session(self.session_id, "resumed_other") + except Exception: + pass + + # Switch to the target session + self.session_id = target_id + self._resumed = True + self._pending_title = None + + # Load conversation history + restored = self._session_db.get_messages_as_conversation(target_id) + self.conversation_history = restored or [] + + # Re-open the target session so it's not marked as ended + try: + self._session_db.reopen_session(target_id) + except Exception: + pass + + # Sync the agent if already initialised + if self.agent: + self.agent.session_id = target_id + self.agent.reset_session_state() + if hasattr(self.agent, "_last_flushed_db_idx"): + self.agent._last_flushed_db_idx = len(self.conversation_history) + if hasattr(self.agent, "_todo_store"): + try: + from tools.todo_tool import TodoStore + self.agent._todo_store = TodoStore() + except Exception: + pass + if hasattr(self.agent, "_invalidate_system_prompt"): + self.agent._invalidate_system_prompt() + + title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else "" + msg_count = len([m for m in self.conversation_history if m.get("role") == "user"]) + if self.conversation_history: + _cprint( + f" ↻ Resumed session {target_id}{title_part}" + f" ({msg_count} user message{'s' if msg_count != 1 else ''}," + f" {len(self.conversation_history)} total)" + ) + else: + _cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.") + def reset_conversation(self): """Reset the conversation by starting a new session.""" self.new_session() @@ -3667,6 +3743,8 @@ class HermesCLI: _cprint(" Session database not available.") elif canonical == "new": self.new_session() + elif canonical == "resume": + self._handle_resume_command(cmd_original) elif canonical == "provider": self._show_model_and_providers() elif canonical == "prompt": diff --git a/hermes_state.py b/hermes_state.py index d3088fce6..31ed12190 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -284,6 +284,15 @@ class SessionDB: ) self._conn.commit() + def reopen_session(self, session_id: str) -> None: + """Clear ended_at/end_reason so a session can be resumed.""" + with self._lock: + self._conn.execute( + "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", + (session_id,), + ) + self._conn.commit() + def update_system_prompt(self, session_id: str, system_prompt: str) -> None: """Store the full assembled system prompt snapshot.""" with self._lock: diff --git a/run_agent.py b/run_agent.py index 7810a8ce1..67624e60e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2055,6 +2055,23 @@ class AIAgent: msg["content"] = self._clean_session_content(msg["content"]) cleaned.append(msg) + # Guard: never overwrite a larger session log with fewer messages. + # This protects against data loss when --resume loads a session whose + # messages weren't fully written to SQLite — the resumed agent starts + # with partial history and would otherwise clobber the full JSON log. + if self.session_log_file.exists(): + try: + existing = json.loads(self.session_log_file.read_text(encoding="utf-8")) + existing_count = existing.get("message_count", len(existing.get("messages", []))) + if existing_count > len(cleaned): + logging.debug( + "Skipping session log overwrite: existing has %d messages, current has %d", + existing_count, len(cleaned), + ) + return + except Exception: + pass # corrupted existing file — allow the overwrite + entry = { "session_id": self.session_id, "model": self.model,