{"repo":"NousResearch/hermes-agent","public_id":"210","display_label":"Gateway retry/undo/reset commands not persisting","summary":"The /retry, /undo, and /reset gateway commands were not functioning correctly, with transcript changes failing to persist and memories being silently lost. These members cover the bug report and associated fixes for this behavior.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":217,"merged_at":"2026-03-02T08:18:48+00:00"},"resolution":{"state":"resolved","by":"pr-217"},"freshness":{"state":"stale","last_verified_at":"2026-03-02T08:18:48+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":210,"pr_number":217,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-02T08:18:48+00:00"}],"members":[{"kind":"issue","number":210,"title":"/retry and /undo gateway commands have no effect, /reset silently loses memories","url":null,"state":"closed"},{"kind":"pr","number":213,"title":"fix(gateway): make /retry, /undo, and /reset actually work (fixes #210)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":217,"title":"fix(gateway): persist transcript changes in /retry, /undo and fix /reset attribute ","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/session.py","tests/gateway/test_session.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -901,13 +901,12 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:\n         source = event.source\n         \n         # Get existing session key\n-        session_key = f\"agent:main:{source.platform.value}:\" + \\\n-                      (f\"dm\" if source.chat_type == \"dm\" else f\"{source.chat_type}:{source.chat_id}\")\n+        session_key = self.session_store._generate_session_key(source)\n         \n         # Memory flush before reset: load the old transcript and let a\n         # temporary agent save memories before the session is wiped.\n         try:\n-            old_entry = self.session_store._sessions.get(session_key)\n+            old_entry = self.session_store._entries.get(session_key)\n             if old_entry:\n                 old_history = self.session_store.load_transcript(old_entry.session_id)\n                 if old_history:","@@ -1135,7 +1134,7 @@ async def _handle_retry_command(self, event: MessageEvent) -> str:\n         \n         # Truncate history to before the last user message\n         truncated = history[:last_user_idx]\n-        session_entry.conversation_history = truncated\n+        self.session_store.rewrite_transcript(session_entry.session_id, truncated)\n         \n         # Re-send by creating a fake text event with the old message\n         retry_event = MessageEvent(","@@ -1167,7 +1166,7 @@ async def _handle_undo_command(self, event: MessageEvent) -> str:\n         \n         removed_msg = history[last_user_idx].get(\"content\", \"\")\n         removed_count = len(history) - last_user_idx\n-        session_entry.conversation_history = history[:last_user_idx]\n+        self.session_store.rewrite_transcript(session_entry.session_id, history[:last_user_idx])\n         \n         preview = removed_msg[:40] + \"...\" if len(removed_msg) > 40 else removed_msg\n         return f\"↩️ Undid {removed_count} message(s).\\nRemoved: \\\"{preview}\\\"\""]},{"file":"gateway/session.py","hunks":["@@ -567,6 +567,37 @@ def append_to_transcript(self, session_id: str, message: Dict[str, Any]) -> None\n         with open(transcript_path, \"a\") as f:\n             f.write(json.dumps(message, ensure_ascii=False) + \"\\n\")\n     \n+    def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) -> None:\n+        \"\"\"Replace a session's transcript with the given messages.\"\"\"\n+        # Rewrite SQLite\n+        if self._db:\n+            try:\n+                self._db._conn.execute(\n+                    \"DELETE FROM messages WHERE session_id = ?\", (session_id,)\n+                )\n+                self._db._conn.execute(\n+                    \"UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?\",\n+                    (session_id,),\n+                )\n+                self._db._conn.commit()\n+                for msg in messages:\n+                    self._db.append_message(\n+                        session_id=session_id,\n+                        role=msg.get(\"role\", \"unknown\"),\n+                        content=msg.get(\"content\"),\n+                        tool_name=msg.get(\"tool_name\"),\n+                        tool_calls=msg.get(\"tool_calls\"),\n+                        tool_call_id=msg.get(\"tool_call_id\"),\n+                    )\n+            except Exception as e:\n+                logger.debug(\"Session DB rewrite failed: %s\", e)\n+\n+        # Rewrite legacy JSONL\n+        transcript_path = self.get_transcript_path(session_id)\n+        with open(transcript_path, \"w\") as f:\n+            for msg in messages:\n+                f.write(json.dumps(msg, ensure_ascii=False) + \"\\n\")\n+\n     def load_transcript(self, session_id: str) -> List[Dict[str, Any]]:\n         \"\"\"Load all messages from a session's transcript.\"\"\"\n         # Try SQLite first"]},{"file":"tests/gateway/test_session.py","hunks":["@@ -1,9 +1,13 @@\n \"\"\"Tests for gateway session management.\"\"\"\n \n+import json\n import pytest\n+from pathlib import Path\n+from unittest.mock import patch, MagicMock\n from gateway.config import Platform, HomeChannel, GatewayConfig, PlatformConfig\n from gateway.session import (\n     SessionSource,\n+    SessionStore,\n     build_session_context,\n     build_session_context_prompt,\n )","@@ -199,3 +203,59 @@ def test_whatsapp_prompt(self):\n         prompt = build_session_context_prompt(ctx)\n \n         assert \"WhatsApp\" in prompt or \"whatsapp\" in prompt.lower()\n+\n+\n+class TestSessionStoreRewriteTranscript:\n+    \"\"\"Regression: /retry and /undo must persist truncated history to disk.\"\"\"\n+\n+    @pytest.fixture()\n+    def store(self, tmp_path):\n+        config = GatewayConfig()\n+        with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+            s = SessionStore(sessions_dir=tmp_path, config=config)\n+        s._db = None  # no SQLite for these tests\n+        s._loaded = True\n+        return s\n+\n+    def test_rewrite_replaces_jsonl(self, store, tmp_path):\n+        session_id = \"test_session_1\"\n+        # Write initial transcript\n+        for msg in [\n+            {\"role\": \"user\", \"content\": \"hello\"},\n+            {\"role\": \"assistant\", \"content\": \"hi\"},\n+            {\"role\": \"user\", \"content\": \"undo this\"},\n+            {\"role\": \"assistant\", \"content\": \"ok\"},\n+        ]:\n+            store.append_to_transcript(session_id, msg)\n+\n+        # Rewrite with truncated history\n+        store.rewrite_transcript(session_id, [\n+            {\"role\": \"user\", \"content\": \"hello\"},\n+            {\"role\": \"assistant\", \"content\": \"hi\"},\n+        ])\n+\n+        reloaded = store.load_transcript(session_id)\n+        assert len(reloaded) == 2\n+        assert reloaded[0][\"content\"] == \"hello\"\n+        assert reloaded[1][\"content\"] == \"hi\"\n+\n+    def test_rewrite_with_empty_list(self, store):\n+        session_id = \"test_session_2\"\n+        store.append_to_transcript(session_id, {\"role\": \"user\", \"content\": \"hi\"})\n+\n+        store.rewrite_transcript(session_id, [])\n+\n+        reloaded = store.load_transcript(session_id)\n+        assert reloaded == []\n+\n+\n+class TestSessionStoreEntriesAttribute:\n+    \"\"\"Regression: /reset must access _entries, not _sessions.\"\"\"\n+\n+    def test_entries_attribute_exists(self):\n+        config = GatewayConfig()\n+        with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+            store = SessionStore(sessions_dir=Path(\"/tmp\"), config=config)\n+        store._loaded = True\n+        assert hasattr(store, \"_entries\")\n+        assert not hasattr(store, \"_sessions\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}