From 91799405aa3894989851d696e069fa5b3e43bf1c Mon Sep 17 00:00:00 2001 From: metamon <269728612+metamon-p@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:02:35 -0700 Subject: [PATCH] fix(gateway): hint Slack/Discord channels at the prior auto-reset session Salvaged from PR #36220, ported onto the current SessionStore (SQLite- backed get_or_create_session; activity check is last_prompt_tokens) and the sidecar-note reset path (context notes now ride turn_sidecar_notes instead of prepending to context_prompt). Long-lived Slack/Discord channels/threads lose their context on daily/idle session resets, and the agent can bind a new request to an unrelated recent session (observed: a Discord thread reset caused a PR in the wrong repository). Record prev_session_id when an auto-reset replaces a session with real activity, persist it, and append a deterministic one-line hint to the auto-reset context note pointing the agent at session_search for that specific prior session. No LLM calls, no channel-history APIs, no extra DB lookups; other platforms and activity-free resets are untouched. Refs #36220. Co-authored-by: metamon <269728612+metamon-p@users.noreply.github.com> --- gateway/run.py | 12 ++ gateway/session.py | 53 +++++++ tests/gateway/test_channel_continuity_hint.py | 145 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 tests/gateway/test_channel_continuity_hint.py diff --git a/gateway/run.py b/gateway/run.py index b0dd0d0ad5522..9b33930079525 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1972,6 +1972,7 @@ from gateway.session import ( SessionContext, build_session_context, build_session_context_prompt, + build_channel_continuity_note, build_session_key, is_shared_multi_user_session, neutralize_untrusted_inline_text, @@ -12587,6 +12588,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew context_note = "[System note: The previous gateway session could not be recovered after a restart (API recovery timed out). This is a fresh conversation — use /resume to restore history if needed.]" else: context_note = "[System note: The user's previous session expired due to inactivity. This is a fresh conversation with no prior context.]" + # Slack/Discord channels/threads are long-lived: point the agent at + # the specific prior same-channel session so it recalls that context + # via session_search instead of an unrelated recent session. Returns + # None (appends nothing) for other platforms or when there's no prior + # activity to recall. Deterministic — no extra API/DB calls (#36220). + try: + continuity_note = build_channel_continuity_note(session_entry, source) + except Exception: + continuity_note = None + if continuity_note: + context_note = context_note + "\n\n" + continuity_note turn_sidecar_notes.append(context_note) # Send a user-facing notification explaining the reset, unless: diff --git a/gateway/session.py b/gateway/session.py index ed2ffaea10d30..968c7f21dc136 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -784,6 +784,13 @@ class SessionEntry: auto_reset_reason: Optional[str] = None # "idle" or "daily" reset_had_activity: bool = False # whether the expired session had any messages + # When this session was created by an auto-reset, the session_id of the + # session it replaced. Used to give Slack/Discord channels/threads a + # lightweight continuity hint (see build_channel_continuity_note) so the + # agent recalls the prior same-channel session via session_search instead + # of binding the request to an unrelated recent session. + prev_session_id: Optional[str] = None + # Set by reset_session() when the user explicitly sends /new or /reset. # Consumed once by _handle_message_with_agent to trigger topic/channel # skill re-injection on the first message of the new session. We can't @@ -856,6 +863,7 @@ class SessionEntry: "was_auto_reset": self.was_auto_reset, "auto_reset_reason": self.auto_reset_reason, "reset_had_activity": self.reset_had_activity, + "prev_session_id": self.prev_session_id, } if self.model_override: # Defence-in-depth: strip credentials even if a caller stored an @@ -932,10 +940,51 @@ class SessionEntry: was_auto_reset=data.get("was_auto_reset", False), auto_reset_reason=data.get("auto_reset_reason"), reset_had_activity=data.get("reset_had_activity", False), + prev_session_id=data.get("prev_session_id"), model_override=sanitize_model_override(data.get("model_override")), ) +def build_channel_continuity_note( + entry: "SessionEntry", + source: SessionSource, +) -> Optional[str]: + """Build a lightweight session-continuity hint for Slack/Discord channels. + + Slack and Discord channels/threads are long-lived: when the daily/idle + reset policy starts a fresh session, the agent loses the thread's prior + context and can mistakenly bind a new request to an unrelated recent + session. This deterministic one-line hint points the agent at the + specific prior session in *this* channel/thread so it recalls that + context via ``session_search`` before acting. + + Returns ``None`` (and the caller adds nothing) unless **all** hold: + - the source platform is Slack or Discord, + - this session was created by an auto-reset that had real activity, + - the previous session_id was recorded on the entry. + + No LLM calls, no extra API/DB lookups — the previous session id is + already known from :meth:`SessionStore.get_or_create_session`. + """ + if source.platform not in (Platform.SLACK, Platform.DISCORD): + return None + if not getattr(entry, "reset_had_activity", False): + return None + prev = getattr(entry, "prev_session_id", None) + if not prev: + return None + + where = "thread" if source.thread_id else "channel" + return ( + f"[System note: This {where} had an earlier Hermes session " + f"(session_id: {prev}) that was auto-reset. If the user refers to " + f"earlier work here, or the request depends on this {where}'s history, " + f"use the session_search tool to recall that prior session before " + f"acting — do not assume an unrelated recent session is the right " + f"context.]" + ) + + def is_shared_multi_user_session( source: SessionSource, *, @@ -2052,6 +2101,7 @@ class SessionStore: was_auto_reset = False auto_reset_reason = None reset_had_activity = False + prev_session_id: Optional[str] = None with self._lock: self._ensure_loaded_locked() @@ -2086,6 +2136,7 @@ class SessionStore: auto_reset_reason = _reset_reason reset_had_activity = entry.last_prompt_tokens > 0 db_end_session_id = entry.session_id + prev_session_id = entry.session_id entry = None _needs_recover = True elif entry.session_id != _stale_session_id: @@ -2100,6 +2151,7 @@ class SessionStore: auto_reset_reason = _reset_reason reset_had_activity = entry.last_prompt_tokens > 0 db_end_session_id = entry.session_id + prev_session_id = entry.session_id self._entries.pop(session_key, None) entry = None _needs_recover = True @@ -2140,6 +2192,7 @@ class SessionStore: was_auto_reset=was_auto_reset, auto_reset_reason=auto_reset_reason, reset_had_activity=reset_had_activity, + prev_session_id=prev_session_id, ) with self._lock: current = self._entries.get(session_key) diff --git a/tests/gateway/test_channel_continuity_hint.py b/tests/gateway/test_channel_continuity_hint.py new file mode 100644 index 0000000000000..6e039d5cfff63 --- /dev/null +++ b/tests/gateway/test_channel_continuity_hint.py @@ -0,0 +1,145 @@ +"""Tests for the lightweight Slack/Discord channel session-continuity hint. + +Salvaged from PR #36220 (metamon-p), ported onto the current SessionStore. + +Covers: +- SessionStore records the previous session_id on auto-reset (and only then). +- prev_session_id survives a to_dict() → from_dict() roundtrip (gateway restart). +- build_channel_continuity_note() emits a hint only for Slack/Discord sessions + that were auto-reset with real prior activity, and stays silent otherwise. +""" + +from datetime import datetime, timedelta + +import pytest + +from gateway.config import GatewayConfig, Platform, SessionResetPolicy +from gateway.session import ( + SessionEntry, + SessionSource, + SessionStore, + build_channel_continuity_note, +) + + +@pytest.fixture() +def _isolated_db(tmp_path, monkeypatch): + import hermes_state + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + return tmp_path + + +def _make_store(tmp_path, policy=None): + config = GatewayConfig() + if policy: + config.default_reset_policy = policy + return SessionStore(sessions_dir=tmp_path / "sessions", config=config) + + +def _slack_source(thread_id=None): + return SessionSource( + platform=Platform.SLACK, + chat_id="C123", + chat_type="thread" if thread_id else "channel", + user_id="U1", + thread_id=thread_id, + ) + + +# --------------------------------------------------------------------------- +# SessionStore records prev_session_id on auto-reset +# --------------------------------------------------------------------------- + +class TestPrevSessionIdCapture: + def test_prev_session_id_set_on_auto_reset(self, _isolated_db, tmp_path): + store = _make_store(tmp_path, SessionResetPolicy(mode="idle", idle_minutes=1)) + source = _slack_source(thread_id="T9") + + entry1 = store.get_or_create_session(source) + assert entry1.prev_session_id is None # fresh session, nothing replaced + + entry1.last_prompt_tokens = 4000 # had real conversation + entry1.updated_at = datetime.now() - timedelta(minutes=5) + store._save() + + entry2 = store.get_or_create_session(source) + assert entry2.was_auto_reset is True + assert entry2.reset_had_activity is True + assert entry2.prev_session_id == entry1.session_id + + def test_prev_session_id_none_without_reset(self, _isolated_db, tmp_path): + store = _make_store(tmp_path) + source = _slack_source() + + entry = store.get_or_create_session(source) + assert entry.prev_session_id is None + + def test_prev_session_id_roundtrips_serialization(self): + entry = SessionEntry( + session_key="k", + session_id="20260101_010000_def", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.SLACK, + was_auto_reset=True, + auto_reset_reason="daily", + reset_had_activity=True, + prev_session_id="20260101_000000_abc", + ) + reloaded = SessionEntry.from_dict(entry.to_dict()) + assert reloaded.prev_session_id == "20260101_000000_abc" + + +# --------------------------------------------------------------------------- +# build_channel_continuity_note +# --------------------------------------------------------------------------- + +def _reset_entry(platform, prev="20260101_000000_abc", had_activity=True): + return SessionEntry( + session_key="k", + session_id="20260101_010000_def", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=platform, + was_auto_reset=True, + auto_reset_reason="daily", + reset_had_activity=had_activity, + prev_session_id=prev, + ) + + +class TestBuildChannelContinuityNote: + def test_slack_channel_emits_hint(self): + entry = _reset_entry(Platform.SLACK) + note = build_channel_continuity_note(entry, _slack_source()) + assert note is not None + assert "session_search" in note + assert entry.prev_session_id in note + assert "channel" in note + + def test_discord_thread_uses_thread_wording(self): + entry = _reset_entry(Platform.DISCORD) + source = SessionSource( + platform=Platform.DISCORD, + chat_id="c", + chat_type="thread", + thread_id="T1", + ) + note = build_channel_continuity_note(entry, source) + assert note is not None + assert "thread" in note + + def test_other_platform_returns_none(self): + entry = _reset_entry(Platform.TELEGRAM) + source = SessionSource(platform=Platform.TELEGRAM, chat_id="c", user_id="u") + assert build_channel_continuity_note(entry, source) is None + + def test_no_activity_returns_none(self): + entry = _reset_entry(Platform.SLACK, had_activity=False) + assert build_channel_continuity_note(entry, _slack_source()) is None + + def test_no_prev_session_id_returns_none(self): + entry = _reset_entry(Platform.SLACK, prev=None) + assert build_channel_continuity_note(entry, _slack_source()) is None