Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
165 changes: 165 additions & 0 deletions tests/gateway/test_discord_thread_title_rename.py
Original file line number Diff line number Diff line change
@@ -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()