mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-31 06:51:29 +00:00
Telegram DM topic bindings persist (chat_id, thread_id) -> session_id in SQLite so reopening a topic resumes the right Hermes session. When compression rotated session_entry.session_id mid-turn, the binding row stayed pointed at the pre-compression parent. On the next inbound message in that topic the gateway reloaded the oversized parent transcript, retriggering preflight compression — sometimes in a loop. Two-pronged fix: 1. `_sync_telegram_topic_binding(source, entry, *, reason)` helper called immediately after each of the three session_id rotation sites in _handle_message_with_agent (hygiene compression, agent-result compression rotation, /compress command). Keeps future bindings fresh. 2. Read-path self-heal: when resolving an existing topic binding, walk SessionDB.get_compression_tip() forward and switch_session to the descendant instead of the stored parent. Rewrites the binding row to the tip so subsequent messages skip the walk. Heals existing stale state on the next user message without requiring a gateway restart. Skipped from competing PRs as not load-bearing for the bug: - advance_session_after_compression SessionStore primitive (#26204/ #28870/#33416) — preserves end_reason='compression' analytics nicety but doesn't affect routing correctness. - Cached-agent eviction on session_id mismatch — _compress_context() already mutates tmp_agent.session_id on the cached object so the in-memory agent self-corrects. - Startup repair pass (#33416) — redundant once the read path heals on the next message; one-line CLI follow-up can address bindings for topics users never reopen. Closes #20470, #29712, #33414. Acknowledges work in #23195 (@litvinovvo), #26204 (@bizyumov), #28870 (@donrhmexe), #29713 (@hehehe0803), #29945 (@eugeneb1ack), #33416 (@bizyumov).
1436 lines
53 KiB
Python
1436 lines
53 KiB
Python
"""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()
|
|
adapter.send_image_file = AsyncMock()
|
|
adapter._bot = None
|
|
adapter._create_dm_topic = AsyncMock(return_value=None)
|
|
adapter.rename_dm_topic = 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)
|
|
|
|
# Default switch_session impl: returns a SessionEntry carrying the target
|
|
# session_id. Mirrors SessionStore.switch_session semantics for tests that
|
|
# exercise Telegram topic binding rebinds without a real store.
|
|
def _switch_session(session_key, target_session_id):
|
|
return SessionEntry(
|
|
session_key=session_key,
|
|
session_id=target_session_id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
platform=Platform.TELEGRAM,
|
|
chat_type="dm",
|
|
origin=None,
|
|
)
|
|
runner.session_store.switch_session = MagicMock(side_effect=_switch_session)
|
|
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)
|
|
# Bypass the destructive-slash confirm gate — these tests focus on
|
|
# /new topic-mode mechanics, not the confirm prompt itself.
|
|
runner._read_user_config = lambda: {
|
|
"approvals": {"destructive_slash_confirm": False}
|
|
}
|
|
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 "All Messages" 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 "All Messages" in result
|
|
assert "Use /new inside" 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 "parallel work" in result
|
|
assert "All Messages" in result
|
|
runner.session_store.reset_session.assert_called_once_with(topic_key)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_inside_telegram_topic_rewrites_binding_to_new_session(tmp_path, monkeypatch):
|
|
"""Regression: /new inside a topic must rewrite the binding table.
|
|
|
|
Previously /new reset the SessionStore entry but the
|
|
telegram_dm_topic_bindings row still pointed at the old session_id;
|
|
the next inbound message would look up the stale binding and switch
|
|
back to the old session, making /new a no-op.
|
|
"""
|
|
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-topic-session",
|
|
source="telegram",
|
|
user_id="208214988",
|
|
)
|
|
topic_source = _make_source(thread_id="17585")
|
|
topic_key = build_session_key(topic_source)
|
|
session_db.bind_telegram_topic(
|
|
chat_id="208214988",
|
|
thread_id="17585",
|
|
user_id="208214988",
|
|
session_key=topic_key,
|
|
session_id="old-topic-session",
|
|
)
|
|
|
|
runner = _make_runner(session_db=session_db)
|
|
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,
|
|
)
|
|
# Mirror SessionStore.reset_session: in production it calls
|
|
# SessionDB.create_session() for the new id before returning, so the
|
|
# bindings FK can reference it.
|
|
session_db.create_session(
|
|
session_id="new-topic-session",
|
|
source="telegram",
|
|
user_id="208214988",
|
|
)
|
|
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": "***"}
|
|
)
|
|
|
|
await runner._handle_message(_make_event("/new", thread_id="17585"))
|
|
|
|
binding = session_db.get_telegram_topic_binding(
|
|
chat_id="208214988", thread_id="17585",
|
|
)
|
|
assert binding is not None
|
|
assert binding["session_id"] == "new-topic-session"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_topic_binding_follows_compression_tip_on_read(tmp_path, monkeypatch):
|
|
"""Stale topic bindings auto-heal to the compression child on next inbound.
|
|
|
|
Regression for #20470 / #29712 / #33414. After compression rotates the
|
|
session_id, the binding row still pointed at the parent. On the next
|
|
inbound message in that topic, the gateway used to reload the oversized
|
|
parent transcript and re-run preflight compression — sometimes in a loop.
|
|
The read path now walks ``SessionDB.get_compression_tip()`` and rewrites
|
|
the binding to the descendant.
|
|
"""
|
|
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")
|
|
# Build a parent -> compression child chain. end_session sets ended_at;
|
|
# create_session sets started_at to "now", so the child's started_at is
|
|
# always >= parent's ended_at on a real clock.
|
|
session_db.create_session(
|
|
session_id="parent-session", source="telegram", user_id="208214988",
|
|
)
|
|
session_db.end_session("parent-session", end_reason="compression")
|
|
session_db.create_session(
|
|
session_id="child-session",
|
|
source="telegram",
|
|
user_id="208214988",
|
|
parent_session_id="parent-session",
|
|
)
|
|
topic_source = _make_source(thread_id="17585")
|
|
topic_key = build_session_key(topic_source)
|
|
# Pre-bug binding: topic still pointed at the pre-compression parent.
|
|
session_db.bind_telegram_topic(
|
|
chat_id="208214988",
|
|
thread_id="17585",
|
|
user_id="208214988",
|
|
session_key=topic_key,
|
|
session_id="parent-session",
|
|
)
|
|
|
|
runner = _make_runner(session_db=session_db)
|
|
# switch_session() returns a SessionEntry pointing at whatever id was
|
|
# requested; capture the requested id for assertion.
|
|
switched_to: dict = {}
|
|
|
|
def fake_switch(_key, new_session_id):
|
|
switched_to["id"] = new_session_id
|
|
return SessionEntry(
|
|
session_key=topic_key,
|
|
session_id=new_session_id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
platform=Platform.TELEGRAM,
|
|
chat_type="dm",
|
|
origin=topic_source,
|
|
)
|
|
|
|
runner.session_store.switch_session = MagicMock(side_effect=fake_switch)
|
|
runner._run_agent = AsyncMock(
|
|
return_value={
|
|
"success": True,
|
|
"final_response": "ok",
|
|
"session_id": "child-session",
|
|
"messages": [],
|
|
}
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
|
)
|
|
|
|
await runner._handle_message(_make_event("follow up after compression", thread_id="17585"))
|
|
|
|
# The route was advanced to the compression tip, not the stale parent.
|
|
assert switched_to.get("id") == "child-session"
|
|
# The binding row was rewritten to point at the descendant so future
|
|
# inbound messages skip the tip walk and resolve directly.
|
|
refreshed = session_db.get_telegram_topic_binding(
|
|
chat_id="208214988", thread_id="17585",
|
|
)
|
|
assert refreshed is not None
|
|
assert refreshed["session_id"] == "child-session"
|
|
|
|
|
|
@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 "All Messages" in result
|
|
assert session_db.get_meta("telegram_dm_topic_schema_version") == "2"
|
|
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 "All Messages" 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"))
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_topic_root_command_creates_and_pins_system_topic(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)
|
|
adapter = runner.adapters[Platform.TELEGRAM]
|
|
adapter._create_dm_topic.return_value = 4242
|
|
adapter.send.return_value = SimpleNamespace(success=True, message_id="777")
|
|
bot = AsyncMock()
|
|
bot.get_me.return_value = {
|
|
"has_topics_enabled": True,
|
|
"allows_users_to_create_topics": True,
|
|
}
|
|
adapter._bot = bot
|
|
|
|
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
|
|
adapter._create_dm_topic.assert_awaited_once_with(208214988, "System")
|
|
adapter.send.assert_awaited_once_with(
|
|
"208214988",
|
|
"System topic for Hermes commands and status.",
|
|
metadata={"thread_id": "4242"},
|
|
)
|
|
bot.pin_chat_message.assert_awaited_once_with(
|
|
chat_id=208214988,
|
|
message_id=777,
|
|
disable_notification=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_generated_title_renames_bound_telegram_topic(tmp_path):
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.apply_telegram_topic_migration()
|
|
db.create_session("sess-topic", source="telegram", user_id="208214988")
|
|
db.bind_telegram_topic(
|
|
chat_id="208214988",
|
|
thread_id="42",
|
|
user_id="208214988",
|
|
session_key="agent:main:telegram:dm:208214988:42",
|
|
session_id="sess-topic",
|
|
)
|
|
runner = _make_runner(session_db=db)
|
|
runner._telegram_topic_mode_enabled = lambda source: True
|
|
|
|
await runner._rename_telegram_topic_for_session_title(
|
|
_make_source(thread_id="42"),
|
|
"sess-topic",
|
|
" Build Telegram Topic UX ",
|
|
)
|
|
|
|
runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_awaited_once_with(
|
|
chat_id="208214988",
|
|
thread_id="42",
|
|
name="Build Telegram Topic UX",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_generated_title_does_not_rename_topic_bound_to_other_session(tmp_path):
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.apply_telegram_topic_migration()
|
|
db.create_session("sess-other", source="telegram", user_id="208214988")
|
|
db.bind_telegram_topic(
|
|
chat_id="208214988",
|
|
thread_id="42",
|
|
user_id="208214988",
|
|
session_key="agent:main:telegram:dm:208214988:42",
|
|
session_id="sess-other",
|
|
)
|
|
runner = _make_runner(session_db=db)
|
|
runner._telegram_topic_mode_enabled = lambda source: True
|
|
|
|
await runner._rename_telegram_topic_for_session_title(
|
|
_make_source(thread_id="42"),
|
|
"sess-topic",
|
|
"Wrong Session Title",
|
|
)
|
|
|
|
runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_operator_declared_topic_is_not_auto_renamed(tmp_path):
|
|
"""Topics registered in extra.dm_topics keep their operator-chosen name."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
db.create_session(session_id="sess-topic", source="telegram", user_id="208214988")
|
|
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="sess-topic",
|
|
)
|
|
runner = _make_runner(session_db=db)
|
|
runner._telegram_topic_mode_enabled = lambda source: True
|
|
|
|
# Give the adapter a concrete class with _get_dm_topic_info so the
|
|
# class-based lookup in _rename_telegram_topic_for_session_title
|
|
# actually finds it (a MagicMock auto-attr would be skipped).
|
|
class _FakeAdapter:
|
|
def _get_dm_topic_info(self, chat_id, thread_id):
|
|
return {"name": "Research", "skill": "arxiv"}
|
|
|
|
async def rename_dm_topic(self, **kwargs):
|
|
return None
|
|
|
|
fake = _FakeAdapter()
|
|
fake.rename_dm_topic = AsyncMock()
|
|
runner.adapters[Platform.TELEGRAM] = fake
|
|
|
|
await runner._rename_telegram_topic_for_session_title(
|
|
_make_source(thread_id="17585"),
|
|
"sess-topic",
|
|
"Auto-generated title",
|
|
)
|
|
|
|
fake.rename_dm_topic.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disable_topic_auto_rename_extra_skips_rename(tmp_path):
|
|
"""extra.disable_topic_auto_rename=True must short-circuit auto-rename."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.apply_telegram_topic_migration()
|
|
db.create_session("sess-topic", source="telegram", user_id="208214988")
|
|
db.bind_telegram_topic(
|
|
chat_id="208214988",
|
|
thread_id="42",
|
|
user_id="208214988",
|
|
session_key="agent:main:telegram:dm:208214988:42",
|
|
session_id="sess-topic",
|
|
)
|
|
runner = _make_runner(session_db=db)
|
|
runner._telegram_topic_mode_enabled = lambda source: True
|
|
# Flip the operator switch.
|
|
runner.config.platforms[Platform.TELEGRAM].extra["disable_topic_auto_rename"] = True
|
|
|
|
await runner._rename_telegram_topic_for_session_title(
|
|
_make_source(thread_id="42"),
|
|
"sess-topic",
|
|
"Auto-generated title",
|
|
)
|
|
|
|
runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schedule_topic_rename_respects_disable_flag(tmp_path):
|
|
"""The scheduling entry-point must also honour disable_topic_auto_rename."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
runner = _make_runner(session_db=db)
|
|
runner._telegram_topic_mode_enabled = lambda source: True
|
|
runner.config.platforms[Platform.TELEGRAM].extra["disable_topic_auto_rename"] = "yes"
|
|
|
|
# If the flag is honoured we never schedule the coroutine, so
|
|
# _rename_telegram_topic_for_session_title is never invoked.
|
|
called = False
|
|
|
|
async def _spy(*args, **kwargs):
|
|
nonlocal called
|
|
called = True
|
|
|
|
runner._rename_telegram_topic_for_session_title = _spy
|
|
|
|
runner._schedule_telegram_topic_title_rename(
|
|
_make_source(thread_id="42"),
|
|
"sess-topic",
|
|
"Auto-generated title",
|
|
)
|
|
|
|
# Give any (incorrectly scheduled) coroutine a chance to run.
|
|
import asyncio
|
|
await asyncio.sleep(0)
|
|
assert called is False
|
|
|
|
|
|
def test_telegram_topic_auto_rename_disabled_string_truthy(tmp_path):
|
|
"""Common truthy string forms ('1', 'true', 'on', 'yes') must disable rename."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
runner = _make_runner(session_db=db)
|
|
source = _make_source(thread_id="42")
|
|
|
|
cfg_extra = runner.config.platforms[Platform.TELEGRAM].extra
|
|
for value in ("1", "true", "TRUE", "yes", "on"):
|
|
cfg_extra["disable_topic_auto_rename"] = value
|
|
assert runner._telegram_topic_auto_rename_disabled(source) is True, value
|
|
|
|
for value in ("0", "false", "no", "off", "", None):
|
|
cfg_extra["disable_topic_auto_rename"] = value
|
|
assert runner._telegram_topic_auto_rename_disabled(source) is False, value
|
|
|
|
# Explicit bools still work.
|
|
cfg_extra["disable_topic_auto_rename"] = True
|
|
assert runner._telegram_topic_auto_rename_disabled(source) is True
|
|
cfg_extra["disable_topic_auto_rename"] = False
|
|
assert runner._telegram_topic_auto_rename_disabled(source) is False
|
|
|
|
|
|
def test_general_topic_is_treated_as_root_lobby(tmp_path):
|
|
"""Messages in the Telegram General topic (thread_id=1) route to the lobby, not a lane."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
runner = _make_runner(session_db=db)
|
|
|
|
general_source = _make_source(thread_id="1")
|
|
assert runner._is_telegram_topic_root_lobby(general_source) is True
|
|
assert runner._is_telegram_topic_lane(general_source) is False
|
|
|
|
no_thread_source = _make_source(thread_id=None)
|
|
assert runner._is_telegram_topic_root_lobby(no_thread_source) is True
|
|
assert runner._is_telegram_topic_lane(no_thread_source) is False
|
|
|
|
real_topic = _make_source(thread_id="17585")
|
|
assert runner._is_telegram_topic_root_lobby(real_topic) is False
|
|
assert runner._is_telegram_topic_lane(real_topic) is True
|
|
|
|
|
|
def test_lobby_reminder_is_debounced_per_chat(tmp_path):
|
|
"""Consecutive root-DM prompts should only surface one lobby reminder per cooldown."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
runner = _make_runner(session_db=db)
|
|
|
|
source = _make_source(thread_id=None)
|
|
assert runner._should_send_telegram_lobby_reminder(source) is True
|
|
# Next call inside the cooldown window must return False.
|
|
assert runner._should_send_telegram_lobby_reminder(source) is False
|
|
assert runner._should_send_telegram_lobby_reminder(source) is False
|
|
|
|
# A different chat gets its own window.
|
|
other = _make_source(thread_id=None)
|
|
# Swap chat_id so the debounce key is different.
|
|
from dataclasses import replace
|
|
other = replace(other, chat_id="999999999")
|
|
assert runner._should_send_telegram_lobby_reminder(other) is True
|
|
|
|
|
|
def test_binding_survives_session_deletion_via_cascade(tmp_path):
|
|
"""Deleting a session with a topic binding must not raise FK errors."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
db.create_session(session_id="sess-to-delete", source="telegram", user_id="208214988")
|
|
db.bind_telegram_topic(
|
|
chat_id="208214988",
|
|
thread_id="17585",
|
|
user_id="208214988",
|
|
session_key="agent:main:telegram:dm:208214988:17585",
|
|
session_id="sess-to-delete",
|
|
)
|
|
|
|
# Before: binding exists.
|
|
binding = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585")
|
|
assert binding is not None
|
|
|
|
# Delete the session. Without ON DELETE CASCADE this would raise
|
|
# sqlite3.IntegrityError: FOREIGN KEY constraint failed.
|
|
db._conn.execute("DELETE FROM sessions WHERE id = ?", ("sess-to-delete",))
|
|
db._conn.commit()
|
|
|
|
# After: binding row automatically cleared.
|
|
binding_after = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585")
|
|
assert binding_after is None
|
|
|
|
|
|
def test_migration_rebuilds_v1_binding_table_with_cascade_fk(tmp_path):
|
|
"""v1 → v2 migration rebuilds the bindings table when FK lacks ON DELETE CASCADE."""
|
|
db_path = tmp_path / "state.db"
|
|
db = SessionDB(db_path=db_path)
|
|
|
|
# Simulate a v1-shaped DB: migration ran without ON DELETE CASCADE.
|
|
db.apply_telegram_topic_migration() # Creates v2 (our new shape)
|
|
# Drop the v2 bindings table and recreate it in the old v1 shape.
|
|
with db._lock:
|
|
db._conn.execute("DROP TABLE telegram_dm_topic_bindings")
|
|
db._conn.execute(
|
|
"""
|
|
CREATE TABLE 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)
|
|
)
|
|
"""
|
|
)
|
|
# Also rewind the version marker so migration treats this as v1.
|
|
db._conn.execute(
|
|
"UPDATE state_meta SET value = '1' WHERE key = 'telegram_dm_topic_schema_version'"
|
|
)
|
|
db._conn.commit()
|
|
|
|
# Sanity check: FK has no CASCADE action yet.
|
|
fk_rows = db._conn.execute(
|
|
"PRAGMA foreign_key_list('telegram_dm_topic_bindings')"
|
|
).fetchall()
|
|
assert any(row[2] == "sessions" and (row[6] or "") != "CASCADE" for row in fk_rows)
|
|
|
|
# Re-run migration — should upgrade to v2 shape.
|
|
db.apply_telegram_topic_migration()
|
|
|
|
fk_rows_after = db._conn.execute(
|
|
"PRAGMA foreign_key_list('telegram_dm_topic_bindings')"
|
|
).fetchall()
|
|
assert any(row[2] == "sessions" and row[6] == "CASCADE" for row in fk_rows_after)
|
|
|
|
version = db._conn.execute(
|
|
"SELECT value FROM state_meta WHERE key = 'telegram_dm_topic_schema_version'"
|
|
).fetchone()
|
|
assert version is not None and version[0] == "2"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_topic_help_subcommand_returns_usage(tmp_path):
|
|
"""/topic help surfaces usage without activating anything."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
runner = _make_runner(session_db=db)
|
|
|
|
result = await runner._handle_topic_command(_make_event("/topic help"))
|
|
|
|
assert "/topic help" in result
|
|
assert "/topic off" in result
|
|
assert "/topic <id>" in result
|
|
# No side effects — topic mode tables should not even exist yet.
|
|
tables = {
|
|
row[0]
|
|
for row in db._conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'telegram_dm%'"
|
|
).fetchall()
|
|
}
|
|
assert tables == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_topic_off_disables_mode_and_clears_bindings(tmp_path, monkeypatch):
|
|
"""/topic off flips the row off AND deletes bindings for this chat."""
|
|
import gateway.run as gateway_run
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
db.create_session(session_id="topic-sess", source="telegram", user_id="208214988")
|
|
db.bind_telegram_topic(
|
|
chat_id="208214988",
|
|
thread_id="17585",
|
|
user_id="208214988",
|
|
session_key="k",
|
|
session_id="topic-sess",
|
|
)
|
|
runner = _make_runner(session_db=db)
|
|
|
|
monkeypatch.setattr(
|
|
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
|
)
|
|
|
|
result = await runner._handle_topic_command(_make_event("/topic off"))
|
|
|
|
assert "OFF" in result or "off" in result
|
|
assert db.is_telegram_topic_mode_enabled(
|
|
chat_id="208214988", user_id="208214988"
|
|
) is False
|
|
# Bindings cleared.
|
|
assert db.get_telegram_topic_binding(
|
|
chat_id="208214988", thread_id="17585"
|
|
) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_topic_off_is_idempotent_when_never_enabled(tmp_path):
|
|
"""/topic off against a chat that never ran /topic is a no-op message."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
runner = _make_runner(session_db=db)
|
|
|
|
result = await runner._handle_topic_command(_make_event("/topic off"))
|
|
|
|
assert "not currently enabled" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_topic_refuses_unauthorized_user(tmp_path, monkeypatch):
|
|
"""Unauthorized DMs cannot flip multi-session mode on."""
|
|
import gateway.run as gateway_run
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
runner = _make_runner(session_db=db)
|
|
runner._is_user_authorized = lambda _source: False # Deny
|
|
|
|
monkeypatch.setattr(
|
|
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"}
|
|
)
|
|
|
|
result = await runner._handle_topic_command(_make_event("/topic"))
|
|
|
|
assert "not authorized" in result.lower()
|
|
# Tables must not be created for an unauthorized caller.
|
|
tables = {
|
|
row[0]
|
|
for row in db._conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'telegram_dm%'"
|
|
).fetchall()
|
|
}
|
|
assert tables == set()
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# Cross-topic Reply leak / stripped-reply recovery
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _seed_two_topic_bindings(session_db):
|
|
"""Create two topics for the same user in topic mode, oldest first."""
|
|
session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
# Seed two distinct sessions so the bind FK resolves.
|
|
session_db.create_session(
|
|
session_id="sess-A",
|
|
source="telegram",
|
|
user_id="208214988",
|
|
)
|
|
session_db.create_session(
|
|
session_id="sess-B",
|
|
source="telegram",
|
|
user_id="208214988",
|
|
)
|
|
# Old topic A first, then current topic B (so B is "most recent").
|
|
src_a = _make_source(thread_id="111")
|
|
session_db.bind_telegram_topic(
|
|
chat_id=src_a.chat_id,
|
|
thread_id=src_a.thread_id,
|
|
user_id=src_a.user_id,
|
|
session_key=build_session_key(src_a),
|
|
session_id="sess-A",
|
|
)
|
|
src_b = _make_source(thread_id="222")
|
|
session_db.bind_telegram_topic(
|
|
chat_id=src_b.chat_id,
|
|
thread_id=src_b.thread_id,
|
|
user_id=src_b.user_id,
|
|
session_key=build_session_key(src_b),
|
|
session_id="sess-B",
|
|
)
|
|
|
|
|
|
def test_recover_returns_none_for_known_topic(tmp_path):
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
_seed_two_topic_bindings(db)
|
|
runner = _make_runner(session_db=db)
|
|
|
|
assert runner._recover_telegram_topic_thread_id(_make_source(thread_id="222")) is None
|
|
|
|
|
|
def test_recover_preserves_unknown_thread_id_for_new_topic(tmp_path):
|
|
# A newly-created Telegram DM topic arrives with a real, previously-unbound
|
|
# message_thread_id. It must become its own session lane rather than being
|
|
# rewritten to whichever older topic was most recently active.
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
_seed_two_topic_bindings(db)
|
|
runner = _make_runner(session_db=db)
|
|
|
|
assert runner._recover_telegram_topic_thread_id(_make_source(thread_id="9999")) is None
|
|
|
|
|
|
def test_recover_rewrites_lobby_thread_id_to_most_recent(tmp_path):
|
|
# Stripped plain reply: thread_id is None, topic mode is on.
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
_seed_two_topic_bindings(db)
|
|
runner = _make_runner(session_db=db)
|
|
|
|
assert runner._recover_telegram_topic_thread_id(_make_source(thread_id=None)) == "222"
|
|
|
|
|
|
def test_recover_returns_none_when_topic_mode_disabled(tmp_path):
|
|
# Non-topic-mode DMs keep the existing strip-to-lobby behavior.
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
runner = _make_runner(session_db=db)
|
|
|
|
assert runner._recover_telegram_topic_thread_id(_make_source(thread_id=None)) is None
|
|
|
|
|
|
def test_recover_returns_none_when_no_bindings_yet(tmp_path):
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
runner = _make_runner(session_db=db)
|
|
|
|
assert runner._recover_telegram_topic_thread_id(_make_source(thread_id=None)) is None
|
|
|
|
|
|
def test_recover_returns_none_for_brand_new_topic(tmp_path):
|
|
# Regression for #31086: bindings exist for a prior topic but the user
|
|
# opened a fresh one (thread_id "99999"). Recovery must return None so the
|
|
# new topic gets its own session rather than being silently merged into
|
|
# the previous topic's session. The hijack was self-reinforcing — because
|
|
# the rewrite ran before _record_telegram_topic_binding, the new topic's
|
|
# binding row never got written, so every subsequent message in that topic
|
|
# looked "unknown" and was hijacked again.
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
db.create_session(session_id="sess-old", source="telegram", user_id="208214988")
|
|
src_old = _make_source(thread_id="12345")
|
|
db.bind_telegram_topic(
|
|
chat_id=src_old.chat_id,
|
|
thread_id=src_old.thread_id,
|
|
user_id=src_old.user_id,
|
|
session_key=build_session_key(src_old),
|
|
session_id="sess-old",
|
|
)
|
|
runner = _make_runner(session_db=db)
|
|
|
|
# "99999" is non-lobby and not in the binding table — brand-new topic.
|
|
assert runner._recover_telegram_topic_thread_id(_make_source(thread_id="99999")) is None
|
|
|
|
|
|
def test_list_telegram_topic_bindings_for_chat(tmp_path):
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
_seed_two_topic_bindings(db)
|
|
rows = db.list_telegram_topic_bindings_for_chat(chat_id="208214988")
|
|
assert [r["thread_id"] for r in rows] == ["222", "111"]
|
|
|
|
|
|
def test_list_telegram_topic_bindings_for_chat_no_table(tmp_path):
|
|
# Missing topic-mode tables → [] without auto-migrating.
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
assert db.list_telegram_topic_bindings_for_chat(chat_id="208214988") == []
|
|
tables = {
|
|
row[0]
|
|
for row in db._conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'telegram_dm%'"
|
|
).fetchall()
|
|
}
|
|
assert tables == set()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for get_telegram_topic_binding_by_session (issue #27166)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_get_telegram_topic_binding_by_session_returns_binding(tmp_path):
|
|
"""Reverse lookup by session_id returns the binding row."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
db.create_session(session_id="sess-27166", source="telegram", user_id="208214988")
|
|
db.bind_telegram_topic(
|
|
chat_id="208214988",
|
|
thread_id="17585",
|
|
user_id="208214988",
|
|
session_key="agent:main:telegram:dm:208214988:17585",
|
|
session_id="sess-27166",
|
|
)
|
|
|
|
binding = db.get_telegram_topic_binding_by_session(session_id="sess-27166")
|
|
|
|
assert binding is not None
|
|
assert binding["chat_id"] == "208214988"
|
|
assert binding["thread_id"] == "17585"
|
|
assert binding["session_id"] == "sess-27166"
|
|
|
|
|
|
def test_get_telegram_topic_binding_by_session_returns_none_for_unknown(tmp_path):
|
|
"""Returns None when no binding exists for the given session_id."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.apply_telegram_topic_migration()
|
|
|
|
result = db.get_telegram_topic_binding_by_session(session_id="nonexistent-sess")
|
|
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test for session-split thread_id recovery (issue #27166)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_session_split_restores_source_thread_id_from_binding(tmp_path):
|
|
"""After a session split, source.thread_id is restored from the binding.
|
|
|
|
Simulates the case where context compression creates a new session_id and
|
|
source.thread_id is None (synthetic/recovered event). The recovery block
|
|
must look up the binding by the new session_id and restore thread_id on
|
|
source so that _thread_metadata_for_source returns the correct thread.
|
|
"""
|
|
from gateway.run import GatewayRunner
|
|
from gateway.config import Platform
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988")
|
|
db.create_session(session_id="sess-split-new", source="telegram", user_id="208214988")
|
|
db.bind_telegram_topic(
|
|
chat_id="208214988",
|
|
thread_id="17585",
|
|
user_id="208214988",
|
|
session_key="agent:main:telegram:dm:208214988:17585",
|
|
session_id="sess-split-new",
|
|
)
|
|
|
|
runner = object.__new__(GatewayRunner)
|
|
runner._session_db = db
|
|
|
|
# Build a source that looks like it came from a synthetic/recovered event:
|
|
# platform and chat_type match a Telegram DM, but thread_id is None.
|
|
source = _make_source(thread_id=None)
|
|
assert source.platform == Platform.TELEGRAM
|
|
assert source.chat_type == "dm"
|
|
assert source.thread_id is None
|
|
|
|
# Simulate the session-split recovery block logic directly.
|
|
if (
|
|
getattr(source, "platform", None) == Platform.TELEGRAM
|
|
and getattr(source, "chat_type", None) == "dm"
|
|
and getattr(source, "thread_id", None) is None
|
|
and runner._session_db is not None
|
|
):
|
|
try:
|
|
_binding = runner._session_db.get_telegram_topic_binding_by_session(
|
|
session_id="sess-split-new",
|
|
)
|
|
if _binding and _binding.get("thread_id"):
|
|
source.thread_id = str(_binding["thread_id"])
|
|
except Exception:
|
|
pass
|
|
|
|
assert source.thread_id == "17585", (
|
|
"thread_id must be restored from the binding after session split"
|
|
)
|
|
|
|
# Confirm _thread_metadata_for_source now returns non-None.
|
|
runner.config = _make_runner(session_db=db).config
|
|
runner.adapters = _make_runner(session_db=db).adapters
|
|
meta = GatewayRunner._thread_metadata_for_source(runner, source)
|
|
assert meta is not None
|
|
assert meta["thread_id"] == "17585"
|