{"repo":"NousResearch/hermes-agent","public_id":"6508","display_label":"Topic-bound skills lost after session reset","summary":"A bug where topic-bound or auto-loaded skills are not re-injected following a /new or /reset session reset, with multiple fix attempts targeting session and gateway components.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":18205},"resolution":{"state":"partially_resolved","by":"pr-18205"},"freshness":{"state":"verified","last_verified_at":"2026-05-01T03:29:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":6508,"pr_number":18205,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":6508,"title":"Bug: Topic-bound skill not re-injected after /new session reset","url":null,"state":"closed"},{"kind":"issue","number":18461,"title":"Bug: auto_skill not loaded for topic-bound skills after session reset or gateway restart","url":null,"state":"closed"},{"kind":"pr","number":6520,"title":"fix(session): re-inject topic skills after /new reset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#6521","number":6521}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":6521,"title":"fix(gateway): re-inject topic-bound skill after /new session reset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10856","number":10856}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":10856,"title":"fix(gateway): re-inject topic-bound skill after /new session reset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18205","number":18205}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":18205,"title":"fix(gateway): re-inject topic-bound skill after /new or /reset","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/session.py","scripts/release.py","tests/gateway/test_fresh_reset_skill_injection.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -5282,7 +5282,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n         _is_new_session = (\n             session_entry.created_at == session_entry.updated_at\n             or getattr(session_entry, \"was_auto_reset\", False)\n+            or getattr(session_entry, \"is_fresh_reset\", False)\n         )\n+        # Consume the is_fresh_reset flag immediately so it doesn't leak\n+        # onto subsequent messages in the same session (issue #6508).\n+        if getattr(session_entry, \"is_fresh_reset\", False):\n+            session_entry.is_fresh_reset = False\n         if _is_new_session:\n             await self.hooks.emit(\"session:start\", {\n                 \"platform\": source.platform.value if source.platform else \"\","]},{"file":"gateway/session.py","hunks":["@@ -458,6 +458,15 @@ class SessionEntry:\n     was_auto_reset: bool = False\n     auto_reset_reason: Optional[str] = None  # \"idle\" or \"daily\"\n     reset_had_activity: bool = False  # whether the expired session had any messages\n+\n+    # Set by reset_session() when the user explicitly sends /new or /reset.\n+    # Consumed once by _handle_message_with_agent to trigger topic/channel\n+    # skill re-injection on the first message of the new session.  We can't\n+    # reuse was_auto_reset for this because that flag fires the \"session\n+    # expired due to inactivity\" user-facing notice and a misleading\n+    # context-note prepend — both wrong for an explicit manual reset.\n+    # See issue #6508.\n+    is_fresh_reset: bool = False\n     \n     # Set by the background expiry watcher after it finalizes an expired\n     # session (invoking on_session_finalize hooks and evicting the cached","@@ -508,6 +517,7 @@ def to_dict(self) -> Dict[str, Any]:\n                 if self.last_resume_marked_at\n                 else None\n             ),\n+            \"is_fresh_reset\": self.is_fresh_reset,\n         }\n         if self.origin:\n             result[\"origin\"] = self.origin.to_dict()","@@ -556,6 +566,7 @@ def from_dict(cls, data: Dict[str, Any]) -> \"SessionEntry\":\n             resume_pending=data.get(\"resume_pending\", False),\n             resume_reason=data.get(\"resume_reason\"),\n             last_resume_marked_at=last_resume_marked_at,\n+            is_fresh_reset=data.get(\"is_fresh_reset\", False),\n         )\n \n ","@@ -1132,6 +1143,7 @@ def reset_session(self, session_key: str) -> Optional[SessionEntry]:\n                 display_name=old_entry.display_name,\n                 platform=old_entry.platform,\n                 chat_type=old_entry.chat_type,\n+                is_fresh_reset=True,\n             )\n \n             self._entries[session_key] = new_entry"]},{"file":"scripts/release.py","hunks":["@@ -426,6 +426,8 @@\n     \"[redacted:email]\": \"ogzerber\",\n     \"[redacted:email]\": \"cola-runner\",\n     \"[redacted:email]\": \"ygd58\",\n+    \"[redacted:email]\": \"warabe1122\",\n+    \"[redacted:email]\": \"willy-scr\",\n     \"[redacted:email]\": \"vominh1919\",\n     \"[redacted:email]\": \"iamagenius00\",\n     \"[redacted:email]\": \"cresslank\","]},{"file":"tests/gateway/test_fresh_reset_skill_injection.py","hunks":["@@ -0,0 +1,201 @@\n+\"\"\"Regression tests for topic/channel skill auto-injection after /new or /reset.\n+\n+Covers the fix for issue #6508.\n+\n+Before the fix:\n+    1. User sends ``/new`` — ``reset_session`` creates a fresh SessionEntry\n+       with ``created_at == updated_at``.\n+    2. User sends the next message.\n+    3. ``get_or_create_session`` finds the entry and bumps\n+       ``entry.updated_at = now`` (microseconds after ``created_at``).\n+    4. ``_handle_message_with_agent`` checks\n+       ``_is_new_session = (created_at == updated_at) or was_auto_reset``.\n+       Both are False → ``_is_new_session = False`` → topic/channel skills\n+       are silently skipped for the first message of a manually reset session.\n+\n+After the fix:\n+    ``reset_session`` stamps the new entry with ``is_fresh_reset=True``.\n+    ``_handle_message_with_agent`` ORs this into ``_is_new_session`` and\n+    consumes the flag immediately after the check, so subsequent messages\n+    are treated as continuing the session and the flag does not leak.\n+\n+We use ``was_auto_reset`` for surprise resets (idle/daily/suspended) and\n+``is_fresh_reset`` for user-initiated resets because the former also drives\n+a \"Session automatically reset due to inactivity\" user-facing notice and\n+a context-note prepend into the agent's prompt — both wrong for an explicit\n+/new or /reset.\n+\"\"\"\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform\n+from gateway.session import SessionEntry, SessionSource, SessionStore\n+\n+\n+def _make_store(tmp_path):\n+    return SessionStore(sessions_dir=tmp_path, config=GatewayConfig())\n+\n+\n+def _make_source(chat_id=\"123\", user_id=\"u1\"):\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=chat_id,\n+        user_id=user_id,\n+    )\n+\n+\n+def _is_new_session(entry) -> bool:\n+    \"\"\"Mirror of the predicate in ``_handle_message_with_agent``.\n+\n+    Kept in-sync with the production check so this test fails loudly if the\n+    upstream logic regresses.\n+    \"\"\"\n+    return (\n+        entry.created_at == entry.updated_at\n+        or getattr(entry, \"was_auto_reset\", False)\n+        or getattr(entry, \"is_fresh_reset\", False)\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# reset_session stamps is_fresh_reset=True\n+# ---------------------------------------------------------------------------\n+\n+class TestResetSessionStampsFreshReset:\n+    def test_reset_session_sets_is_fresh_reset_true(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        store.get_or_create_session(source)\n+        session_key = store._generate_session_key(source)\n+\n+        new_entry = store.reset_session(session_key)\n+\n+        assert new_entry is not None\n+        assert new_entry.is_fresh_reset is True\n+\n+    def test_reset_session_unknown_key_returns_none(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        assert store.reset_session(\"unknown:key\") is None\n+\n+    def test_fresh_session_does_not_have_is_fresh_reset(self, tmp_path):\n+        \"\"\"A vanilla first-time session should not carry the flag.\"\"\"\n+        store = _make_store(tmp_path)\n+        entry = store.get_or_create_session(_make_source())\n+        assert entry.is_fresh_reset is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# Core regression: _is_new_session stays True after updated_at bump\n+# ---------------------------------------------------------------------------\n+\n+class TestIsNewSessionSurvivesUpdatedAtBump:\n+    def test_is_new_session_true_after_reset_then_next_message(self, tmp_path):\n+        \"\"\"The actual bug: _is_new_session was False on message after /reset.\"\"\"\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        store.get_or_create_session(source)\n+        session_key = store._generate_session_key(source)\n+\n+        # User sends /reset\n+        store.reset_session(session_key)\n+\n+        # Next inbound message — get_or_create_session bumps updated_at\n+        entry = store.get_or_create_session(source)\n+\n+        # Before the fix: created_at != updated_at, was_auto_reset=False → False\n+        # After the fix: is_fresh_reset=True carries the signal through the bump\n+        assert _is_new_session(entry) is True\n+\n+    def test_flag_consumed_after_first_read(self, tmp_path):\n+        \"\"\"After the message handler consumes is_fresh_reset, the NEXT\n+        message should not be treated as a new session (skill re-injection\n+        must not fire a second time).\n+        \"\"\"\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        store.get_or_create_session(source)\n+        session_key = store._generate_session_key(source)\n+        store.reset_session(session_key)\n+\n+        # First message — handler consumes the flag\n+        entry = store.get_or_create_session(source)\n+        assert _is_new_session(entry) is True\n+        entry.is_fresh_reset = False  # what _handle_message_with_agent does\n+\n+        # Second message — must not be treated as new\n+        entry = store.get_or_create_session(source)\n+        assert _is_new_session(entry) is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# Vanilla-session behavior is unchanged\n+# ---------------------------------------------------------------------------\n+\n+class TestVanillaBehaviorUnaffected:\n+    def test_ongoing_session_not_flagged_as_new(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        store.get_or_create_session(source)\n+\n+        # Second message on the same session — updated_at bumps,\n+        # is_fresh_reset was never set\n+        entry = store.get_or_create_session(source)\n+        assert entry.is_fresh_reset is False\n+        assert _is_new_session(entry) is False\n+\n+    def test_idle_auto_reset_does_not_set_is_fresh_reset(self, tmp_path):\n+        \"\"\"Idle/daily auto-resets use was_auto_reset — confirm they do NOT\n+        also set is_fresh_reset (which would double-fire the skill path and\n+        not leak through the auto-reset guard).\n+        \"\"\"\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+\n+        # Simulate the auto-reset code path: get_or_create_session's internal\n+        # branch that sets was_auto_reset does NOT touch is_fresh_reset.\n+        # Construct a fresh entry the same way that branch does.\n+        store._entries.pop(store._generate_session_key(source))\n+        fresh = SessionEntry(\n+            session_key=entry.session_key,\n+            session_id=\"new_id\",\n+            created_at=entry.created_at,\n+            updated_at=entry.created_at,\n+            origin=source,\n+            was_auto_reset=True,\n+            auto_reset_reason=\"idle\",\n+        )\n+        assert fresh.is_fresh_reset is False\n+        assert fresh.was_auto_reset is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Persistence through sessions.json round-trip\n+# ---------------------------------------------------------------------------\n+\n+class TestPersistence:\n+    def test_is_fresh_reset_survives_to_dict_from_dict(self, tmp_path):\n+        \"\"\"Protect against the gateway restarting between /reset and the\n+        next message — the flag must be persisted in sessions.json.\n+        \"\"\"\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        store.get_or_create_session(source)\n+        session_key = store._generate_session_key(source)\n+        new_entry = store.reset_session(session_key)\n+\n+        assert new_entry.is_fresh_reset is True\n+        restored = SessionEntry.from_dict(new_entry.to_dict())\n+        assert restored.is_fresh_reset is True\n+\n+    def test_default_false_when_missing_from_dict(self, tmp_path):\n+        \"\"\"Older sessions.json files written before this field existed must\n+        load cleanly with is_fresh_reset defaulting to False.\n+        \"\"\"\n+        data = {\n+            \"session_key\": \"telegram:1:123\",\n+            \"session_id\": \"sess1\",\n+            \"created_at\": \"2026-01-01T00:00:00\",\n+            \"updated_at\": \"2026-01-01T00:00:00\",\n+        }\n+        entry = SessionEntry.from_dict(data)\n+        assert entry.is_fresh_reset is False\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}