From 06475d1138df5aff34e103810e04fcc3c03d9b51 Mon Sep 17 00:00:00 2001 From: nicecuprani Date: Thu, 17 Sep 2026 14:28:56 +0300 Subject: [PATCH] fix(session-titles): resolve the canonical Bot Chat by exact title, never a "#N" sibling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SessionDB.resolve_session_by_title()` prefers a "` #N`" continuation over the exact match. For the canonical Bot Chat that preference is wrong: Bot Mode identifies that session BY its exact title, and this lookup is the only one every DM transport shares (`hermes -p <bot> chat --in ~ -c "Bot Chat"` — used by `message_agent`, `bot_relay` and cron delivery). Once a numbered sibling exists (a Desktop branch of the canonical chat, `title_source='derived'`), every teammate DM resolves to that sibling: a visible, unmanaged session where the `message_agent` gate is off, so the addressed bot cannot answer and the sender gets silence instead of an error. Return the exact match when the requested title IS the canonical Bot Chat title; every other title keeps the "newest continuation wins" behaviour untouched. (cherry picked from commit e92638b4c53c97c48db4d9bbb2d3b6b580d289a7) --- hermes_state_titles.py | 9 ++++++ .../test_canonical_title_guard.py | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/hermes_state_titles.py b/hermes_state_titles.py index 4c49aaad8c..84d8f3e0cc 100644 --- a/hermes_state_titles.py +++ b/hermes_state_titles.py @@ -167,6 +167,15 @@ class SessionTitlesMixin: def resolve_session_by_title(self, title: str) -> Optional[str]: """Resolve a title to a session ID, preferring the latest "title #N" continuation.""" exact = self.get_session_by_title(title) + # Exception to the "#N continuation" preference: the canonical Bot Chat's identity + # IS its exact title (Bot Mode re-resolves it by name on every open, no id pointer). + # A "<title> #N" sibling — a Desktop branch or a client-minted numbered row — is NOT + # a Bot Mode session: it is visible, unmanaged, and the message_agent gate is off in + # it. Every DM transport (``hermes -p <bot> chat --in ~ -c "Bot Chat"``: message_agent, + # bot_relay, cron delivery) resolves through here, so letting the numbered row win + # silently routes teammates' messages into a chat whose bot cannot answer back. + if exact is not None and title == self.CANONICAL_BOT_CHAT_TITLE: + return exact["id"] # Escape LIKE wildcards so "%"/"_" in titles cannot false-match. numbered = self._read_all( "SELECT id, title, started_at FROM sessions " diff --git a/tests/hermes_state/test_canonical_title_guard.py b/tests/hermes_state/test_canonical_title_guard.py index a7a6e060f6..f246fe0978 100644 --- a/tests/hermes_state/test_canonical_title_guard.py +++ b/tests/hermes_state/test_canonical_title_guard.py @@ -142,3 +142,35 @@ def test_auto_titler_can_rename_visible_derived_bot_chat(db): source=SessionDB.TITLE_SOURCE_LLM, ) assert db.get_session("visible")["title"] == "Renamed by titler" + + +def test_numbered_branch_never_shadows_the_canonical_chat(db): + """Title resolution must land on the canonical Bot Chat, never on a "#N" sibling. + + Every DM transport resolves the target bot by that exact name + (``hermes -p <bot> chat --in ~ -c "Bot Chat"``: message_agent, bot_relay, cron + delivery). A numbered sibling — a Desktop branch, which is visible and NOT + Bot-Mode-managed — would otherwise swallow each teammate's message into a + session whose bot has no message_agent and cannot answer. + """ + canonical = _make_canonical(db, "forever") + db.create_session("branch", source="desktop", parent_session_id=canonical) + assert db._set_session_title( + "branch", + f"{SessionDB.CANONICAL_BOT_CHAT_TITLE} #2", + source=SessionDB.TITLE_SOURCE_DERIVED, + ) + assert db.set_session_hidden("branch", False) + + assert db.resolve_session_by_title(SessionDB.CANONICAL_BOT_CHAT_TITLE) == canonical + + +def test_numbered_continuation_still_wins_for_ordinary_titles(db): + # Control: the "#N continuation" preference is the point of the lookup — it + # stays intact for every non-Bot-Chat title. + db.create_session("plain", source="cli") + assert db.set_session_title("plain", "My session") + db.create_session("plain2", source="cli") + assert db.set_session_title("plain2", "My session #2") + + assert db.resolve_session_by_title("My session") == "plain2"