feat(discord): channel history backfill for multi-user sessions

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)
This commit is contained in:
snav 2026-05-14 01:46:11 -04:00 committed by Teknium
parent ccb5aae0d2
commit e84fe483bc
10 changed files with 596 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = []

View file

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

View file

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

View file

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

View file

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

View file

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