From ecf8082ac4b7dec83371452488ea788cc0455ff0 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 12 May 2026 10:41:23 +0200 Subject: [PATCH] feat(discord): auto-rename threads from session titles --- gateway/platforms/discord.py | 19 +- gateway/run.py | 39 +++++ .../test_discord_thread_title_rename.py | 165 ++++++++++++++++++ 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_discord_thread_title_rename.py diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 1817ece173db..a4003e85af06 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3779,7 +3779,7 @@ async def create_handoff_thread( send = getattr(parent, "send", None) if send is None: return None - seed_msg = await send(f"\U0001f9f5 Hermes handoff: **{thread_name}**") + seed_msg = await send(f"🧵 Hermes handoff: **{thread_name}**") thread = await seed_msg.create_thread( name=thread_name, auto_archive_duration=1440, @@ -3793,6 +3793,23 @@ async def create_handoff_thread( ) return None + async def rename_thread(self, thread_id: int, new_name: str) -> bool: + """Best-effort rename of a Discord thread to match the auto-generated session title.""" + if not self._client or not DISCORD_AVAILABLE: + return False + try: + thread = self._client.get_channel(thread_id) or await self._client.fetch_channel(thread_id) + if thread and hasattr(thread, "edit"): + trimmed_name = (new_name or "").strip()[:100] + if not trimmed_name: + return False + await thread.edit(name=trimmed_name) # Discord caps at 100 chars + logger.debug("[%s] Renamed thread %s to '%s'", self.name, thread_id, trimmed_name) + return True + except Exception: + logger.debug("[%s] Failed to rename thread %s", self.name, thread_id, exc_info=True) + return False + async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", diff --git a/gateway/run.py b/gateway/run.py index 559adae89bf0..0569432dafb0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11015,6 +11015,39 @@ def _log_rename_failure(fut) -> None: future.add_done_callback(_log_rename_failure) + def _schedule_discord_thread_title_rename( + self, + source: SessionSource, + title: str, + ) -> None: + """Schedule a thread rename from the auto-title background thread.""" + if not title or source.platform != Platform.DISCORD or not source.thread_id: + return + adapter = self.adapters.get(Platform.DISCORD) + if adapter is None or not hasattr(adapter, "rename_thread"): + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = getattr(self, "_gateway_loop", None) + if loop is None or loop.is_closed(): + return + try: + thread_id = int(source.thread_id) + except (ValueError, TypeError): + return + future = asyncio.run_coroutine_threadsafe( + adapter.rename_thread(thread_id, title), + loop, + ) + def _log_rename_failure(fut) -> None: + try: + fut.result() + except Exception: + logger.debug("Discord thread title rename failed", exc_info=True) + + future.add_done_callback(_log_rename_failure) + _TELEGRAM_CAPABILITY_HINT_COOLDOWN_S = 300.0 def _should_send_telegram_capability_hint(self, source: SessionSource) -> bool: @@ -15362,6 +15395,12 @@ def _approval_notify_sync(approval_data: dict) -> None: effective_session_id, title, ) + elif source.platform == Platform.DISCORD and source.thread_id: + copied_source = dataclasses.replace(source) + maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_discord_thread_title_rename( + copied_source, + title, + ) maybe_auto_title( self._session_db, effective_session_id, diff --git a/tests/gateway/test_discord_thread_title_rename.py b/tests/gateway/test_discord_thread_title_rename.py new file mode 100644 index 000000000000..5642473fafdb --- /dev/null +++ b/tests/gateway/test_discord_thread_title_rename.py @@ -0,0 +1,165 @@ +"""Tests for Discord thread renaming after auto-generated session titles.""" + +from __future__ import annotations + +import asyncio +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.session import SessionSource + + +def _ensure_discord_mock() -> None: + """Install a mock discord module when discord.py isn't available.""" + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) + discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3) + discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from gateway.run import GatewayRunner # noqa: E402 + + +def _make_source(*, thread_id: str | None = "456") -> SessionSource: + return SessionSource( + platform=Platform.DISCORD, + user_id="user-1", + chat_id="channel-1", + user_name="tester", + chat_type="thread", + thread_id=thread_id, + ) + + +def _make_runner(adapter: object) -> GatewayRunner: + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")} + ) + runner.adapters = {Platform.DISCORD: adapter} + runner._gateway_loop = MagicMock() + runner._gateway_loop.is_closed.return_value = False + return runner + + +@pytest.mark.asyncio +async def test_discord_adapter_rename_thread_edits_name_and_truncates_to_100_chars(): + config = PlatformConfig(enabled=True, token="fake-token") + adapter = DiscordAdapter(config) + + thread = MagicMock() + thread.edit = AsyncMock() + + adapter._client = MagicMock() + adapter._client.get_channel.return_value = thread + + long_name = "x" * 140 + ok = await adapter.rename_thread(123456789, long_name) + + assert ok is True + adapter._client.fetch_channel.assert_not_called() + thread.edit.assert_awaited_once_with(name="x" * 100) + + +@pytest.mark.asyncio +async def test_discord_adapter_rename_thread_fetches_when_cache_misses(): + config = PlatformConfig(enabled=True, token="fake-token") + adapter = DiscordAdapter(config) + + thread = MagicMock() + thread.edit = AsyncMock() + + adapter._client = MagicMock() + adapter._client.get_channel.return_value = None + adapter._client.fetch_channel = AsyncMock(return_value=thread) + + ok = await adapter.rename_thread(777, "Readable Session") + + assert ok is True + adapter._client.fetch_channel.assert_awaited_once_with(777) + thread.edit.assert_awaited_once_with(name="Readable Session") + + +@pytest.mark.asyncio +async def test_schedule_discord_thread_title_rename_submits_coroutine(monkeypatch): + adapter = MagicMock() + adapter.rename_thread = AsyncMock(return_value=True) + runner = _make_runner(adapter) + + captured: dict[str, object] = {} + + class _FakeFuture: + def __init__(self, task: asyncio.Task): + self._task = task + + def add_done_callback(self, cb): + self._task.add_done_callback(cb) + + def result(self): + return self._task.result() + + def _fake_run_coroutine_threadsafe(coro, loop): + captured["loop"] = loop + task = loop.create_task(coro) + captured["task"] = task + return _FakeFuture(task) + + monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", _fake_run_coroutine_threadsafe) + + runner._schedule_discord_thread_title_rename(_make_source(thread_id="456"), "Readable Session") + await captured["task"] + + assert captured["loop"] is asyncio.get_running_loop() + adapter.rename_thread.assert_awaited_once_with(456, "Readable Session") + + +def test_schedule_discord_thread_title_rename_ignores_invalid_thread_id(monkeypatch): + adapter = MagicMock() + adapter.rename_thread = AsyncMock(return_value=True) + runner = _make_runner(adapter) + + called = False + + def _fake_run_coroutine_threadsafe(coro, loop): + nonlocal called + called = True + raise AssertionError("should not schedule for invalid thread ids") + + monkeypatch.setattr(asyncio, "run_coroutine_threadsafe", _fake_run_coroutine_threadsafe) + + runner._schedule_discord_thread_title_rename(_make_source(thread_id="not-a-number"), "Readable Session") + + assert called is False + adapter.rename_thread.assert_not_called()