diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d39601546886..5fa972100583 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1778,13 +1778,76 @@ def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str def set_session_store(self, session_store: Any) -> None: """ Set the session store for checking active sessions. - + Used by adapters that need to check if a thread/conversation has an active session before processing messages (e.g., Slack thread replies without explicit mentions). """ self._session_store = session_store - + + def has_active_session_for_event(self, event: "MessageEvent") -> bool: + """Check if a persistent session already exists for this event's thread/chat. + + Uses ``build_session_key()`` as the single source of truth for key + construction, reading session-isolation flags from the adapter config. + Also evaluates the store's reset policy — a session that would be + auto-reset (idle timeout, daily reset) on the next interaction is + treated as non-existent so that thread context is still seeded. + + Subclasses can override to customise the ``SessionSource`` (e.g. force + ``chat_type="group"``), but the default implementation covers most + platforms. + """ + session_store = getattr(self, "_session_store", None) + if not session_store: + return False + + try: + store_cfg = getattr(session_store, "config", None) + gspu = getattr(store_cfg, "group_sessions_per_user", True) if store_cfg else True + tspu = getattr(store_cfg, "thread_sessions_per_user", False) if store_cfg else False + + session_key = build_session_key( + event.source, + group_sessions_per_user=gspu, + thread_sessions_per_user=tspu, + ) + session_store._ensure_loaded() + entry = session_store._entries.get(session_key) + if entry is None: + return False + + # Check whether the session would be auto-reset (idle/daily + # policy). If so, the gateway will create a fresh session on + # the next interaction — treat as non-existent so thread context + # is still seeded into the new session. + _should_reset = getattr(session_store, "_should_reset", None) + if _should_reset and event.source: + if _should_reset(entry, event.source): + return False + + return True + except Exception: + return False + + async def fetch_thread_context(self, event: "MessageEvent") -> Optional[str]: + """Fetch platform-specific thread/conversation context for first-time + thread entry. + + Override in subclasses to fetch prior messages from the platform API + when the bot is first mentioned in an existing thread. Return a + formatted context string to prepend to ``event.text``, or ``None``. + + Implementations should: + 1. Determine whether this event warrants context fetching (e.g. it is + a thread reply, not a brand-new thread the bot just created). + 2. Call ``self.has_active_session_for_event(event)`` — return ``None`` + if a session already exists (the session transcript already holds + prior messages). + 3. Fetch and format prior messages via the platform's native API. + """ + return None + @abstractmethod async def connect(self) -> bool: """ @@ -3538,6 +3601,16 @@ async def _stop_typing_task() -> None: try: await self._run_processing_hook("on_processing_start", event) + # Fetch platform-specific thread context for first-time thread + # entry. Each adapter overrides fetch_thread_context() to call + # its native API; the default returns None (no-op). + # Skip for commands — prepending context to "/reset" etc. would + # break command parsing in the gateway runner. + if not event.is_command(): + thread_context = await self.fetch_thread_context(event) + if thread_context: + event.text = thread_context + event.text + # Call the handler (this can take a while with tool calls) response = await self._message_handler(event) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 5accfdb41089..59a7afd4dcc1 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -2008,21 +2008,8 @@ async def _handle_slack_message(self, event: dict) -> None: for t in to_remove: self._mentioned_threads.discard(t) - # When entering a thread for the first time (no existing session), - # fetch thread context so the agent understands the conversation. - if is_thread_reply and not self._has_active_session_for_thread( - channel_id=channel_id, - thread_ts=event_thread_ts, - user_id=user_id, - ): - thread_context = await self._fetch_thread_context( - channel_id=channel_id, - thread_ts=event_thread_ts, - current_ts=ts, - team_id=team_id, - ) - if thread_context: - text = thread_context + text + # Thread context fetching is now handled by the base class via + # fetch_thread_context() called in _process_message_background(). # Determine message type msg_type = MessageType.TEXT @@ -2754,6 +2741,36 @@ async def _fetch_thread_parent_text( logger.debug("[Slack] Failed to fetch thread parent text: %s", exc) return "" + async def fetch_thread_context(self, event: "MessageEvent") -> Optional[str]: + """Fetch Slack thread context on first-time thread entry. + + Overrides ``BasePlatformAdapter.fetch_thread_context()`` so that the + base class call site in ``_process_message_background()`` automatically + prepends thread history for Slack threads. + """ + raw = event.raw_message + if not isinstance(raw, dict): + return None + + ts = raw.get("ts", "") + event_thread_ts = raw.get("thread_ts") + is_thread_reply = bool(event_thread_ts and event_thread_ts != ts) + if not is_thread_reply: + return None + + if self.has_active_session_for_event(event): + return None + + team_id = raw.get("team", "") + channel_id = raw.get("channel", event.source.chat_id if event.source else "") + context = await self._fetch_thread_context( + channel_id=channel_id, + thread_ts=event_thread_ts, + current_ts=ts, + team_id=team_id, + ) + return context or None + async def _handle_slash_command(self, command: dict) -> None: """Handle Slack slash commands. @@ -2846,46 +2863,22 @@ def _has_active_session_for_thread( thread_ts: str, user_id: str, ) -> bool: - """Check if there's an active session for a thread. + """Check if there's a live (non-expired) session for a thread. Used to determine if thread replies without @mentions should be processed (they should if there's an active session). - Uses ``build_session_key()`` as the single source of truth for key - construction — avoids the bug where manual key building didn't - respect ``thread_sessions_per_user`` and ``group_sessions_per_user`` - settings correctly. + Delegates to ``has_active_session_for_event()`` which checks both + key presence AND the store's reset policy (idle/daily expiry). """ - session_store = getattr(self, "_session_store", None) - if not session_store: - return False - - try: - from gateway.session import SessionSource, build_session_key - - source = SessionSource( - platform=Platform.SLACK, - chat_id=channel_id, - chat_type="group", - user_id=user_id, - thread_id=thread_ts, - ) - - # Read session isolation settings from the store's config - store_cfg = getattr(session_store, "config", None) - gspu = getattr(store_cfg, "group_sessions_per_user", True) if store_cfg else True - tspu = getattr(store_cfg, "thread_sessions_per_user", False) if store_cfg else False - - session_key = build_session_key( - source, - group_sessions_per_user=gspu, - thread_sessions_per_user=tspu, - ) - - session_store._ensure_loaded() - return session_key in session_store._entries - except Exception: - return False + source = self.build_source( + chat_id=channel_id, + chat_type="group", + user_id=user_id, + thread_id=thread_ts, + ) + event = MessageEvent(text="", source=source) + return self.has_active_session_for_event(event) async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str: """Download a Slack file using the bot token for auth, with retry.""" diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index efe0b5d1de70..05675719a328 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -4450,6 +4450,62 @@ async def _cache_discord_document(self, att, ext: str) -> bytes: raise Exception(f"HTTP {resp.status}") return await resp.read() + async def fetch_thread_context(self, event: "MessageEvent") -> Optional[str]: + """Fetch Discord thread history on first-time thread entry. + + Overrides ``BasePlatformAdapter.fetch_thread_context()`` so that the + base class call site in ``_process_message_background()`` automatically + prepends thread history for Discord threads. + + Skips auto-created threads (no history to fetch) and DMs. + """ + message = event.raw_message + if message is None: + return None + + # Only fetch for pre-existing threads — not DMs or auto-created ones. + channel = getattr(message, "channel", None) + if channel is None or not isinstance(channel, discord.Thread): + return None + + if self.has_active_session_for_event(event): + return None + + try: + context_parts: list[str] = [] + bot_user = self._client.user + # oldest_first=True gives chronological order + async for msg in channel.history(limit=30, oldest_first=True): + # Skip the triggering message itself + if msg.id == message.id: + continue + # Skip bot's own messages to avoid circular context + if bot_user and msg.author.id == bot_user.id: + continue + msg_text = (msg.content or "").strip() + if not msg_text: + continue + # Strip bot @mentions from context messages + if bot_user: + msg_text = msg_text.replace(f"<@{bot_user.id}>", "").strip() + msg_text = msg_text.replace(f"<@!{bot_user.id}>", "").strip() + if not msg_text: + continue + name = getattr(msg.author, "display_name", None) or msg.author.name + context_parts.append(f"{name}: {msg_text}") + + if not context_parts: + return None + + return ( + "[Thread context \u2014 prior messages in this thread (not yet in conversation history):]\n" + + "\n".join(context_parts) + + "\n[End of thread context]\n\n" + ) + except Exception as e: + logger.warning("[Discord] Failed to fetch thread context: %s", e) + return None + async def _handle_message(self, message: DiscordMessage) -> None: """Handle incoming Discord messages.""" # In server channels (not DMs), require the bot to be @mentioned diff --git a/tests/gateway/test_slack_approval_buttons.py b/tests/gateway/test_slack_approval_buttons.py index bc12d0072bd3..0cf8261436fc 100644 --- a/tests/gateway/test_slack_approval_buttons.py +++ b/tests/gateway/test_slack_approval_buttons.py @@ -518,6 +518,7 @@ def test_uses_build_session_key(self): mock_store.config = MagicMock() mock_store.config.group_sessions_per_user = False # threads don't include user_id mock_store.config.thread_sessions_per_user = False + mock_store._should_reset = MagicMock(return_value=None) # session is live adapter._session_store = mock_store # With the fix, build_session_key should be called which respects @@ -538,6 +539,7 @@ def test_no_session_returns_false(self): mock_store.config = MagicMock() mock_store.config.group_sessions_per_user = True mock_store.config.thread_sessions_per_user = False + mock_store._should_reset = MagicMock(return_value=None) adapter._session_store = mock_store result = adapter._has_active_session_for_thread( diff --git a/tests/gateway/test_thread_context_hook.py b/tests/gateway/test_thread_context_hook.py new file mode 100644 index 000000000000..ecbf5d6edca2 --- /dev/null +++ b/tests/gateway/test_thread_context_hook.py @@ -0,0 +1,461 @@ +"""Tests for the unified fetch_thread_context() hook in BasePlatformAdapter.""" + +import asyncio +import sys +from pathlib import Path +from typing import Optional +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import BasePlatformAdapter, MessageEvent, ProcessingOutcome, SendResult +from gateway.session import SessionSource, build_session_key + + +# --------------------------------------------------------------------------- +# Minimal DummyAdapter for base class tests +# --------------------------------------------------------------------------- + +class DummyAdapter(BasePlatformAdapter): + """Minimal adapter for testing base class behavior.""" + + def __init__(self, platform: Platform = Platform.TELEGRAM): + super().__init__(PlatformConfig(enabled=True, token="fake"), platform) + self.sent = [] + + async def connect(self) -> bool: + return True + + async def disconnect(self) -> None: + return None + + async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: + self.sent.append(content) + return SendResult(success=True, message_id="1") + + async def send_typing(self, chat_id: str, metadata=None) -> None: + return None + + async def get_chat_info(self, chat_id: str): + return {"id": chat_id} + + async def on_processing_start(self, event: MessageEvent) -> None: + pass + + async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: + pass + + +class ContextDummyAdapter(DummyAdapter): + """DummyAdapter that returns context from fetch_thread_context().""" + + def __init__(self, context_to_return: Optional[str] = None): + super().__init__() + self.context_to_return = context_to_return + + async def fetch_thread_context(self, event: MessageEvent) -> Optional[str]: + return self.context_to_return + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +def _make_event( + chat_id: str = "chat1", + thread_id: Optional[str] = None, + text: str = "hello", + platform: Platform = Platform.TELEGRAM, + user_id: Optional[str] = None, + raw_message: object = None, +) -> MessageEvent: + return MessageEvent( + text=text, + source=SessionSource( + platform=platform, + chat_id=chat_id, + chat_type="group", + thread_id=thread_id, + user_id=user_id, + ), + message_id="msg1", + raw_message=raw_message, + ) + + +def _make_session_store(entries: dict = None, gspu: bool = True, tspu: bool = False, should_reset=None): + store = MagicMock() + store._entries = entries or {} + store._ensure_loaded = MagicMock() + store.config = MagicMock() + store.config.group_sessions_per_user = gspu + store.config.thread_sessions_per_user = tspu + # _should_reset returns a reason string if the session should be reset, else None + store._should_reset = MagicMock(return_value=should_reset) + return store + + +# =========================================================================== +# has_active_session_for_event +# =========================================================================== + +class TestHasActiveSessionForEvent: + """Test the base class session-check helper.""" + + def test_returns_false_without_session_store(self): + adapter = DummyAdapter() + event = _make_event(thread_id="t1") + assert adapter.has_active_session_for_event(event) is False + + def test_returns_true_when_session_exists(self): + adapter = DummyAdapter() + event = _make_event(chat_id="C1", thread_id="1000.0", platform=Platform.TELEGRAM) + key = build_session_key(event.source, group_sessions_per_user=False, thread_sessions_per_user=False) + adapter.set_session_store(_make_session_store(entries={key: MagicMock()}, gspu=False)) + assert adapter.has_active_session_for_event(event) is True + + def test_returns_false_when_no_session(self): + adapter = DummyAdapter() + event = _make_event(chat_id="C1", thread_id="1000.0") + adapter.set_session_store(_make_session_store(entries={})) + assert adapter.has_active_session_for_event(event) is False + + def test_returns_false_on_exception(self): + adapter = DummyAdapter() + event = _make_event(thread_id="t1") + store = MagicMock() + store._ensure_loaded = MagicMock(side_effect=RuntimeError("broken")) + store.config = MagicMock() + store.config.group_sessions_per_user = True + store.config.thread_sessions_per_user = False + adapter.set_session_store(store) + assert adapter.has_active_session_for_event(event) is False + + def test_returns_false_when_session_would_be_reset(self): + """A session that exists but would be auto-reset (idle/daily) is treated as absent.""" + adapter = DummyAdapter() + event = _make_event(chat_id="C1", thread_id="1000.0", platform=Platform.TELEGRAM) + key = build_session_key(event.source, group_sessions_per_user=False, thread_sessions_per_user=False) + entry = MagicMock() + store = _make_session_store(entries={key: entry}, gspu=False, should_reset="idle") + adapter.set_session_store(store) + + assert adapter.has_active_session_for_event(event) is False + store._should_reset.assert_called_once_with(entry, event.source) + + def test_returns_true_when_session_not_expired(self): + """A session that exists and is NOT expired is treated as present.""" + adapter = DummyAdapter() + event = _make_event(chat_id="C1", thread_id="1000.0", platform=Platform.TELEGRAM) + key = build_session_key(event.source, group_sessions_per_user=False, thread_sessions_per_user=False) + entry = MagicMock() + store = _make_session_store(entries={key: entry}, gspu=False, should_reset=None) + adapter.set_session_store(store) + + assert adapter.has_active_session_for_event(event) is True + + +# =========================================================================== +# fetch_thread_context — base default +# =========================================================================== + +class TestFetchThreadContextBase: + """Test the base class default (returns None).""" + + @pytest.mark.asyncio + async def test_default_returns_none(self): + adapter = DummyAdapter() + event = _make_event(thread_id="t1") + result = await adapter.fetch_thread_context(event) + assert result is None + + +# =========================================================================== +# _process_message_background integration — context prepended +# =========================================================================== + +class TestThreadContextIntegration: + """Test that _process_message_background prepends thread context.""" + + @pytest.mark.asyncio + async def test_context_prepended_to_event_text(self): + adapter = ContextDummyAdapter(context_to_return="[Thread context]\nAlice: hi\n[End]\n\n") + captured_text = [] + + async def mock_handler(event): + captured_text.append(event.text) + return "ok" + + adapter.set_message_handler(mock_handler) + event = _make_event(text="my question", thread_id="t1") + + adapter._active_sessions["test_key"] = asyncio.Event() + await adapter._process_message_background(event, "test_key") + + assert len(captured_text) == 1 + assert captured_text[0].startswith("[Thread context]") + assert "my question" in captured_text[0] + + @pytest.mark.asyncio + async def test_no_context_when_none_returned(self): + adapter = ContextDummyAdapter(context_to_return=None) + captured_text = [] + + async def mock_handler(event): + captured_text.append(event.text) + return "ok" + + adapter.set_message_handler(mock_handler) + event = _make_event(text="my question", thread_id="t1") + + adapter._active_sessions["test_key"] = asyncio.Event() + await adapter._process_message_background(event, "test_key") + + assert len(captured_text) == 1 + assert captured_text[0] == "my question" + + @pytest.mark.asyncio + async def test_command_not_prefixed_with_context(self): + """Commands like /reset should not have thread context prepended.""" + adapter = ContextDummyAdapter(context_to_return="[Thread context]\nAlice: hi\n[End]\n\n") + captured_text = [] + + async def mock_handler(event): + captured_text.append(event.text) + return "ok" + + adapter.set_message_handler(mock_handler) + event = _make_event(text="/reset", thread_id="t1") + + adapter._active_sessions["test_key"] = asyncio.Event() + await adapter._process_message_background(event, "test_key") + + assert len(captured_text) == 1 + assert captured_text[0] == "/reset" + assert "[Thread context]" not in captured_text[0] + + @pytest.mark.asyncio + async def test_command_with_args_not_prefixed(self): + """Commands with arguments should also be left alone.""" + adapter = ContextDummyAdapter(context_to_return="[Thread context]\n[End]\n\n") + captured_text = [] + + async def mock_handler(event): + captured_text.append(event.text) + return "ok" + + adapter.set_message_handler(mock_handler) + event = _make_event(text="/model claude-sonnet-4-20250514", thread_id="t1") + + adapter._active_sessions["test_key"] = asyncio.Event() + await adapter._process_message_background(event, "test_key") + + assert len(captured_text) == 1 + assert captured_text[0].startswith("/model") + + +# =========================================================================== +# Discord fetch_thread_context +# =========================================================================== + +def _ensure_discord_mock(): + """Wire up minimal discord.py mocks so DiscordAdapter can be imported.""" + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + discord_mod = MagicMock() + discord_mod.Thread = type("Thread", (), {}) + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Intents.default.return_value.message_content = True + for name in [ + "discord", "discord.ext", "discord.ext.commands", + "discord.opus", "discord.sinks", + ]: + sys.modules.setdefault(name, discord_mod) + + +_ensure_discord_mock() + +import discord +from plugins.platforms.discord.adapter import DiscordAdapter + + +def _make_discord_adapter(): + config = PlatformConfig(enabled=True, token="fake-discord-token") + adapter = DiscordAdapter(config) + adapter._client = MagicMock() + adapter._client.user = MagicMock() + adapter._client.user.id = 999 + return adapter + + +def _make_discord_thread_message(messages, msg_id=100, author_name="Caller", author_id=1): + """Create a mock Discord message in a thread with history.""" + thread = MagicMock(spec=discord.Thread) + + async def mock_history(limit=30, oldest_first=True): + for m in messages: + yield m + + thread.history = mock_history + + message = MagicMock() + message.id = msg_id + message.channel = thread + message.author = MagicMock() + message.author.id = author_id + message.author.display_name = author_name + message.author.name = author_name + return message + + +def _make_discord_msg(msg_id, author_id, author_name, content): + msg = MagicMock() + msg.id = msg_id + msg.author = MagicMock() + msg.author.id = author_id + msg.author.display_name = author_name + msg.author.name = author_name + msg.content = content + return msg + + +class TestDiscordFetchThreadContext: + """Test the Discord adapter's fetch_thread_context() override.""" + + @pytest.mark.asyncio + async def test_fetches_and_formats_context(self): + adapter = _make_discord_adapter() + thread_msgs = [ + _make_discord_msg(1, 10, "Alice", "This is the original question"), + _make_discord_msg(2, 20, "Bob", "I think we should refactor"), + _make_discord_msg(100, 1, "Caller", "Current message"), # triggering msg + ] + raw_message = _make_discord_thread_message(thread_msgs) + event = _make_event( + chat_id="chan1", thread_id="thread1", text="hey bot", + platform=Platform.DISCORD, raw_message=raw_message, + ) + adapter.set_session_store(_make_session_store(entries={})) + + context = await adapter.fetch_thread_context(event) + + assert context is not None + assert "[Thread context" in context + assert "Alice: This is the original question" in context + assert "Bob: I think we should refactor" in context + # Triggering message should be excluded + assert "Current message" not in context + + @pytest.mark.asyncio + async def test_skips_bot_messages(self): + adapter = _make_discord_adapter() + bot_id = adapter._client.user.id + thread_msgs = [ + _make_discord_msg(1, 10, "Alice", "Question"), + _make_discord_msg(2, bot_id, "Bot", "Bot reply (should be skipped)"), + _make_discord_msg(100, 1, "Caller", "Current"), + ] + raw_message = _make_discord_thread_message(thread_msgs) + event = _make_event( + chat_id="chan1", thread_id="thread1", text="hey", + platform=Platform.DISCORD, raw_message=raw_message, + ) + adapter.set_session_store(_make_session_store(entries={})) + + context = await adapter.fetch_thread_context(event) + + assert context is not None + assert "Bot reply" not in context + assert "Alice: Question" in context + + @pytest.mark.asyncio + async def test_returns_none_when_session_exists(self): + adapter = _make_discord_adapter() + thread_msgs = [_make_discord_msg(1, 10, "Alice", "Question")] + raw_message = _make_discord_thread_message(thread_msgs) + event = _make_event( + chat_id="chan1", thread_id="thread1", text="hey", + platform=Platform.DISCORD, raw_message=raw_message, + ) + + key = build_session_key(event.source, group_sessions_per_user=False, thread_sessions_per_user=False) + adapter.set_session_store(_make_session_store(entries={key: MagicMock()}, gspu=False)) + + context = await adapter.fetch_thread_context(event) + assert context is None + + @pytest.mark.asyncio + async def test_returns_none_for_non_thread(self): + adapter = _make_discord_adapter() + message = MagicMock() + message.channel = MagicMock() # Not a discord.Thread + event = _make_event( + chat_id="chan1", text="hey", + platform=Platform.DISCORD, raw_message=message, + ) + + context = await adapter.fetch_thread_context(event) + assert context is None + + @pytest.mark.asyncio + async def test_returns_none_on_empty_thread(self): + adapter = _make_discord_adapter() + raw_message = _make_discord_thread_message( + [_make_discord_msg(100, 1, "Caller", "Current")], + ) + event = _make_event( + chat_id="chan1", thread_id="thread1", text="hey", + platform=Platform.DISCORD, raw_message=raw_message, + ) + adapter.set_session_store(_make_session_store(entries={})) + + context = await adapter.fetch_thread_context(event) + # Only the triggering message exists, which is excluded + assert context is None + + @pytest.mark.asyncio + async def test_strips_bot_mentions(self): + adapter = _make_discord_adapter() + bot_id = adapter._client.user.id + thread_msgs = [ + _make_discord_msg(1, 10, "Alice", f"hey <@{bot_id}> what do you think?"), + _make_discord_msg(100, 1, "Caller", "Current"), + ] + raw_message = _make_discord_thread_message(thread_msgs) + event = _make_event( + chat_id="chan1", thread_id="thread1", text="hey", + platform=Platform.DISCORD, raw_message=raw_message, + ) + adapter.set_session_store(_make_session_store(entries={})) + + context = await adapter.fetch_thread_context(event) + + assert context is not None + assert f"<@{bot_id}>" not in context + assert "what do you think?" in context + + @pytest.mark.asyncio + async def test_api_failure_returns_none(self): + adapter = _make_discord_adapter() + thread = MagicMock(spec=discord.Thread) + + async def broken_history(**kwargs): + raise RuntimeError("API error") + # Make it an async generator that raises + yield # pragma: no cover + + thread.history = broken_history + message = MagicMock() + message.id = 100 + message.channel = thread + event = _make_event( + chat_id="chan1", thread_id="thread1", text="hey", + platform=Platform.DISCORD, raw_message=message, + ) + adapter.set_session_store(_make_session_store(entries={})) + + context = await adapter.fetch_thread_context(event) + assert context is None