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)
This commit is contained in:
committed by
Mark S.
parent
66fa864f19
commit
ec991b468b
@@ -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)
|
||||
|
||||
80
tests/gateway/test_discord_auto_thread_rename_pin.py
Normal file
80
tests/gateway/test_discord_auto_thread_rename_pin.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user