# NousResearch/hermes-agent · Gateway retry/undo/reset commands not persisting

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #217. Verify it addresses your case.
- **Recommended:** read PR #217 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-217`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #210: /retry and /undo gateway commands have no effect, /reset silently loses memories

## Prior & competing PRs

- **#217: fix(gateway): persist transcript changes in /retry, /undo and fix /reset attribute **. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/run.py`, `gateway/session.py`, `tests/gateway/test_session.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -901,13 +901,12 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
             source = event.source
             
             # Get existing session key
    -        session_key = f"agent:main:{source.platform.value}:" + \
    -                      (f"dm" if source.chat_type == "dm" else f"{source.chat_type}:{source.chat_id}")
    +        session_key = self.session_store._generate_session_key(source)
             
             # Memory flush before reset: load the old transcript and let a
             # temporary agent save memories before the session is wiped.
             try:
    -            old_entry = self.session_store._sessions.get(session_key)
    +            old_entry = self.session_store._entries.get(session_key)
                 if old_entry:
                     old_history = self.session_store.load_transcript(old_entry.session_id)
                     if old_history:
    ```

    ```diff
    @@ -1135,7 +1134,7 @@ async def _handle_retry_command(self, event: MessageEvent) -> str:
             
             # Truncate history to before the last user message
             truncated = history[:last_user_idx]
    -        session_entry.conversation_history = truncated
    +        self.session_store.rewrite_transcript(session_entry.session_id, truncated)
             
             # Re-send by creating a fake text event with the old message
             retry_event = MessageEvent(
    ```

    ```diff
    @@ -1167,7 +1166,7 @@ async def _handle_undo_command(self, event: MessageEvent) -> str:
             
             removed_msg = history[last_user_idx].get("content", "")
             removed_count = len(history) - last_user_idx
    -        session_entry.conversation_history = history[:last_user_idx]
    +        self.session_store.rewrite_transcript(session_entry.session_id, history[:last_user_idx])
             
             preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg
             return f"↩️ Undid {removed_count} message(s).\nRemoved: \"{preview}\""
    ```
  - `gateway/session.py`

    ```diff
    @@ -567,6 +567,37 @@ def append_to_transcript(self, session_id: str, message: Dict[str, Any]) -> None
             with open(transcript_path, "a") as f:
                 f.write(json.dumps(message, ensure_ascii=False) + "\n")
         
    +    def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
    +        """Replace a session's transcript with the given messages."""
    +        # Rewrite SQLite
    +        if self._db:
    +            try:
    +                self._db._conn.execute(
    +                    "DELETE FROM messages WHERE session_id = ?", (session_id,)
    +                )
    +                self._db._conn.execute(
    +                    "UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
    +                    (session_id,),
    +                )
    +                self._db._conn.commit()
    +                for msg in messages:
    +                    self._db.append_message(
    +                        session_id=session_id,
    +                        role=msg.get("role", "unknown"),
    +                        content=msg.get("content"),
    +                        tool_name=msg.get("tool_name"),
    +                        tool_calls=msg.get("tool_calls"),
    +                        tool_call_id=msg.get("tool_call_id"),
    +                    )
    +            except Exception as e:
    +                logger.debug("Session DB rewrite failed: %s", e)
    +
    +        # Rewrite legacy JSONL
    +        transcript_path = self.get_transcript_path(session_id)
    +        with open(transcript_path, "w") as f:
    +            for msg in messages:
    +                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
    +
         def load_transcript(self, session_id: str) -> List[Dict[str, Any]]:
             """Load all messages from a session's transcript."""
             # Try SQLite first
    ```
  - `tests/gateway/test_session.py`

    ```diff
    @@ -1,9 +1,13 @@
     """Tests for gateway session management."""
     
    +import json
     import pytest
    +from pathlib import Path
    +from unittest.mock import patch, MagicMock
     from gateway.config import Platform, HomeChannel, GatewayConfig, PlatformConfig
     from gateway.session import (
         SessionSource,
    +    SessionStore,
         build_session_context,
         build_session_context_prompt,
     )
    ```

    ```diff
    @@ -199,3 +203,59 @@ def test_whatsapp_prompt(self):
             prompt = build_session_context_prompt(ctx)
     
             assert "WhatsApp" in prompt or "whatsapp" in prompt.lower()
    +
    +
    +class TestSessionStoreRewriteTranscript:
    +    """Regression: /retry and /undo must persist truncated history to disk."""
    +
    +    @pytest.fixture()
    +    def store(self, tmp_path):
    +        config = GatewayConfig()
    +        with patch("gateway.session.SessionStore._ensure_loaded"):
    +            s = SessionStore(sessions_dir=tmp_path, config=config)
    +        s._db = None  # no SQLite for these tests
    +        s._loaded = True
    +        return s
    +
    +    def test_rewrite_replaces_jsonl(self, store, tmp_path):
    +        session_id = "test_session_1"
    +        # Write initial transcript
    +        for msg in [
    +            {"role": "user", "content": "hello"},
    +            {"role": "assistant", "content": "hi"},
    +            {"role": "user", "content": "undo this"},
    +            {"role": "assistant", "content": "ok"},
    +        ]:
    +            store.append_to_transcript(session_id, msg)
    +
    +        # Rewrite with truncated history
    +        store.rewrite_transcript(session_id, [
    +            {"role": "user", "content": "hello"},
    +            {"role": "assistant", "content": "hi"},
    +        ])
    +
    +        reloaded = store.load_transcript(session_id)
    +        assert len(reloaded) == 2
    +        assert reloaded[0]["content"] == "hello"
    +        assert reloaded[1]["content"] == "hi"
    +
    +    def test_rewrite_with_empty_list(self, store):
    +        session_id = "test_session_2"
    +        store.append_to_transcript(session_id, {"role": "user", "content": "hi"})
    +
    +        store.rewrite_transcript(session_id, [])
    +
    +        reloaded = store.load_transcript(session_id)
    +        assert reloaded == []
    +
    +
    +class TestSessionStoreEntriesAttribute:
    +    """Regression: /reset must access _entries, not _sessions."""
    +
    +    def test_entries_attribute_exists(self):
    +        config = GatewayConfig()
    +        with patch("gateway.session.SessionStore._ensure_loaded"):
    +            store = SessionStore(sessions_dir=Path("/tmp"), config=config)
    +        store._loaded = True
    +        assert hasattr(store, "_entries")
    +        assert not hasattr(store, "_sessions")
    ```
- **#213: fix(gateway): make /retry, /undo, and /reset actually work (fixes #210)**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`

## Fix status

- Merged to main in PR #217 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-03-02`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
