From ec991b468b52326bb9526f3dbe55a47239c4e28c Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Fri, 2 Oct 2026 15:01:08 +0900 Subject: [PATCH] fix(discord): Hermes's own auto-thread title rename keeps the pinned prompt chat_name keys the pinned session-context prompt, and the title lane renames a new auto-thread once the LLM title arrives, normally before the user's second message. Turn 2 read the new name and re-rendered the already-sent prompt, a prompt-cache miss on every auto-threaded conversation on the default config. rename_thread now records the name it replaced and the name it set for a guarded semantic rename, and _format_thread_chat_name keeps the replaced name while the thread still carries Hermes's title. A rename by anyone else no longer matches and re-renders as before. (cherry picked from commit b57858e031b8af5f6fa3fa8d567907c16fafda2b) --- plugins/platforms/discord/adapter.py | 16 ++++ .../test_discord_auto_thread_rename_pin.py | 80 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/gateway/test_discord_auto_thread_rename_pin.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 8228bdf1a9..eec0d0afe9 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -89,6 +89,7 @@ _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5 _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0 # Discord caps global slash commands at 100/app; exceeding it fails the ENTIRE sync (error 30032). _DISCORD_MAX_APP_COMMANDS = 100 +_SEMANTIC_THREAD_RENAMES_MAX = 2000 # Native slash commands (registered before COMMAND_REGISTRY/plugins so they survive the 100 cap): # (discord name, description, [(arg, type, default-or-_REQUIRED, arg description, # [(choice label, value), ...] or None)], command-text template, follow-up message) @@ -1113,6 +1114,9 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): self._voice_fx_cfg: Dict[str, Any] = self._load_voice_fx_config() # Threads the bot participated in (no @mention needed there); persisted across restarts. self._threads = ThreadParticipationTracker("discord") + # thread id -> (name it replaced, name it set) for Hermes's own semantic renames; see + # _format_thread_chat_name. In memory: after a restart the next turn re-renders once. + self._semantic_thread_renames: Dict[str, Tuple[str, str]] = {} # Persistent typing loops per channel (DMs don't reliably show bot typing events). self._typing_tasks: Dict[str, asyncio.Task] = {} self._bot_task: Optional[asyncio.Task] = None @@ -5452,6 +5456,12 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): return False try: await edit(name=cleaned, reason="Hermes semantic session title") + if only_if_current_name is not None: + renames = self._semantic_thread_renames + renames.pop(str(thread_id_int), None) + renames[str(thread_id_int)] = (current_name, cleaned) + while len(renames) > _SEMANTIC_THREAD_RENAMES_MAX: + renames.pop(next(iter(renames))) logger.info( "[%s] Renamed Discord thread %s from %r to %r", self.name, thread_id, current_name, cleaned, @@ -5795,6 +5805,12 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter): def _format_thread_chat_name(self, thread: Any) -> str: """Build a readable chat name for thread-like Discord channels, including forum context when available.""" thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread")) + # chat_name keys the pinned session-context prompt, and Hermes's own title rename lands + # between a new thread's first and second turns: keep the name the turns were pinned under. + # A rename by anyone else no longer matches what Hermes set, so it still re-renders. + renamed = self._semantic_thread_renames.get(str(getattr(thread, "id", ""))) + if renamed and renamed[1] == thread_name: + thread_name = renamed[0] parent = getattr(thread, "parent", None) guild = getattr(thread, "guild", None) or getattr(parent, "guild", None) guild_name = getattr(guild, "name", None) diff --git a/tests/gateway/test_discord_auto_thread_rename_pin.py b/tests/gateway/test_discord_auto_thread_rename_pin.py new file mode 100644 index 0000000000..7613c2c7bf --- /dev/null +++ b/tests/gateway/test_discord_auto_thread_rename_pin.py @@ -0,0 +1,80 @@ +"""Hermes's own semantic rename of an auto-created thread keeps the thread's pinned prompt. + +``chat_name`` is part of the pinned session-context prompt's key. The title lane renames a new +auto-thread once the LLM title arrives, normally before the user's second message, so turn 2 read +the new name and re-rendered the already-sent prompt: a prompt-cache miss on turn 2 of every +auto-threaded conversation on the default config. A rename by anyone else is a real metadata +change and still re-renders. +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import gateway.run as gateway_run +import plugins.platforms.discord.adapter as discord_platform +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.session import build_session_context +from plugins.platforms.discord.adapter import DiscordAdapter + + +class _Text: + def __init__(self, channel_id, name="ops"): + self.id, self.name, self.topic = channel_id, name, None + self.guild = SimpleNamespace(id=1, name="Hermes Server") + + +class _Thread: + def __init__(self, channel_id, parent, name): + self.id, self.name, self.parent, self.parent_id = channel_id, name, parent, parent.id + self.guild = parent.guild + + async def edit(self, *, name, reason=None): + self.name = name + + +_USER = SimpleNamespace(id=42, display_name="Alice", name="alice") + + +def _message(channel, message_id): + return SimpleNamespace( + id=message_id, content="what broke?", mentions=[], attachments=[], reference=None, + created_at=datetime.now(timezone.utc), channel=channel, author=_USER) + + +@pytest.mark.asyncio +async def test_hermes_title_rename_keeps_the_pin_and_a_human_rename_does_not(monkeypatch): + monkeypatch.setattr(discord_platform.discord, "Thread", _Thread, raising=False) + monkeypatch.setattr(discord_platform, "DISCORD_AVAILABLE", True) + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") + parent = _Text(700) + thread = _Thread(800, parent, name="what broke?") + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fake")) + adapter._client = SimpleNamespace(user=SimpleNamespace(id=999), get_channel=lambda _id: thread) + adapter._text_batch_delay_seconds = 0 + adapter._discord_history_backfill = lambda: False + adapter._auto_create_thread = AsyncMock(return_value=thread) + adapter.handle_message = AsyncMock() + + runner = object.__new__(gateway_run.GatewayRunner) + config = GatewayConfig(platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="x")}) + + async def turn(channel, message_id): + await adapter._handle_message(_message(channel, message_id)) + source = adapter.handle_message.await_args.args[0].source + assert source.chat_id == "800" + return runner._pinned_session_context_prompt(build_session_context(source, config), False, "k") + + first = await turn(parent, 100) + # The title lane's call, as gateway/run_topics.py makes it for a native auto-thread. + assert await adapter.rename_thread("800", "Database outage", only_if_current_name="what broke?") + assert thread.name == "Database outage" + assert await turn(thread, 101) == first + + thread.name = "Renamed by a moderator" + after_human_rename = await turn(thread, 102) + assert after_human_rename != first + assert "Renamed by a moderator" in after_human_rename