feat: add Telegram DM topic-mode sessions

This commit is contained in:
EmelyanenkoK 2026-05-02 18:04:57 +03:00 committed by Teknium
parent 0ce1b9fe20
commit d6615d8ec7
8 changed files with 1890 additions and 18 deletions

View file

@ -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 <session_id>` 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 <session_id> 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 <id>
```
### 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 <session_id>` 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 <session_id>` root/main DM
Reject with instructions:
```text
Create a new topic with the + button, open it, then send /topic <session_id> there to restore this session.
```
### `/topic <session_id>` 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 <session_id>` in non-main topics.
**Tests first:**
1. root `/topic <id>` rejects with instructions;
2. topic `/topic <id>` 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 <id>` 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 <session_id>` 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.

View file

@ -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>
_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

View file

@ -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",

View file

@ -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:

View file

@ -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"))

View file

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

View file

@ -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

View file

@ -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"
}