mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
feat: add Telegram DM topic-mode sessions
This commit is contained in:
parent
0ce1b9fe20
commit
d6615d8ec7
8 changed files with 1890 additions and 18 deletions
|
|
@ -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.
|
||||
265
gateway/run.py
265
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>
|
||||
_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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
259
hermes_state.py
259
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:
|
||||
|
|
|
|||
625
tests/gateway/test_telegram_topic_mode.py
Normal file
625
tests/gateway/test_telegram_topic_mode.py
Normal 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"))
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
41
ui-tui/package-lock.json
generated
41
ui-tui/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue