From e84fe483bc958ef2ce11463d10ee57bdc2ccc5fb Mon Sep 17 00:00:00 2001 From: snav Date: Thu, 14 May 2026 01:46:11 -0400 Subject: [PATCH] feat(discord): channel history backfill for multi-user sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional channel-context backfill for Discord shared-channel sessions so the agent can see recent messages it missed between its own turns (typically when require_mention=true filters out most traffic). Previously the agent only saw the @mention message that triggered it, which led to disorienting replies in active multi-user channels where the conversation context was invisible. With backfill enabled, a configurable number of recent messages are fetched per-turn and prepended to the trigger message as a context block, kept separate from sender-prefix logic so attribution remains clean. This re-opens the work from #13063 (approved by @OutThisLife on 2026-04-20, closed when I closed the branch to address the simpolism:main head-branch issue plus an ordering bug I caught later in live use). Filing against the freshly-rewritten problem statement in #13054 so the design is grounded in the failure mode rather than the implementation shape. The implementation follows the **push-mode last-self-anchored** design from the two options laid out in #13054. See the issue for the trade-off discussion vs pull-mode (#13120 was an earlier closed PR using that shape). Treating this as a reference implementation — happy to rewrite as last-trigger anchoring or as a hybrid with #13120 if maintainers prefer. Changes: - gateway/platforms/discord.py: - new `_discord_history_backfill()` / `_discord_history_backfill_limit()` helpers (config.extra > env > default), mirroring the existing `_discord_require_mention()` shape - new `_fetch_channel_context()` that scans `channel.history()` backwards from the trigger to the bot's last message (or limit), formats as `[Recent channel messages] / [name] msg / ...`, respects DISCORD_ALLOW_BOTS, skips system messages - per-channel `_last_self_message_id` cache to narrow the fetch window on hot paths (avoids full history scan when the bot has spoken recently) - **IMPORTANT**: passes `oldest_first=False` explicitly to `channel.history()`. discord.py 2.x silently flips the default to True when `after=` is supplied, which would select the EARLIEST N messages after our last response instead of the LATEST N before the trigger. In high-traffic windows this would return stale tool traces and drop the actual final answer the user is asking about. See regression test below. Caught in live use during a Codex tool-trace burst on May 13 2026. - gateway/config.py: discord_history_backfill + discord_history_backfill_limit settings + yaml→env bridge - gateway/platforms/base.py: channel_context field on MessageEvent - gateway/run.py: prepend channel_context after sender-prefix so the [sender name] tag applies to the trigger message alone, not to the backfill - hermes_cli/config.py: defaults for new discord.history_backfill and discord.history_backfill_limit keys - cli-config.yaml.example: documented defaults - tests/gateway/test_discord_free_response.py: 7 new tests covering cold-start backfill, self-message stop boundary, other-bot filtering, cache hot-path narrowing, stale-cache fallback, shared-channel + per-user backfill paths, and the ordering regression test (`test_fetch_channel_context_cache_uses_latest_window_when_after_set`) - tests/gateway/test_config.py: yaml→env bridge tests - tests/gateway/test_session.py: prefix-order edge cases - website/docs/user-guide/messaging/discord.md: env vars + config keys + usage docs Tested on Ubuntu 24.04 — empirically validated in my own multi-bot Discord research server for the past three weeks. Fixes #13054 Supersedes #13063 (closed) --- cli-config.yaml.example | 10 + gateway/config.py | 8 + gateway/platforms/base.py | 6 + gateway/platforms/discord.py | 183 ++++++++++++- gateway/run.py | 6 + hermes_cli/config.py | 2 + tests/gateway/test_config.py | 20 ++ tests/gateway/test_discord_free_response.py | 265 +++++++++++++++++++ tests/gateway/test_session.py | 71 +++++ website/docs/user-guide/messaging/discord.md | 27 ++ 10 files changed, 596 insertions(+), 2 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 13d9ad9c420..c286099a87a 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -681,6 +681,16 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages +# +# Discord-specific settings (config.yaml top-level, not under platforms:): +# +# discord: +# require_mention: true # Require @mention in server channels (default: true) +# auto_thread: true # Auto-create thread on @mention (default: true) +# free_response_channels: "" # Channel IDs where no mention is needed +# reactions: true # Show processing reactions (default: true) +# history_backfill: false # Recover missed channel messages on mention (default: false) +# history_backfill_limit: 50 # Max messages to scan backwards (default: 50) # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) diff --git a/gateway/config.py b/gateway/config.py index b3b87e24664..7180f1ddb84 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -941,6 +941,14 @@ def load_gateway_config() -> GatewayConfig: if isinstance(ntc, list): ntc = ",".join(str(v) for v in ntc) os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) + # history_backfill: recover missed channel messages for shared sessions + # when require_mention is active. Fetches messages between bot turns + # and prepends them to the user message for context. + if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"): + os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower() + hbl = discord_cfg.get("history_backfill_limit") + if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"): + os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl) # allow_mentions: granular control over what the bot can ping. # Safe defaults (no @everyone/roles) are applied in the adapter; # these YAML keys only override when set and let users opt back diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index ad9dac170ee..d03bc282ed3 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -955,6 +955,12 @@ class MessageEvent: # Per-channel ephemeral system prompt (e.g. Discord channel_prompts). # Applied at API call time and never persisted to transcript history. channel_prompt: Optional[str] = None + + # Channel context recovered by history backfill (e.g. messages between + # bot turns that were missed due to require_mention). Kept separate + # from ``text`` so the sender-prefix logic in run.py can operate on the + # trigger message alone, then prepend this context afterward. + channel_context: Optional[str] = None # Internal flag — set for synthetic events (e.g. background process # completion notifications) that must bypass user authorization checks. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 4793df35c7c..652e8d4af76 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -589,6 +589,10 @@ class DiscordAdapter(BasePlatformAdapter): # chunk only, default), "all" (reply-reference on every chunk). self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' self._slash_commands: bool = self.config.extra.get("slash_commands", True) + # In-memory cache of the bot's last message ID per channel, used by + # history backfill to skip the full scan on hot paths. Falls back to + # scanning channel.history() on cache miss (cold start / restart). + self._last_self_message_id: Dict[str, str] = {} async def connect(self) -> bool: """Connect to Discord and start receiving events.""" @@ -1459,6 +1463,12 @@ class DiscordAdapter(BasePlatformAdapter): raise message_ids.append(str(msg.id)) + # Track the last message we sent in this channel for history + # backfill — avoids a full channel.history() scan on hot paths. + if message_ids: + _target_id = thread_id or chat_id + self._last_self_message_id[_target_id] = message_ids[-1] + return SendResult( success=True, message_id=message_ids[0] if message_ids else None, @@ -3596,6 +3606,134 @@ class DiscordAdapter(BasePlatformAdapter): return bool(configured) return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + def _discord_history_backfill(self) -> bool: + """Return whether history backfill is enabled for shared sessions.""" + configured = self.config.extra.get("history_backfill") + if configured is not None: + if isinstance(configured, str): + return configured.lower() not in ("false", "0", "no", "off") + return bool(configured) + return os.getenv("DISCORD_HISTORY_BACKFILL", "false").lower() in ("true", "1", "yes") + + def _discord_history_backfill_limit(self) -> int: + """Return the max number of messages to scan backwards for context. + + In practice the scan usually stops much earlier — at the bot's own + last message in the channel (the natural partition point). This + limit is a safety cap for cold starts and long gaps where no prior + bot message exists in recent history. + """ + configured = self.config.extra.get("history_backfill_limit") + if configured is not None: + try: + return int(configured) + except (ValueError, TypeError): + pass + raw = os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT", "50") + try: + return int(raw) + except (ValueError, TypeError): + return 50 + + async def _fetch_channel_context( + self, + channel: Any, + before: "DiscordMessage", + ) -> str: + """Fetch recent channel messages for conversational context. + + Scans backwards from *before* and collects messages until it hits + a message sent by this bot (the natural partition point between + bot turns) or reaches ``history_backfill_limit``. + + Returns a formatted block like:: + + [Recent channel messages] + [Alice] some message + [Bob [bot]] another message + + Returns an empty string if no context is available. + """ + limit = self._discord_history_backfill_limit() + if limit <= 0: + return "" + + # Determine which bot messages to include in context + allow_bots_raw = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() + include_other_bots = allow_bots_raw != "none" + + # Use the in-memory cache to narrow the fetch window on hot paths. + # If we know our last message ID in this channel, pass it as `after` + # to avoid scanning the full limit. Falls back to scanning on cache + # miss (cold start / restart). + # Guard: only use the cache when it's chronologically before the + # trigger — Discord snowflake IDs are monotonically increasing, so + # a simple int comparison suffices. + channel_id = str(getattr(channel, "id", "")) + _cached_id = self._last_self_message_id.get(channel_id) + _after_obj = None + try: + if _cached_id and int(_cached_id) < int(before.id): + _after_obj = discord.Object(id=int(_cached_id)) + except (ValueError, TypeError): + pass # Malformed cache entry — fall back to cold-start scan + + try: + collected = [] + # IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x + # silently flips the default to True when `after=` is supplied, + # which would select the *earliest* N messages after our last + # response instead of the *latest* N before the trigger. In + # high-traffic windows that returns stale tool traces and drops + # the actual final answer. See the regression test + # `test_fetch_channel_context_cache_uses_latest_window_when_after_set`. + async for msg in channel.history( + limit=limit, + before=before, + after=_after_obj, + oldest_first=False, + ): + # Stop at our own message — this is the partition point. + # Everything before this is already in the session transcript. + # (Redundant when _after_obj is set, but needed for cold start.) + if msg.author == self._client.user: + break + + # Skip system messages (pins, joins, thread renames, etc.) + if msg.type not in (discord.MessageType.default, discord.MessageType.reply): + continue + + # Respect DISCORD_ALLOW_BOTS for other bots. + # For history context, "mentions" is treated as "all" — we are + # deciding what context to show, not whether to respond. + if getattr(msg.author, "bot", False) and not include_other_bots: + continue + + content = getattr(msg, "clean_content", msg.content) or "" + if not content and msg.attachments: + content = "(attachment)" + if not content: + continue + + name = msg.author.display_name + if getattr(msg.author, "bot", False): + name = f"{name} [bot]" + collected.append(f"[{name}] {content}") + + if not collected: + return "" + + # channel.history returns newest-first (oldest_first=False); reverse for chronological order + collected.reverse() + return "[Recent channel messages]\n" + "\n".join(collected) + + except discord.Forbidden: + logger.debug("[%s] Missing permissions to fetch channel history", self.name) + return "" + except Exception as e: + logger.warning("[%s] Failed to fetch channel history: %s", self.name, e) + return "" + def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -4504,9 +4642,49 @@ class DiscordAdapter(BasePlatformAdapter): if pending_text_injection: event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection + # ── History backfill ───────────────────────────────────────── + # When require_mention is active, the bot only processes messages + # that @mention it. This means channel messages between bot turns + # are invisible to the session transcript. To recover that context, + # fetch recent channel history and prepend it to the user message. + # + # The fetch window is: everything after the bot's last message in + # the channel up to (but not including) the current trigger. On + # cold start (no prior bot message found), fetch the last N messages + # and stop at the first self-message encountered. + # + # This only runs for shared sessions (group_sessions_per_user=False + # or shared threads) where multiple users contribute context the bot + # would otherwise miss. + # + # Messages that arrive while the bot is processing (between trigger + # and response) are not captured — this is an accepted simplification + # to keep the partition rule clean. + _channel_context = None + _is_dm = isinstance(message.channel, discord.DMChannel) + if not _is_dm: + _is_shared = ( + (is_thread and not self.config.extra.get("thread_sessions_per_user", False)) + or (not is_thread and not self.config.extra.get("group_sessions_per_user", True)) + ) + _needed_mention = ( + require_mention + and not is_free_channel + and not in_bot_thread + ) + _backfill_enabled = self._discord_history_backfill() + if _is_shared and _needed_mention and _backfill_enabled: + _backfill_text = await self._fetch_channel_context( + message.channel, before=message, + ) + if _backfill_text: + _channel_context = _backfill_text + # Defense-in-depth: prevent empty user messages from entering session - # (can happen when user sends @mention-only with no other text) - if not event_text or not event_text.strip(): + # (can happen when user sends @mention-only with no other text). + # When channel_context is present, a bare mention means "catch me up" + # — the context IS the message, so skip the placeholder. + if (not event_text or not event_text.strip()) and not _channel_context: event_text = "(The user sent a message with no text content)" _chan = message.channel @@ -4535,6 +4713,7 @@ class DiscordAdapter(BasePlatformAdapter): timestamp=message.created_at, auto_skill=_skills, channel_prompt=_channel_prompt, + channel_context=_channel_context, ) # Track thread participation so the bot won't require @mention for diff --git a/gateway/run.py b/gateway/run.py index 77ed7260c3b..d986917ebab 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6809,6 +6809,12 @@ class GatewayRunner: if _is_shared_multi_user and source.user_name: message_text = f"[{source.user_name}] {message_text}" + # Prepend channel context from history backfill (if any). This + # happens after sender-prefix so the prefix only applies to the + # trigger message, not the backfill block. + if getattr(event, "channel_context", None): + message_text = f"{event.channel_context}\n\n[New message]\n{message_text}" + if event.media_urls: image_paths = [] audio_paths = [] diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 685de3d7341..8bd8e7fa079 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1251,6 +1251,8 @@ DEFAULT_CONFIG = { "allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist) "auto_thread": True, # Auto-create threads on @mention in channels (like Slack) "thread_require_mention": False, # If True, require @mention in threads too (multi-bot threads) + "history_backfill": False, # If True, prepend recent channel scrollback when bot is triggered in a shared channel + "history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block "reactions": True, # Add 👀/✅/❌ reactions to messages during processing "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) # Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index aae3c9e5880..cf197bd6f7f 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -409,6 +409,26 @@ class TestLoadGatewayConfig: "456": "Therapist mode", } + def test_bridges_discord_history_backfill_settings_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " history_backfill: true\n" + " history_backfill_limit: 17\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_HISTORY_BACKFILL", raising=False) + monkeypatch.delenv("DISCORD_HISTORY_BACKFILL_LIMIT", raising=False) + + load_gateway_config() + + assert os.getenv("DISCORD_HISTORY_BACKFILL") == "true" + assert os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT") == "17" + def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 57198b9e73a..cf81961a201 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -62,6 +62,12 @@ class FakeTextChannel: self.guild = SimpleNamespace(name=guild_name) self.topic = None + def history(self, *, limit, before, after=None, oldest_first=None): + async def _iter(): + return + yield + return _iter() + class FakeForumChannel: def __init__(self, channel_id: int = 1, name: str = "support-forum", guild_name: str = "Hermes Server"): @@ -99,6 +105,9 @@ def adapter(monkeypatch): "DISCORD_NO_THREAD_CHANNELS", "DISCORD_ALLOWED_CHANNELS", "DISCORD_IGNORED_CHANNELS", + "DISCORD_HISTORY_BACKFILL", + "DISCORD_HISTORY_BACKFILL_LIMIT", + "DISCORD_ALLOW_BOTS", ): monkeypatch.delenv(_var, raising=False) @@ -125,6 +134,48 @@ def make_message(*, channel, content: str, mentions=None, msg_type=None): ) +def make_history_message( + *, + author, + content: str, + msg_id: int, + msg_type=None, + attachments=None, +): + return SimpleNamespace( + id=msg_id, + author=author, + content=content, + attachments=list(attachments or []), + type=msg_type if msg_type is not None else discord_platform.discord.MessageType.default, + ) + + +class FakeHistoryChannel(FakeTextChannel): + def __init__(self, history_messages, **kwargs): + super().__init__(**kwargs) + self._history_messages = list(history_messages) + + def history(self, *, limit, before, after=None, oldest_first=None): + before_id = int(getattr(before, "id", before)) + after_id = int(getattr(after, "id", after)) if after is not None else None + if oldest_first is None: + oldest_first = after is not None + + messages = [ + message for message in self._history_messages + if int(message.id) < before_id + and (after_id is None or int(message.id) > after_id) + ] + messages.sort(key=lambda message: int(message.id), reverse=not oldest_first) + + async def _iter(): + for message in messages[:limit]: + yield message + + return _iter() + + @pytest.mark.asyncio async def test_discord_defaults_to_require_mention(adapter, monkeypatch): """Default behavior: require @mention in server channels.""" @@ -578,3 +629,217 @@ async def test_discord_thread_require_mention_via_config_extra(adapter, monkeypa await adapter._handle_message(message) adapter.handle_message.assert_not_awaited() + + + +@pytest.mark.asyncio +async def test_fetch_channel_context_stops_at_self_message_and_reverses_to_chronological_order(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 10 + + other_bot = SimpleNamespace(id=55, display_name="Gemini", name="Gemini", bot=True) + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + old_human = SimpleNamespace(id=57, display_name="Bob", name="Bob", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=human, content="latest human note", msg_id=4), + make_history_message(author=other_bot, content="latest bot note", msg_id=3), + make_history_message(author=adapter._client.user, content="our prior response", msg_id=2), + make_history_message(author=old_human, content="older than boundary", msg_id=1), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + assert result == ( + "[Recent channel messages]\n" + "[Gemini [bot]] latest bot note\n" + "[Alice] latest human note" + ) + + +@pytest.mark.asyncio +async def test_fetch_channel_context_skips_other_bots_when_allow_bots_none(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none") + adapter.config.extra["history_backfill_limit"] = 10 + + other_bot = SimpleNamespace(id=55, display_name="Gemini", name="Gemini", bot=True) + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=human, content="human note", msg_id=3), + make_history_message(author=other_bot, content="bot note", msg_id=2), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + assert result == "[Recent channel messages]\n[Alice] human note" + + +@pytest.mark.asyncio +async def test_fetch_channel_context_uses_cache_to_narrow_window(adapter, monkeypatch): + """When _last_self_message_id is cached, the fetch passes after= to skip old messages.""" + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 50 + + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + # Record the after= arg passed to history() + recorded_after = {} + + class CacheTrackingChannel(FakeHistoryChannel): + def history(self, *, limit, before, after=None, oldest_first=None): + recorded_after["value"] = after + return super().history( + limit=limit, + before=before, + after=after, + oldest_first=oldest_first, + ) + + channel = CacheTrackingChannel( + [make_history_message(author=human, content="hello", msg_id=200)], + channel_id=777, + ) + + # Seed the cache — bot's last message in this channel was ID 100 + adapter._last_self_message_id["777"] = "100" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 300 # trigger is newer than cache + + result = await adapter._fetch_channel_context(channel, before=trigger) + + assert result == "[Recent channel messages]\n[Alice] hello" + # Verify cache was used: after= should be set (not None) + assert recorded_after["value"] is not None + + +@pytest.mark.asyncio +async def test_fetch_channel_context_cache_uses_latest_window_when_after_set(adapter, monkeypatch): + """Regression: discord.py defaults oldest_first=True when after= is provided. + + The hot cache path passes both after= and before=. We still want the latest + messages before the trigger, not the earliest messages after our prior + response, otherwise tool traces can crowd out the final answer. + """ + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 3 + + codex = SimpleNamespace(id=56, display_name="Codex", name="Codex", bot=True) + human = SimpleNamespace(id=57, display_name="Alice", name="Alice", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=codex, content="old tool trace 1", msg_id=101), + make_history_message(author=codex, content="old tool trace 2", msg_id=102), + make_history_message(author=codex, content="old tool trace 3", msg_id=103), + make_history_message(author=codex, content="final analysis", msg_id=104), + make_history_message(author=human, content="latest follow-up", msg_id=105), + ], + channel_id=777, + ) + adapter._last_self_message_id["777"] = "100" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 200 + + result = await adapter._fetch_channel_context(channel, before=trigger) + + assert "[Codex [bot]] final analysis" in result + assert "[Alice] latest follow-up" in result + assert "old tool trace 1" not in result + assert "old tool trace 2" not in result + + +@pytest.mark.asyncio +async def test_fetch_channel_context_ignores_stale_cache(adapter, monkeypatch): + """If cached ID is >= trigger ID (stale/future), fall back to cold-start scan.""" + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 50 + + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + recorded_after = {} + + class CacheTrackingChannel(FakeHistoryChannel): + def history(self, *, limit, before, after=None, oldest_first=None): + recorded_after["value"] = after + return super().history( + limit=limit, + before=before, + after=after, + oldest_first=oldest_first, + ) + + channel = CacheTrackingChannel( + [make_history_message(author=human, content="hello", msg_id=50)], + channel_id=777, + ) + + # Cache has a NEWER ID than the trigger — stale/invalid + adapter._last_self_message_id["777"] = "500" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 300 + + result = await adapter._fetch_channel_context(channel, before=trigger) + + assert result == "[Recent channel messages]\n[Alice] hello" + # Cache should have been ignored — after= should be None + assert recorded_after["value"] is None + + +@pytest.mark.asyncio +async def test_discord_shared_channel_backfill_prepends_context(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + adapter.config.extra["group_sessions_per_user"] = False + adapter.config.extra["history_backfill"] = True + adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] context") + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=321), + content=f"<@{bot_user.id}> hello with mention", + mentions=[bot_user], + ) + + await adapter._handle_message(message) + + adapter._fetch_channel_context.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello with mention" + assert event.channel_context == "[Recent channel messages]\n[Alice] context" + + +@pytest.mark.asyncio +async def test_discord_per_user_channel_does_not_backfill(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + adapter.config.extra["group_sessions_per_user"] = True + adapter.config.extra["history_backfill"] = True + adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] context") + + bot_user = adapter._client.user + message = make_message( + channel=FakeTextChannel(channel_id=321), + content=f"<@{bot_user.id}> hello with mention", + mentions=[bot_user], + ) + + await adapter._handle_message(message) + + adapter._fetch_channel_context.assert_not_awaited() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello with mention" + assert event.channel_context is None + + diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index 57a8aefa5e8..b8fd45558cd 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -5,6 +5,7 @@ import pytest from pathlib import Path from unittest.mock import patch, MagicMock from gateway.config import Platform, HomeChannel, GatewayConfig, PlatformConfig +from gateway.platforms.base import MessageEvent from gateway.session import ( SessionSource, SessionStore, @@ -430,6 +431,76 @@ class TestBuildSessionContextPrompt: assert "Multi-user thread" not in prompt +class TestSenderPrefixWithBackfill: + """Regression: sender prefix must not wrap the backfill context block. + + Tests exercise the real GatewayRunner._prepare_inbound_message_text() + method to ensure the [sender_name] prefix applies only to the trigger + message, not the channel_context backfill block. + """ + + @pytest.fixture() + def runner(self): + from gateway.run import GatewayRunner + + r = GatewayRunner.__new__(GatewayRunner) + r.config = GatewayConfig(group_sessions_per_user=False) + r.adapters = {} + r._model = "test-model" + r._base_url = "" + r._has_setup_skill = lambda: False + return r + + @pytest.fixture() + def source(self): + return SessionSource( + platform=Platform.DISCORD, + chat_id="c1", + chat_type="group", + user_name="Alice", + ) + + @pytest.mark.asyncio + async def test_plain_message_gets_prefix(self, runner, source): + """Normal message without backfill gets [sender] prefix.""" + event = MessageEvent(text="hello world", source=source) + result = await runner._prepare_inbound_message_text( + event=event, source=source, history=[], + ) + assert result == "[Alice] hello world" + + @pytest.mark.asyncio + async def test_backfill_prefix_only_on_trigger(self, runner, source): + """Backfill context must NOT get the sender prefix.""" + event = MessageEvent( + text="hello world", + source=source, + channel_context="[Recent channel messages]\n[Bob] some context", + ) + result = await runner._prepare_inbound_message_text( + event=event, source=source, history=[], + ) + assert result.startswith("[Recent channel messages]") + assert "[Alice] [Recent channel messages]" not in result + assert "[New message]\n[Alice] hello world" in result + + @pytest.mark.asyncio + async def test_backfill_preserves_context_block(self, runner, source): + """The backfill block should pass through unchanged — no double-prefixing.""" + context = "[Recent channel messages]\n[Bob] first\n[Charlie [bot]] second" + event = MessageEvent( + text="hey everyone", source=source, channel_context=context, + ) + result = await runner._prepare_inbound_message_text( + event=event, source=source, history=[], + ) + assert result.startswith(context) + assert "[Alice] hey everyone" in result + assert "[Alice] [Bob]" not in result + assert "[Alice] [Charlie" not in result + assert "[Alice] [Recent" not in result + + class TestSessionStoreRewriteTranscript: """Regression: /retry and /undo must persist truncated history to disk.""" diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index a4530148cbf..605e59e2e95 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -437,6 +437,33 @@ Behavior: - If a message arrives inside a thread or forum post and that thread has no explicit entry, Hermes falls back to the parent channel/forum ID. - Prompts are applied ephemerally at runtime, so changing them affects future turns immediately without rewriting past session history. +#### `discord.history_backfill` + +**Type:** boolean — **Default:** `false` + +When enabled, the bot recovers missed channel messages on each `@mention`. With `require_mention: true`, the bot only processes messages that tag it directly — everything else in the channel is invisible. History backfill scans backwards through recent channel history when triggered, collecting messages between the bot's last response and the current mention, and includes them as context. + +This is most useful for **shared sessions** (`group_sessions_per_user: false`) where multiple users contribute to the same conversation and the bot needs to see what happened between turns. + +```yaml +discord: + history_backfill: true +``` + +> **Note:** Messages that arrive *while* the bot is processing (between a trigger and its response) are not captured. This is an accepted simplification — the user can re-send or tag again. + +#### `discord.history_backfill_limit` + +**Type:** integer — **Default:** `50` + +Maximum number of messages to scan backwards when recovering channel context. In practice the scan usually stops much earlier — at the bot's own last message in the channel, which is the natural boundary between turns. This limit is a safety cap for cold starts and long gaps where no prior bot message exists in recent history. + +```yaml +discord: + history_backfill: true + history_backfill_limit: 50 +``` + #### `group_sessions_per_user` **Type:** boolean — **Default:** `true`