From d6615d8ec7d5c5a3d7b63e3c2c4bfddbd6fddd4b Mon Sep 17 00:00:00 2001 From: EmelyanenkoK Date: Sat, 2 May 2026 18:04:57 +0300 Subject: [PATCH] feat: add Telegram DM topic-mode sessions --- ...ram-dm-user-managed-multisession-topics.md | 473 +++++++++++++ gateway/run.py | 265 +++++++- hermes_cli/commands.py | 2 + hermes_state.py | 259 ++++++++ tests/gateway/test_telegram_topic_mode.py | 625 ++++++++++++++++++ tests/hermes_cli/test_commands.py | 6 + tests/test_hermes_state.py | 237 +++++++ ui-tui/package-lock.json | 41 +- 8 files changed, 1890 insertions(+), 18 deletions(-) create mode 100644 docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md create mode 100644 tests/gateway/test_telegram_topic_mode.py diff --git a/docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md b/docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md new file mode 100644 index 0000000000..43c0e5da78 --- /dev/null +++ b/docs/plans/2026-05-02-telegram-dm-user-managed-multisession-topics.md @@ -0,0 +1,473 @@ +# Telegram DM User-Managed Multi-Session Topics Implementation Plan + +> **For Hermes:** Use test-driven-development for implementation. Use subagent-driven-development only after this plan is split into small reviewed tasks. + +**Goal:** Add an opt-in Telegram DM multi-session mode where Telegram user-created private-chat topics become independent Hermes session lanes, while the root DM becomes a system lobby. + +**Architecture:** Rely on Telegram's native private-chat topic UI. Users create new topics with the `+` button; Hermes maps each `message_thread_id` to a separate session lane. Hermes does not create topics for normal `/new` flow and does not try to manage topic lifecycle beyond activation/status, root-lobby behavior, and restoring legacy sessions into a user-created topic. + +**Tech Stack:** Hermes gateway, Telegram Bot API 9.4+, python-telegram-bot adapter, SQLite SessionDB / side tables, pytest. + +--- + +## 1. Product decisions + +### Accepted + +- PR-quality implementation: migrations, tests, docs, backwards compatibility. +- Use SQLite persistence, not JSON sidecars. +- Live status suffixes in topic titles are out of MVP. +- Topic title sync/editing is out of MVP except future-compatible storage if cheap. +- User creates Telegram topics manually through the Telegram bot interface. +- `/new` does **not** create Telegram topics. +- Root/main DM becomes a system lobby after activation. +- Existing Telegram behavior remains unchanged until the feature is activated/enabled. +- Migration of old sessions is supported through `/topic` listing and `/topic ` restore inside a user-created topic. + +### Telegram API assumptions verified from Bot API docs + +- `getMe` returns bot `User` fields: + - `has_topics_enabled`: forum/topic mode enabled in private chats. + - `allows_users_to_create_topics`: users may create/delete topics in private chats. +- `createForumTopic` works for private chats with a user, but MVP does not rely on it for normal flow. +- `Message.message_thread_id` identifies a topic in private chats. +- `sendMessage` supports `message_thread_id` for private-chat topics. +- `pinChatMessage` is allowed in private chats. + +--- + +## 2. Target UX + +### 2.1 Activation from root/main DM + +User sends: + +```text +/topic +``` + +Hermes: + +1. calls Telegram `getMe`; +2. verifies `has_topics_enabled` and `allows_users_to_create_topics`; +3. enables multi-session topic mode for this Telegram DM user/chat; +4. sends an onboarding message; +5. pins the onboarding message if configured; +6. shows old/unlinked sessions that can be restored into topics. + +Suggested onboarding text: + +```text +Multi-session mode is enabled. + +Create new Hermes chats with the + button in this bot interface. Each Telegram topic is an independent Hermes session, so you can work on different tasks in parallel. + +This main chat is reserved for system commands, status, and session management. + +To restore an old session: +1. Use /topic here to see unlinked sessions. +2. Create a new topic with the + button. +3. Send /topic inside that topic. +``` + +### 2.2 Root/main DM after activation + +Root DM is a system lobby. + +Allowed/system commands include at least: + +- `/topic` +- `/status` +- `/sessions` if available +- `/usage` +- `/help` +- `/platforms` + +Normal user prompts in root DM do not enter the agent loop. Reply: + +```text +This main chat is reserved for system commands. + +To chat with Hermes, create a new topic using the + button in this bot interface. Each topic works as an independent Hermes session. +``` + +`/new` in root DM does not create a session/topic. Reply: + +```text +To start a new parallel Hermes chat, create a new topic with the + button in this bot interface. + +Each topic is an independent Hermes session. Use /new inside a topic only if you want to replace that topic's current session. +``` + +### 2.3 First message in a user-created topic + +When a user creates a Telegram topic and sends the first message there: + +1. Hermes receives a Telegram DM message with `message_thread_id`. +2. Hermes derives the existing thread-aware `session_key` from `(platform=telegram, chat_type=dm, chat_id, thread_id)`. +3. If no binding exists, Hermes creates a fresh Hermes session for this topic lane and persists the binding. +4. The message runs through the normal agent loop for that lane. + +### 2.4 `/new` inside a non-main topic + +`/new` remains supported but replaces the session attached to the current topic lane. + +Hermes should warn: + +```text +Started a new Hermes session in this topic. + +Tip: for parallel work, create a new topic with the + button instead of using /new here. /new replaces the session attached to the current topic. +``` + +### 2.5 `/topic` in root/main DM after activation + +Shows: + +- mode enabled/disabled; +- last capability check result; +- whether intro message is pinned if known; +- count of known topic bindings; +- list of old/unlinked sessions. + +Example: + +```text +Telegram multi-session topics are enabled. + +Create new Hermes chats with the + button in this bot interface. + +Unlinked previous sessions: +1. 2026-05-01 Research notes — id: abc123 +2. 2026-04-30 Deploy debugging — id: def456 +3. Untitled session — id: ghi789 + +To restore one: +1. Create a new topic with the + button. +2. Open that topic. +3. Send /topic +``` + +### 2.6 `/topic` inside a non-main topic + +Without args, show the current topic binding: + +```text +This topic is linked to: +Session: Research notes +ID: abc123 + +Use /new to replace this topic with a fresh session. +For parallel work, create another topic with the + button. +``` + +### 2.7 `/topic ` inside a non-main topic + +Restore an old/unlinked session into the current user-created topic. + +Behavior: + +1. reject if not in Telegram DM topic; +2. verify session belongs to the same Telegram user/chat or is a safe legacy root DM session for this user; +3. reject if session is already linked to another active topic in MVP; +4. `SessionStore.switch_session(current_topic_session_key, target_session_id)`; +5. upsert binding with `managed_mode = restored`; +6. send two messages into the topic: + - session restored confirmation; + - last Hermes assistant message if available. + +Example: + +```text +Session restored: Research notes + +Last Hermes message: +... +``` + +--- + +## 3. Persistence model + +Use SQLite, but topic-mode schema changes are **explicit opt-in migrations**, not automatic startup reconciliation. + +Important rollback-safety rule: + +- upgrading Hermes and starting the gateway must not create Telegram topic-mode tables or columns; +- old/default Telegram behavior must keep working on the existing `state.db`; +- the first `/topic` activation path calls an idempotent explicit migration, then enables topic mode for that chat; +- if activation fails before the migration is needed, the database remains in the pre-topic-mode shape. + +### 3.1 No eager `sessions` table mutation for MVP + +Do **not** add `chat_id`, `chat_type`, `thread_id`, or `session_key` columns to `sessions` as part of ordinary `SessionDB()` startup. The existing declarative `_reconcile_columns()` mechanism would add them eagerly on every process start, which violates the managed-migration requirement. + +For MVP, keep origin/session-lane data in topic-specific side tables created only by the explicit `/topic` migration. Legacy unlinked sessions can be discovered conservatively from existing data (`source = telegram`, `user_id = current Telegram user`) plus absence from topic bindings. + +If future PRs need richer origin metadata for all gateway sessions, introduce it behind a separate explicit migration/command or a compatibility-reviewed schema bump. + +### 3.2 Explicit `/topic` migration API + +Add an idempotent method such as: + +```python +def apply_telegram_topic_migration(self) -> None: ... +``` + +It creates only topic-mode side tables/indexes and records: + +```text +state_meta.telegram_dm_topic_schema_version = 1 +``` + +This method is called from `/topic` activation/status paths before reading or writing topic-mode state. It is not called from generic `SessionDB.__init__`, gateway startup, CLI startup, or auto-maintenance. + +### 3.3 `telegram_dm_topic_mode` + +Stores per-user/chat activation state. Created only by `apply_telegram_topic_migration()`. + +Suggested fields: + +- `chat_id` primary key +- `user_id` +- `enabled` +- `activated_at` +- `updated_at` +- `has_topics_enabled` +- `allows_users_to_create_topics` +- `capability_checked_at` +- `intro_message_id` +- `pinned_message_id` + +### 3.4 `telegram_dm_topic_bindings` + +Stores Telegram topic/thread to Hermes session binding. Created only by `apply_telegram_topic_migration()`. + +Suggested fields: + +- `chat_id` +- `thread_id` +- `user_id` +- `session_key` +- `session_id` +- `managed_mode` + - `auto` + - `restored` + - `new_replaced` +- `linked_at` +- `updated_at` + +Recommended constraints: + +- primary key `(chat_id, thread_id)`; +- unique index on `session_id` for MVP to prevent one session linked to multiple topics; +- index `(user_id, chat_id)` for status/listing. + +### 3.5 Unlinked session semantics + +For MVP, a session is unlinked if: + +- `source = telegram`; +- `user_id = current Telegram user`; +- no row in `telegram_dm_topic_bindings` has `session_id = session_id`. + +This is intentionally conservative until a future explicit migration adds richer cross-platform origin metadata. + +Never dedupe by title. + +--- + +## 4. Config + +Suggested config block: + +```yaml +platforms: + telegram: + extra: + multisession_topics: + enabled: false + mode: user_managed_topics + root_chat_behavior: system_lobby + pin_intro_message: true +``` + +Notes: + +- `enabled: false` means existing Telegram behavior is unchanged. +- Activation via `/topic` may create per-chat enabled state only if global config permits it. +- `root_chat_behavior: system_lobby` is the MVP behavior for activated chats. + +--- + +## 5. Command behavior summary + +### `/topic` root/main DM + +- If not activated: capability check, activate, send/pin onboarding, list unlinked sessions. +- If activated: show status and unlinked sessions. + +### `/topic` non-main topic + +- Show current binding. + +### `/topic ` root/main DM + +Reject with instructions: + +```text +Create a new topic with the + button, open it, then send /topic there to restore this session. +``` + +### `/topic ` non-main topic + +Restore that session into this topic if ownership/linking checks pass. + +### `/new` root/main DM when activated + +Reply with instructions to use the `+` button. Do not enter agent loop. + +### `/new` non-main topic + +Create a new session in the current topic lane, persist/update binding, warn that `+` is preferred for parallel work. + +### Normal text root/main DM when activated + +Reply with system-lobby instruction. Do not enter agent loop. + +### Normal text non-main topic + +Normal Hermes agent flow for that topic's session lane. + +--- + +## 6. PR breakdown + +### PR 1 — Explicit topic-mode schema migration + +**Goal:** Add rollback-safe SQLite support for Telegram topic mode without mutating `state.db` on ordinary upgrade/startup. + +**Files likely touched:** + +- `hermes_state.py` +- tests under `tests/` + +**Tests first:** + +1. opening an old/current DB with `SessionDB()` does not create topic-mode tables or `sessions` origin columns; +2. calling `apply_telegram_topic_migration()` creates `telegram_dm_topic_mode` and `telegram_dm_topic_bindings` idempotently; +3. migration records `state_meta.telegram_dm_topic_schema_version = 1`. + +### PR 2 — Topic mode activation and binding APIs + +**Goal:** Add SQLite persistence for activation and topic bindings. + +**Tests first:** + +1. enable/check mode row round-trips; +2. binding upsert and lookup by `(chat_id, user_id, thread_id)`; +3. linked sessions are excluded from unlinked list. + +### PR 3 — `/topic` activation/status command + +**Goal:** Implement root activation/status/listing behavior. + +**Tests first:** + +1. `/topic` in root checks `getMe` capabilities and records activation; +2. capability failure returns readable instructions; +3. activated root `/topic` lists unlinked sessions. + +### PR 4 — System lobby behavior + +**Goal:** Prevent root chat from entering agent loop after activation. + +**Tests first:** + +1. normal text in activated root returns lobby instruction; +2. `/new` in activated root returns `+` button instruction; +3. non-activated root behavior is unchanged. + +### PR 5 — Auto-bind user-created topics + +**Goal:** First message in non-main topic creates/uses an independent session lane. + +**Tests first:** + +1. new topic message creates binding with `auto_created`; +2. repeated topic message reuses same binding/lane; +3. two topics in same DM do not share sessions. + +### PR 6 — Restore legacy sessions into a topic + +**Goal:** Implement `/topic ` in non-main topics. + +**Tests first:** + +1. root `/topic ` rejects with instructions; +2. topic `/topic ` switches current topic lane to target session; +3. restore rejects sessions from other users/chats; +4. restore rejects already-linked sessions; +5. restore emits confirmation and last Hermes assistant message. + +### PR 7 — `/new` inside topic updates binding + +**Goal:** Keep existing `/new` semantics but persist topic binding replacement. + +**Tests first:** + +1. `/new` in topic creates a new session for same topic lane; +2. binding updates to `managed_mode = new_replaced`; +3. response includes guidance to use `+` for parallel work. + +### PR 8 — Docs and polish + +**Goal:** Document the feature and Telegram setup. + +**Files likely touched:** + +- `website/docs/user-guide/messaging/telegram.md` +- maybe `website/docs/user-guide/sessions.md` + +Docs must explain: + +- BotFather/Telegram settings for topic mode and user-created topics; +- `/topic` activation; +- root system lobby; +- using `+` for new parallel chats; +- restoring old sessions with `/topic ` inside a topic; +- limitations. + +--- + +## 7. Testing / quality gates + +Run targeted tests after each TDD cycle, then broader tests before completion. + +Suggested commands after inspection confirms test paths: + +```bash +python -m pytest tests/test_hermes_state.py -q +python -m pytest tests/gateway/ -q +python -m pytest tests/ -o 'addopts=' -q +``` + +Do not ship without verifying disabled-feature backwards compatibility. + +--- + +## 8. Definition of done for MVP + +- `/topic` activates/checks Telegram DM multi-session mode. +- Root DM becomes a system lobby after activation. +- Onboarding message tells users to create new chats with the Telegram `+` button. +- Onboarding message can be pinned in private chat. +- User-created topics automatically become independent Hermes session lanes. +- `/new` in root gives instructions, not a new agent run. +- `/new` in a topic creates a new session in that topic and warns that `+` is preferred for parallel work. +- `/topic` in root lists unlinked old sessions. +- `/topic ` inside a topic restores that session and sends confirmation + last Hermes assistant message. +- Ownership checks prevent restoring other users' sessions. +- Already-linked sessions are not restored into a second topic in MVP. +- Existing Telegram behavior is unchanged when the feature is disabled. +- Tests and docs are included. diff --git a/gateway/run.py b/gateway/run.py index 6047de3220..40c4bdb453 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1454,6 +1454,85 @@ class GatewayRunner: thread_sessions_per_user=getattr(config, "thread_sessions_per_user", False), ) + def _telegram_topic_mode_enabled(self, source: SessionSource) -> bool: + """Return whether Telegram DM topic mode is active for this chat.""" + if source.platform != Platform.TELEGRAM or source.chat_type != "dm": + return False + session_db = getattr(self, "_session_db", None) + if session_db is None: + return False + try: + return bool( + session_db.is_telegram_topic_mode_enabled( + chat_id=str(source.chat_id), + user_id=str(source.user_id), + ) + ) + except Exception: + logger.debug("Failed to read Telegram topic mode state", exc_info=True) + return False + + def _is_telegram_topic_root_lobby(self, source: SessionSource) -> bool: + """True for the main Telegram DM when topic mode has made it a lobby.""" + return ( + source.platform == Platform.TELEGRAM + and source.chat_type == "dm" + and not source.thread_id + and self._telegram_topic_mode_enabled(source) + ) + + def _is_telegram_topic_lane(self, source: SessionSource) -> bool: + """True for a user-created Telegram private-chat topic lane.""" + return ( + source.platform == Platform.TELEGRAM + and source.chat_type == "dm" + and bool(source.thread_id) + and self._telegram_topic_mode_enabled(source) + ) + + def _telegram_topic_root_lobby_message(self) -> str: + return ( + "This main chat is reserved for system commands.\n\n" + "To chat with Hermes, create a new topic using the + button in " + "this bot interface. Each topic works as an independent Hermes " + "session." + ) + + def _telegram_topic_root_new_message(self) -> str: + return ( + "To start a new parallel Hermes chat, create a new topic with the " + "+ button in this bot interface.\n\n" + "Each topic is an independent Hermes session. Use /new inside a " + "topic only if you want to replace that topic's current session." + ) + + def _telegram_topic_new_header(self, source: SessionSource) -> Optional[str]: + if not self._is_telegram_topic_lane(source): + return None + return ( + "Started a new Hermes session in this topic.\n\n" + "Tip: for parallel work, create a new topic with the + button " + "instead of using /new here. /new replaces the session attached " + "to the current topic." + ) + + def _record_telegram_topic_binding( + self, + source: SessionSource, + session_entry, + ) -> None: + """Persist the Telegram topic -> Hermes session binding for topic lanes.""" + session_db = getattr(self, "_session_db", None) + if session_db is None or not source.chat_id or not source.thread_id: + return + session_db.bind_telegram_topic( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + user_id=str(source.user_id or ""), + session_key=session_entry.session_key, + session_id=session_entry.session_id, + ) + def _resolve_session_agent_runtime( self, *, @@ -5274,7 +5353,12 @@ class GatewayRunner: break if canonical == "new": + if self._is_telegram_topic_root_lobby(source): + return self._telegram_topic_root_new_message() return await self._handle_reset_command(event) + + if canonical == "topic": + return await self._handle_topic_command(event) if canonical == "help": return await self._handle_help_command(event) @@ -5523,6 +5607,9 @@ class GatewayRunner: # No bare text matching — "yes" in normal conversation must not trigger # execution of a dangerous command. + if self._is_telegram_topic_root_lobby(source): + return self._telegram_topic_root_lobby_message() + # ── Claim this session before any await ─────────────────────── # Between here and _run_agent registering the real AIAgent, there # are numerous await points (hooks, vision enrichment, STT, @@ -5798,6 +5885,22 @@ class GatewayRunner: # Get or create session session_entry = self.session_store.get_or_create_session(source) session_key = session_entry.session_key + if self._is_telegram_topic_lane(source): + try: + binding = self._session_db.get_telegram_topic_binding( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + ) if self._session_db else None + except Exception: + logger.debug("Failed to read Telegram topic binding", exc_info=True) + binding = None + if binding: + session_entry.session_id = str(binding.get("session_id") or session_entry.session_id) + else: + try: + self._record_telegram_topic_binding(source, session_entry) + except Exception: + logger.debug("Failed to record Telegram topic binding", exc_info=True) if getattr(session_entry, "was_auto_reset", False): # Treat auto-reset as a full conversation boundary — drop every # session-scoped transient state so the fresh session does not @@ -6984,11 +7087,11 @@ class GatewayRunner: session_info = "" if new_entry: - header = "✨ Session reset! Starting fresh." + header = self._telegram_topic_new_header(source) or "✨ Session reset! Starting fresh." else: # No existing session, just create one new_entry = self.session_store.get_or_create_session(source, force_new=True) - header = "✨ New session started!" + header = self._telegram_topic_new_header(source) or "✨ New session started!" # Set session title if provided with /new _title_arg = event.get_command_args().strip() @@ -9466,6 +9569,164 @@ class GatewayRunner: logger.warning("Manual compress failed: %s", e) return f"Compression failed: {e}" + async def _handle_topic_command(self, event: MessageEvent) -> str: + """Handle /topic for Telegram DM user-managed topic sessions.""" + source = event.source + if source.platform != Platform.TELEGRAM or source.chat_type != "dm": + return "The /topic command is only available in Telegram private chats." + if not self._session_db: + return "Session database not available." + + args = event.get_command_args().strip() + if args: + if not source.thread_id: + return ( + "To restore a session, first create or open a Telegram topic " + "with the + button, then send /topic <session-id> inside that topic." + ) + return await self._restore_telegram_topic_session(event, args) + + try: + self._session_db.enable_telegram_topic_mode( + chat_id=str(source.chat_id), + user_id=str(source.user_id), + ) + except Exception as exc: + logger.exception("Failed to enable Telegram topic mode") + return f"Failed to enable Telegram topic mode: {exc}" + + if source.thread_id: + try: + binding = self._session_db.get_telegram_topic_binding( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + ) + except Exception: + logger.debug("Failed to read Telegram topic binding", exc_info=True) + binding = None + if binding: + session_id = str(binding.get("session_id") or "") + title = None + try: + title = self._session_db.get_session_title(session_id) + except Exception: + title = None + session_label = title or "Untitled session" + return ( + "This topic is linked to:\n" + f"Session: {session_label}\n" + f"ID: {session_id}\n\n" + "Use /new to replace this topic with a fresh session.\n" + "For parallel work, create another topic with the + button." + ) + return ( + "Telegram multi-session topics are enabled.\n\n" + "This topic will be used as an independent Hermes session. " + "Use /new to replace this topic's current session. For parallel " + "work, create another topic with the + button." + ) + + return self._telegram_topic_root_status_message(source) + + def _telegram_topic_root_status_message(self, source: SessionSource) -> str: + lines = [ + "Telegram multi-session topics are enabled.", + "", + "Create new Hermes chats with the + button in this bot interface.", + "", + ] + try: + sessions = self._session_db.list_unlinked_telegram_sessions_for_user( + chat_id=str(source.chat_id), + user_id=str(source.user_id), + limit=10, + ) + except Exception: + logger.debug("Failed to list unlinked Telegram sessions", exc_info=True) + sessions = [] + + if sessions: + lines.append("Previous unlinked sessions:") + for session in sessions: + session_id = str(session.get("id") or "") + title = str(session.get("title") or "Untitled session") + preview = str(session.get("preview") or "").strip() + line = f"- {title} — `{session_id}`" + if preview: + line += f" — {preview}" + lines.append(line) + lines.extend([ + "", + "To restore one:", + "1. Create or open a topic with the + button.", + "2. Send /topic <session-id> inside that topic.", + f"Example: Send /topic {sessions[0].get('id')} inside a topic.", + ]) + else: + lines.extend([ + "No previous unlinked Telegram sessions found.", + "", + "To restore a previous session later:", + "1. Create a new topic with the + button.", + "2. Open that topic.", + "3. Send /topic <session-id>.", + ]) + return "\n".join(lines) + + async def _restore_telegram_topic_session(self, event: MessageEvent, raw_session_id: str) -> str: + """Restore an existing Telegram-owned Hermes session into this topic.""" + source = event.source + session_id = self._session_db.resolve_session_id(raw_session_id.strip()) + if not session_id: + return f"Session not found: {raw_session_id.strip()}" + + session = self._session_db.get_session(session_id) + if not session: + return f"Session not found: {raw_session_id.strip()}" + if str(session.get("source") or "") != "telegram": + return "That session is not a Telegram session and cannot be restored into this topic." + if str(session.get("user_id") or "") != str(source.user_id): + return "That session does not belong to this Telegram user." + + linked = self._session_db.is_telegram_session_linked_to_topic(session_id=session_id) + current_binding = self._session_db.get_telegram_topic_binding( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + ) + if linked: + if not current_binding or current_binding.get("session_id") != session_id: + return "That session is already linked to another Telegram topic." + + session_key = self._session_key_for_source(source) + try: + self._session_db.bind_telegram_topic( + chat_id=str(source.chat_id), + thread_id=str(source.thread_id), + user_id=str(source.user_id), + session_key=session_key, + session_id=session_id, + managed_mode="restored", + ) + except ValueError as exc: + if "already linked" in str(exc): + return "That session is already linked to another Telegram topic." + raise + + title = self._session_db.get_session_title(session_id) or session_id + last_assistant = None + try: + for message in reversed(self._session_db.get_messages(session_id)): + if message.get("role") == "assistant" and message.get("content"): + last_assistant = str(message.get("content")) + break + except Exception: + last_assistant = None + + response = f"Session restored: {title}" + if last_assistant: + response += f"\n\nLast Hermes message:\n{last_assistant}" + return response + async def _handle_title_command(self, event: MessageEvent) -> str: """Handle /title command — set or show the current session's title.""" source = event.source diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c7ddfa0fa0..cc2365c90d 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -65,6 +65,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ # Session CommandDef("new", "Start a new session (fresh session ID + history)", "Session", aliases=("reset",), args_hint="[name]"), + CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session", + gateway_only=True, args_hint="[session-id]"), CommandDef("clear", "Clear screen and start a new session", "Session", cli_only=True), CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session", diff --git a/hermes_state.py b/hermes_state.py index 2cfd13d6d5..7f26659e7d 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2148,6 +2148,265 @@ class SessionDB: ) self._execute_write(_do) + def apply_telegram_topic_migration(self) -> None: + """Create Telegram DM topic-mode tables on explicit /topic opt-in. + + This migration is deliberately not part of automatic SessionDB startup + reconciliation. Operators must be able to upgrade Hermes, keep the old + Telegram bot behavior running, and only mutate topic-mode state when the + user executes /topic to opt into the feature. + """ + def _do(conn): + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS telegram_dm_topic_mode ( + chat_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + activated_at REAL NOT NULL, + updated_at REAL NOT NULL, + has_topics_enabled INTEGER, + allows_users_to_create_topics INTEGER, + capability_checked_at REAL, + intro_message_id TEXT, + pinned_message_id TEXT + ); + + CREATE TABLE IF NOT EXISTS telegram_dm_topic_bindings ( + chat_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + user_id TEXT NOT NULL, + session_key TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES sessions(id), + managed_mode TEXT NOT NULL DEFAULT 'auto', + linked_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY (chat_id, thread_id) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session + ON telegram_dm_topic_bindings(session_id); + + CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user + ON telegram_dm_topic_bindings(user_id, chat_id); + """ + ) + conn.execute( + "INSERT INTO state_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ("telegram_dm_topic_schema_version", "1"), + ) + self._execute_write(_do) + + def enable_telegram_topic_mode( + self, + *, + chat_id: str, + user_id: str, + has_topics_enabled: Optional[bool] = None, + allows_users_to_create_topics: Optional[bool] = None, + ) -> None: + """Enable Telegram DM topic mode for one private chat/user. + + This method intentionally owns the explicit topic migration. Ordinary + SessionDB startup must not create these side tables. + """ + self.apply_telegram_topic_migration() + now = time.time() + + def _to_int(value: Optional[bool]) -> Optional[int]: + if value is None: + return None + return 1 if value else 0 + + def _do(conn): + conn.execute( + """ + INSERT INTO telegram_dm_topic_mode ( + chat_id, user_id, enabled, activated_at, updated_at, + has_topics_enabled, allows_users_to_create_topics, + capability_checked_at + ) VALUES (?, ?, 1, ?, ?, ?, ?, ?) + ON CONFLICT(chat_id) DO UPDATE SET + user_id = excluded.user_id, + enabled = 1, + updated_at = excluded.updated_at, + has_topics_enabled = excluded.has_topics_enabled, + allows_users_to_create_topics = excluded.allows_users_to_create_topics, + capability_checked_at = excluded.capability_checked_at + """, + ( + str(chat_id), + str(user_id), + now, + now, + _to_int(has_topics_enabled), + _to_int(allows_users_to_create_topics), + now, + ), + ) + self._execute_write(_do) + + def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str) -> bool: + """Return whether Telegram DM topic mode is enabled for this chat/user.""" + with self._lock: + try: + row = self._conn.execute( + """ + SELECT enabled FROM telegram_dm_topic_mode + WHERE chat_id = ? AND user_id = ? + """, + (str(chat_id), str(user_id)), + ).fetchone() + except sqlite3.OperationalError: + return False + if row is None: + return False + enabled = row["enabled"] if isinstance(row, sqlite3.Row) else row[0] + return bool(enabled) + + def get_telegram_topic_binding( + self, + *, + chat_id: str, + thread_id: str, + ) -> Optional[Dict[str, Any]]: + """Return the session binding for a Telegram DM topic, if present.""" + with self._lock: + try: + row = self._conn.execute( + """ + SELECT * FROM telegram_dm_topic_bindings + WHERE chat_id = ? AND thread_id = ? + """, + (str(chat_id), str(thread_id)), + ).fetchone() + except sqlite3.OperationalError: + return None + return dict(row) if row else None + + def bind_telegram_topic( + self, + *, + chat_id: str, + thread_id: str, + user_id: str, + session_key: str, + session_id: str, + managed_mode: str = "auto", + ) -> None: + """Bind one Telegram DM topic thread to one Hermes session. + + A Hermes session may only be linked to one Telegram topic in MVP. + Rebinding the same topic to the same session is idempotent; trying to + link the same session to a different topic raises ValueError. + """ + self.apply_telegram_topic_migration() + now = time.time() + chat_id = str(chat_id) + thread_id = str(thread_id) + user_id = str(user_id) + session_key = str(session_key) + session_id = str(session_id) + + def _do(conn): + existing_session = conn.execute( + """ + SELECT chat_id, thread_id FROM telegram_dm_topic_bindings + WHERE session_id = ? + """, + (session_id,), + ).fetchone() + if existing_session is not None: + linked_chat = existing_session["chat_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[0] + linked_thread = existing_session["thread_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[1] + if str(linked_chat) != chat_id or str(linked_thread) != thread_id: + raise ValueError("session is already linked to another Telegram topic") + + conn.execute( + """ + INSERT INTO telegram_dm_topic_bindings ( + chat_id, thread_id, user_id, session_key, session_id, + managed_mode, linked_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(chat_id, thread_id) DO UPDATE SET + user_id = excluded.user_id, + session_key = excluded.session_key, + session_id = excluded.session_id, + managed_mode = excluded.managed_mode, + updated_at = excluded.updated_at + """, + ( + chat_id, + thread_id, + user_id, + session_key, + session_id, + managed_mode, + now, + now, + ), + ) + self._execute_write(_do) + + def is_telegram_session_linked_to_topic(self, *, session_id: str) -> bool: + """Return True if a Hermes session is already bound to any Telegram DM topic.""" + self.apply_telegram_topic_migration() + row = self._conn.execute( + """ + SELECT 1 FROM telegram_dm_topic_bindings + WHERE session_id = ? + LIMIT 1 + """, + (str(session_id),), + ).fetchone() + return row is not None + + def list_unlinked_telegram_sessions_for_user( + self, + *, + chat_id: str, + user_id: str, + limit: int = 10, + ) -> List[Dict[str, Any]]: + """List previous Telegram sessions for this user that are not bound to a topic.""" + self.apply_telegram_topic_migration() + with self._lock: + rows = self._conn.execute( + """ + SELECT s.*, + COALESCE( + (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63) + FROM messages m + WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL + ORDER BY m.timestamp, m.id LIMIT 1), + '' + ) AS _preview_raw, + COALESCE( + (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), + s.started_at + ) AS last_active + FROM sessions s + WHERE s.source = 'telegram' + AND s.user_id = ? + AND NOT EXISTS ( + SELECT 1 FROM telegram_dm_topic_bindings b + WHERE b.session_id = s.id + ) + ORDER BY last_active DESC, s.started_at DESC + LIMIT ? + """, + (str(user_id), int(limit)), + ).fetchall() + + sessions: List[Dict[str, Any]] = [] + for row in rows: + session = dict(row) + raw = str(session.pop("_preview_raw", "") or "").strip() + session["preview"] = raw[:60] + ("..." if len(raw) > 60 else "") if raw else "" + sessions.append(session) + return sessions + # ── Space reclamation ── def vacuum(self) -> None: diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py new file mode 100644 index 0000000000..ad72514ed5 --- /dev/null +++ b/tests/gateway/test_telegram_topic_mode.py @@ -0,0 +1,625 @@ +"""Tests for Telegram private-chat topic-mode routing. + +Topic mode makes the root Telegram DM a system lobby while user-created +Telegram topics act as independent Hermes session lanes. +""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from hermes_state import SessionDB +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source(*, thread_id: str | None = None) -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + user_id="208214988", + chat_id="208214988", + user_name="tester", + chat_type="dm", + thread_id=thread_id, + ) + + +def _make_event(text: str, *, thread_id: str | None = None) -> MessageEvent: + return MessageEvent( + text=text, + source=_make_source(thread_id=thread_id), + message_id="m1", + ) + + +def _make_group_source(*, thread_id: str | None = None) -> SessionSource: + return SessionSource( + platform=Platform.TELEGRAM, + user_id="208214988", + chat_id="-100123", + user_name="tester", + chat_type="group", + thread_id=thread_id, + ) + + +def _make_group_event(text: str, *, thread_id: str | None = None) -> MessageEvent: + return MessageEvent( + text=text, + source=_make_group_source(thread_id=thread_id), + message_id="gm1", + ) + + +def _make_runner(session_db=None): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + adapter = MagicMock() + adapter.send = AsyncMock() + runner.adapters = {Platform.TELEGRAM: adapter} + runner._voice_mode = {} + runner.hooks = SimpleNamespace( + emit=AsyncMock(), + emit_collect=AsyncMock(return_value=[]), + loaded_hooks=False, + ) + + runner.session_store = MagicMock() + runner.session_store._generate_session_key.side_effect = lambda source: build_session_key( + source, + group_sessions_per_user=getattr(runner.config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(runner.config, "thread_sessions_per_user", False), + ) + runner.session_store.get_or_create_session.side_effect = lambda source, force_new=False: SessionEntry( + session_key=build_session_key( + source, + group_sessions_per_user=getattr(runner.config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(runner.config, "thread_sessions_per_user", False), + ), + session_id="sess-topic" if source.thread_id else "sess-root", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + origin=source, + ) + runner.session_store.load_transcript.return_value = [] + runner.session_store.has_any_sessions.return_value = True + runner.session_store.append_to_transcript = MagicMock() + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + runner.session_store.reset_session = MagicMock(return_value=None) + runner._running_agents = {} + runner._running_agents_ts = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._queued_events = {} + runner._busy_ack_ts = {} + runner._session_model_overrides = {} + runner._pending_model_notes = {} + runner._session_db = session_db + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + runner._draining = False + runner._busy_input_mode = "interrupt" + runner._is_user_authorized = lambda _source: True + runner._session_key_for_source = lambda source: build_session_key( + source, + group_sessions_per_user=getattr(runner.config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(runner.config, "thread_sessions_per_user", False), + ) + runner._set_session_env = lambda _context: None + runner._should_send_voice_reply = lambda *_args, **_kwargs: False + runner._send_voice_reply = AsyncMock() + runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None + runner._emit_gateway_run_progress = AsyncMock() + runner._invalidate_session_run_generation = MagicMock() + runner._begin_session_run_generation = MagicMock(return_value=1) + runner._is_session_run_current = MagicMock(return_value=True) + runner._release_running_agent_state = MagicMock() + runner._evict_cached_agent = MagicMock() + runner._clear_session_boundary_security_state = MagicMock() + runner._set_session_reasoning_override = MagicMock() + runner._format_session_info = MagicMock(return_value="") + return runner + + +@pytest.mark.asyncio +async def test_root_telegram_dm_prompt_is_system_lobby_when_topic_mode_enabled(monkeypatch): + import gateway.run as gateway_run + + runner = _make_runner() + runner._telegram_topic_mode_enabled = lambda source: True + runner._run_agent = AsyncMock( + side_effect=AssertionError("root Telegram DM prompt leaked to the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("hello from root")) + + assert "main chat is reserved for system commands" in result + assert "+ button" in result + runner._run_agent.assert_not_called() + runner.session_store.get_or_create_session.assert_not_called() + + +@pytest.mark.asyncio +async def test_root_telegram_dm_new_shows_create_topic_instruction(monkeypatch): + import gateway.run as gateway_run + + runner = _make_runner() + runner._telegram_topic_mode_enabled = lambda source: True + runner._run_agent = AsyncMock( + side_effect=AssertionError("/new in root Telegram DM must not start an agent") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/new")) + + assert "create a new topic" in result + assert "+ button" in result + assert "Use /new inside a topic" in result + runner._run_agent.assert_not_called() + runner.session_store.reset_session.assert_not_called() + runner.session_store.get_or_create_session.assert_not_called() + + +@pytest.mark.asyncio +async def test_telegram_topic_prompt_still_runs_agent_when_topic_mode_enabled(monkeypatch): + import gateway.run as gateway_run + + runner = _make_runner() + runner._telegram_topic_mode_enabled = lambda source: True + runner._handle_message_with_agent = AsyncMock(return_value="agent response") + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("hello in topic", thread_id="17585")) + + assert result == "agent response" + runner._handle_message_with_agent.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_managed_topic_binding_reuses_restored_session_over_static_lane_session( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="restored-session", + source="telegram", + user_id="208214988", + ) + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key=build_session_key(_make_source(thread_id="17585")), + session_id="restored-session", + managed_mode="restored", + ) + runner = _make_runner(session_db=session_db) + captured = {} + + async def fake_run_agent(*args, **kwargs): + captured["session_id"] = kwargs.get("session_id") + return { + "success": True, + "final_response": "restored response", + "session_id": kwargs.get("session_id"), + "messages": [], + } + + runner._run_agent = AsyncMock(side_effect=fake_run_agent) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("continue restored", thread_id="17585")) + + assert result == "restored response" + assert captured["session_id"] == "restored-session" + + +@pytest.mark.asyncio +async def test_telegram_group_prompt_is_not_topic_lobby_even_when_dm_topic_mode_enabled( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + runner = _make_runner(session_db=session_db) + runner._handle_message_with_agent = AsyncMock(return_value="group agent response") + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_group_event("hello group", thread_id="555")) + + assert result == "group agent response" + runner._handle_message_with_agent.assert_awaited_once() + assert session_db.get_telegram_topic_binding(chat_id="-100123", thread_id="555") is None + + +@pytest.mark.asyncio +async def test_topic_command_is_private_dm_only_and_does_not_enable_group_topic_mode( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("group /topic must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_group_event("/topic", thread_id="555")) + + assert "only available in Telegram private chats" in result + assert session_db.is_telegram_topic_mode_enabled(chat_id="-100123", user_id="208214988") is False + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_group_new_keeps_existing_reset_semantics_when_dm_topic_mode_enabled( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + runner = _make_runner(session_db=session_db) + group_source = _make_group_source(thread_id="555") + group_key = build_session_key(group_source) + new_entry = SessionEntry( + session_key=group_key, + session_id="new-group-session", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="group", + origin=group_source, + ) + runner.session_store.reset_session.return_value = new_entry + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_group_event("/new", thread_id="555")) + + assert "Started a new Hermes session in this topic" not in result + assert "parallel work" not in result + runner.session_store.reset_session.assert_called_once_with(group_key) + + +@pytest.mark.asyncio +async def test_new_inside_telegram_topic_resets_current_topic_with_parallel_tip(monkeypatch): + import gateway.run as gateway_run + + runner = _make_runner() + runner._telegram_topic_mode_enabled = lambda source: True + topic_source = _make_source(thread_id="17585") + topic_key = build_session_key(topic_source) + old_entry = SessionEntry( + session_key=topic_key, + session_id="old-topic-session", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + origin=topic_source, + ) + new_entry = SessionEntry( + session_key=topic_key, + session_id="new-topic-session", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + origin=topic_source, + ) + runner.session_store._entries = {topic_key: old_entry} + runner.session_store.reset_session.return_value = new_entry + runner._agent_cache_lock = None + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/new", thread_id="17585")) + + assert "Started a new Hermes session in this topic" in result + assert "for parallel work" in result + assert "+ button" in result + runner.session_store.reset_session.assert_called_once_with(topic_key) + + +@pytest.mark.asyncio +async def test_topic_root_command_explicitly_migrates_and_enables_topic_mode(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("/topic activation must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic")) + + assert "Telegram multi-session topics are enabled" in result + assert "+ button" in result + assert session_db.get_meta("telegram_dm_topic_schema_version") == "1" + assert session_db.is_telegram_topic_mode_enabled(chat_id="208214988", user_id="208214988") + assert runner._telegram_topic_mode_enabled(_make_source()) is True + runner._run_agent.assert_not_called() + + lobby_result = await runner._handle_message(_make_event("hello after activation")) + + assert "main chat is reserved for system commands" in lobby_result + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_root_command_lists_unlinked_sessions_for_restore(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="old-unlinked", + source="telegram", + user_id="208214988", + ) + session_db.set_session_title("old-unlinked", "Old research") + session_db.append_message("old-unlinked", "user", "first prompt") + session_db.append_message("old-unlinked", "assistant", "old answer") + session_db.create_session( + session_id="already-linked", + source="telegram", + user_id="208214988", + ) + session_db.set_session_title("already-linked", "Already linked") + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="11111", + user_id="208214988", + session_key="agent:main:telegram:dm:208214988:11111", + session_id="already-linked", + ) + session_db.create_session( + session_id="other-user", + source="telegram", + user_id="someone-else", + ) + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("root /topic status must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic")) + + assert "Telegram multi-session topics are enabled" in result + assert "Previous unlinked sessions" in result + assert "Old research" in result + assert "old-unlinked" in result + assert "Send /topic old-unlinked inside a topic" in result + assert "Already linked" not in result + assert "other-user" not in result + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_root_command_handles_no_unlinked_sessions(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("root /topic status must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic")) + + assert "Telegram multi-session topics are enabled" in result + assert "No previous unlinked Telegram sessions found" in result + assert "+ button" in result + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_command_inside_bound_topic_shows_current_session(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.create_session( + session_id="sess-topic", + source="telegram", + user_id="208214988", + ) + session_db.set_session_title("sess-topic", "Research notes") + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="telegram:dm:208214988:thread:17585", + session_id="sess-topic", + ) + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("/topic status must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic", thread_id="17585")) + + assert "This topic is linked to" in result + assert "Research notes" in result + assert "sess-topic" in result + assert "Use /new to replace" in result + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_restore_inside_topic_binds_old_session_and_returns_last_assistant_message( + tmp_path, monkeypatch +): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="old-session", + source="telegram", + user_id="208214988", + ) + session_db.set_session_title("old-session", "Research notes") + session_db.append_message("old-session", "user", "summarize this") + session_db.append_message("old-session", "assistant", "Here is the summary.") + runner = _make_runner(session_db=session_db) + runner._run_agent = AsyncMock( + side_effect=AssertionError("/topic restore must not enter the agent loop") + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic old-session", thread_id="17585")) + + assert "Session restored: Research notes" in result + assert "Last Hermes message:" in result + assert "Here is the summary." in result + binding = session_db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") + assert binding is not None + assert binding["session_id"] == "old-session" + assert binding["user_id"] == "208214988" + assert binding["session_key"] == build_session_key(_make_source(thread_id="17585")) + runner._run_agent.assert_not_called() + + +@pytest.mark.asyncio +async def test_topic_restore_refuses_session_owned_by_another_telegram_user(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="other-session", + source="telegram", + user_id="someone-else", + ) + runner = _make_runner(session_db=session_db) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic other-session", thread_id="17585")) + + assert "does not belong to this Telegram user" in result + assert session_db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") is None + + +@pytest.mark.asyncio +async def test_topic_restore_refuses_already_linked_session(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="linked-session", + source="telegram", + user_id="208214988", + ) + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="11111", + user_id="208214988", + session_key="agent:main:telegram:dm:208214988:11111", + session_id="linked-session", + ) + runner = _make_runner(session_db=session_db) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + result = await runner._handle_message(_make_event("/topic linked-session", thread_id="17585")) + + assert "already linked to another Telegram topic" in result + assert session_db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") is None + + +@pytest.mark.asyncio +async def test_first_message_inside_topic_records_topic_binding(tmp_path, monkeypatch): + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + session_db.create_session( + session_id="sess-topic", + source="telegram", + user_id="208214988", + ) + runner = _make_runner(session_db=session_db) + runner._handle_message_with_agent = AsyncMock(return_value="agent response") + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + source = _make_source(thread_id="17585") + entry = runner.session_store.get_or_create_session(source) + runner._record_telegram_topic_binding(source, entry) + + binding = session_db.get_telegram_topic_binding( + chat_id="208214988", + thread_id="17585", + ) + assert binding is not None + assert binding["user_id"] == "208214988" + assert binding["session_id"] == "sess-topic" + assert binding["session_key"] == build_session_key(_make_source(thread_id="17585")) diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 620611ad42..ad4c7d5c63 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -109,6 +109,12 @@ class TestResolveCommand: assert resolve_command("reload_mcp").name == "reload-mcp" assert resolve_command("tasks").name == "agents" + def test_topic_is_gateway_command(self): + topic = resolve_command("topic") + assert topic is not None + assert topic.name == "topic" + assert "topic" in GATEWAY_KNOWN_COMMANDS + def test_leading_slash_stripped(self): assert resolve_command("/help").name == "help" assert resolve_command("/bg").name == "background" diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 806735f5df..24020286bd 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -35,6 +35,7 @@ class TestSessionLifecycle: assert session["model"] == "test-model" assert session["ended_at"] is None + def test_get_nonexistent_session(self, db): assert db.get_session("nonexistent") is None @@ -1421,6 +1422,242 @@ class TestSchemaInit: columns = {row[1] for row in cursor.fetchall()} assert "title" in columns + def test_topic_mode_schema_is_not_auto_migrated_on_open(self, tmp_path): + """Opening an old DB should not add topic-mode columns until /topic opts in. + + The gateway must remain rollback-safe: simply upgrading Hermes and starting + the old bot should not eagerly mutate the state DB for this feature. + """ + old_db = tmp_path / "old.db" + import sqlite3 + + conn = sqlite3.connect(old_db) + conn.executescript( + """ + CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version VALUES (11); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + api_call_count INTEGER DEFAULT 0, + FOREIGN KEY (parent_session_id) REFERENCES sessions(id) + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_content TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT, + codex_message_items TEXT + ); + """ + ) + conn.close() + + db = SessionDB(db_path=old_db) + cursor = db._conn.execute("PRAGMA table_info(sessions)") + columns = {row[1] for row in cursor.fetchall()} + assert {"chat_id", "chat_type", "thread_id", "session_key"}.isdisjoint(columns) + db.close() + + def test_apply_telegram_topic_migration_creates_topic_tables_explicitly(self, tmp_path): + """The /topic opt-in path owns the DB migration for Telegram topic mode.""" + old_db = tmp_path / "old.db" + import sqlite3 + + conn = sqlite3.connect(old_db) + conn.executescript( + """ + CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version VALUES (11); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + api_call_count INTEGER DEFAULT 0, + FOREIGN KEY (parent_session_id) REFERENCES sessions(id) + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_content TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT, + codex_message_items TEXT + ); + """ + ) + conn.close() + + db = SessionDB(db_path=old_db) + db.apply_telegram_topic_migration() + + tables = { + row[0] + for row in db._conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + assert "telegram_dm_topic_mode" in tables + assert "telegram_dm_topic_bindings" in tables + assert db.get_meta("telegram_dm_topic_schema_version") == "1" + db.close() + + def test_telegram_topic_binding_roundtrip_requires_explicit_schema(self, tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + session_id="topic-session", + source="telegram", + user_id="208214988", + ) + + assert db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") is None + + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="telegram:dm:208214988:thread:17585", + session_id="topic-session", + ) + + binding = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") + assert binding is not None + assert binding["chat_id"] == "208214988" + assert binding["thread_id"] == "17585" + assert binding["user_id"] == "208214988" + assert binding["session_key"] == "telegram:dm:208214988:thread:17585" + assert binding["session_id"] == "topic-session" + assert db.get_meta("telegram_dm_topic_schema_version") == "1" + db.close() + + def test_telegram_topic_binding_refuses_to_relink_session_to_another_topic(self, tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + session_id="topic-session", + source="telegram", + user_id="208214988", + ) + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="key-17585", + session_id="topic-session", + ) + + with pytest.raises(ValueError, match="already linked"): + db.bind_telegram_topic( + chat_id="208214988", + thread_id="99999", + user_id="208214988", + session_key="key-99999", + session_id="topic-session", + ) + db.close() + + def test_list_unlinked_telegram_sessions_for_user_excludes_bound_and_other_users(self, tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + session_id="old-unlinked", + source="telegram", + user_id="208214988", + ) + db.set_session_title("old-unlinked", "Old research") + db.append_message("old-unlinked", "user", "first prompt") + db.create_session( + session_id="already-linked", + source="telegram", + user_id="208214988", + ) + db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key="key-17585", + session_id="already-linked", + ) + db.create_session( + session_id="other-user", + source="telegram", + user_id="someone-else", + ) + + sessions = db.list_unlinked_telegram_sessions_for_user( + chat_id="208214988", + user_id="208214988", + ) + + assert [s["id"] for s in sessions] == ["old-unlinked"] + assert sessions[0]["title"] == "Old research" + assert sessions[0]["preview"] == "first prompt" + db.close() + def test_migration_from_v2(self, tmp_path): """Simulate a v2 database and verify migration adds title column.""" import sqlite3 diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 0677e8bdc1..fd3af4540b 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -125,7 +125,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -503,6 +502,31 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1677,7 +1701,6 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1688,7 +1711,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1699,7 +1721,6 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -1729,7 +1750,6 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -2047,7 +2067,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2450,7 +2469,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3186,7 +3204,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3318,7 +3335,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4227,7 +4243,6 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", - "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -5663,7 +5678,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5773,7 +5787,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6598,7 +6611,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6725,7 +6737,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6835,7 +6846,6 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -7251,7 +7261,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }