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 " #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"