From fc62ae28db865a7d5a7f44ee578c388006e82fc5 Mon Sep 17 00:00:00 2001 From: menelsystemsbot <146561869+menelsystemsbot@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:05:01 -0700 Subject: [PATCH] feat: sync Discord thread names with session titles --- gateway/platforms/base.py | 19 +++ gateway/run.py | 150 +++--------------- gateway/slash_commands.py | 4 +- plugins/platforms/discord/adapter.py | 51 ++++++ plugins/platforms/telegram/adapter.py | 62 ++++++++ .../test_discord_thread_title_rename.py | 126 +++++++++++++++ tests/gateway/test_telegram_topic_mode.py | 95 ++++++----- tests/gateway/test_title_command.py | 45 +++++- 8 files changed, 370 insertions(+), 182 deletions(-) create mode 100644 tests/gateway/test_discord_thread_title_rename.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 8c447a7a2bf2..407a99d91f1c 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2368,6 +2368,25 @@ async def create_handoff_thread( """ return None + async def rename_conversation( + self, + source: Any, + name: str, + *, + session_id: Optional[str] = None, + session_db: Any = None, + ) -> bool: + """Rename the platform-visible conversation container for ``source``. + + This is the title-sync counterpart to ``create_handoff_thread``: when + Hermes names a session, thread/topic-capable adapters can reflect that + title in the visible Discord thread, Telegram topic, etc. + + Default implementation returns ``False`` — adapters that can safely + rename the active conversation override this. + """ + return False + async def edit_message( self, diff --git a/gateway/run.py b/gateway/run.py index 08415eb8629d..b78acd51633b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11276,135 +11276,37 @@ async def _send_telegram_topic_setup_image(self, source: SessionSource) -> None: except Exception: logger.debug("Failed to send Telegram topic setup image", exc_info=True) - def _sanitize_telegram_topic_title(self, title: str) -> str: - """Return a Bot API-safe forum topic name from a generated session title.""" - cleaned = re.sub(r"\s+", " ", str(title or "")).strip() - if not cleaned: - return "Hermes Chat" - # Telegram forum topic names are short (currently 1-128 chars). Keep - # extra room for multi-byte titles and avoid trailing ellipsis churn. - if len(cleaned) > 120: - cleaned = cleaned[:117].rstrip() + "..." - return cleaned - - async def _rename_telegram_topic_for_session_title( + async def _rename_visible_conversation_for_session_title( self, source: SessionSource, session_id: str, title: str, ) -> None: - """Best-effort rename of a Telegram DM topic when Hermes auto-titles a session.""" - if not self._is_telegram_topic_lane(source) or not source.chat_id or not source.thread_id: - return - - # Operator can fully disable per-topic auto-rename via - # extra.disable_topic_auto_rename. Useful when topics are managed - # by the user (ad-hoc Threaded Mode) and auto-rename would - # overwrite their chosen names every time the auto-title fires. - if self._telegram_topic_auto_rename_disabled(source): - return - - # Skip rename when the topic is operator-declared via - # extra.dm_topics. Those topics have fixed names chosen by the - # operator (plus optional skill binding); auto-renaming would - # silently mutate operator config. - # - # Check the class, not the instance — getattr() on MagicMock - # auto-creates attributes, so `hasattr(adapter, "_get_dm_topic_info")` - # would return True for every test double. + """Best-effort sync of a session title to the platform-visible thread/topic.""" adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None - if adapter is not None: - get_info = getattr(type(adapter), "_get_dm_topic_info", None) - if callable(get_info): - try: - operator_topic = get_info(adapter, str(source.chat_id), str(source.thread_id)) - except Exception: - operator_topic = None - # Only treat dict-shaped returns as operator-declared; a - # bare MagicMock or other sentinel shouldn't count. - if isinstance(operator_topic, dict): - return - - session_db = getattr(self, "_session_db", None) - if session_db is not None: - try: - binding = session_db.get_telegram_topic_binding( - chat_id=str(source.chat_id), - thread_id=str(source.thread_id), - ) - if binding and str(binding.get("session_id") or "") != str(session_id): - return - except Exception: - logger.debug("Failed to verify Telegram topic binding before rename", exc_info=True) - return - if adapter is None: return - topic_name = self._sanitize_telegram_topic_title(title) + rename_conversation = getattr(adapter, "rename_conversation", None) + if rename_conversation is None: + return try: - rename_topic = getattr(adapter, "rename_dm_topic", None) - if rename_topic is not None: - await rename_topic( - chat_id=str(source.chat_id), - thread_id=str(source.thread_id), - name=topic_name, - ) - return - - bot = getattr(adapter, "_bot", None) - edit_forum_topic = getattr(bot, "edit_forum_topic", None) if bot is not None else None - if edit_forum_topic is None: - edit_forum_topic = getattr(bot, "editForumTopic", None) if bot is not None else None - if edit_forum_topic is None: - return - try: - await edit_forum_topic( - chat_id=int(source.chat_id), - message_thread_id=int(source.thread_id), - name=topic_name, - ) - except (TypeError, ValueError): - await edit_forum_topic( - chat_id=source.chat_id, - message_thread_id=source.thread_id, - name=topic_name, - ) + await rename_conversation( + source, + title, + session_id=session_id, + session_db=getattr(self, "_session_db", None), + ) except Exception: - logger.debug("Failed to rename Telegram topic for auto-generated title", exc_info=True) - - def _telegram_topic_auto_rename_disabled(self, source: SessionSource) -> bool: - """Return True when operator disabled per-topic auto-rename for this Telegram chat. + logger.debug("Failed to rename visible conversation for session title", exc_info=True) - Controlled via ``gateway.platforms.telegram.extra.disable_topic_auto_rename``. - Default is False (auto-rename enabled, preserves prior behaviour). - """ - platform_cfg = ( - self.config.platforms.get(source.platform) - if getattr(self, "config", None) and getattr(self.config, "platforms", None) - else None - ) - if platform_cfg is None: - return False - extra = getattr(platform_cfg, "extra", None) or {} - value = extra.get("disable_topic_auto_rename") - if value is None: - return False - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "on"} - return bool(value) - - def _schedule_telegram_topic_title_rename( + def _schedule_visible_conversation_title_rename( self, source: SessionSource, session_id: str, title: str, ) -> None: - """Schedule a topic rename from the auto-title background thread.""" - if not title or not self._is_telegram_topic_lane(source): - return - if self._telegram_topic_auto_rename_disabled(source): + """Schedule a platform-visible title sync from the auto-title background thread.""" + if not title: return try: loop = asyncio.get_running_loop() @@ -11417,18 +11319,19 @@ def _schedule_telegram_topic_title_rename( except Exception: copied_source = source future = safe_schedule_threadsafe( - self._rename_telegram_topic_for_session_title(copied_source, session_id, title), + self._rename_visible_conversation_for_session_title(copied_source, session_id, title), loop, logger=logger, - log_message="Telegram topic title rename failed to schedule", + log_message="Visible conversation title rename failed to schedule", ) if future is None: return + def _log_rename_failure(fut) -> None: try: fut.result() except Exception: - logger.debug("Telegram topic title rename failed", exc_info=True) + logger.debug("Visible conversation title rename failed", exc_info=True) future.add_done_callback(_log_rename_failure) @@ -11977,8 +11880,8 @@ def _is_telegram_dm_topic_target( # auto-creates a callable child for any attribute, so an instance-level # lookup would report a DM topic for every test double. Only a # dict-shaped return counts as an operator-declared topic — a bare - # MagicMock or other sentinel must not. Mirrors the guard in - # _rename_telegram_topic_for_session_title. + # MagicMock or other sentinel must not. Mirrors the guard in the + # Telegram adapter's visible conversation rename capability. if adapter is not None and chat_id: get_dm_topic_info = getattr(type(adapter), "_get_dm_topic_info", None) if callable(get_dm_topic_info): @@ -15995,12 +15898,11 @@ def _title_failure_cb(task: str, exc: BaseException) -> None: "api_mode": getattr(agent, "api_mode", None), } if agent else None, } - if self._is_telegram_topic_lane(source): - maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_telegram_topic_title_rename( - source, - effective_session_id, - title, - ) + maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_visible_conversation_title_rename( + source, + effective_session_id, + title, + ) maybe_auto_title( self._session_db, effective_session_id, diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index dbfd778daf9b..e3c72f85727f 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2853,14 +2853,14 @@ async def _handle_title_command(self, event: MessageEvent) -> str: # and the topic kept its auto-assigned name. No-ops off # Telegram topic lanes and when auto-rename is disabled. schedule_rename = getattr( - self, "_schedule_telegram_topic_title_rename", None + self, "_schedule_visible_conversation_title_rename", None ) if callable(schedule_rename): try: schedule_rename(source, session_id, sanitized) except Exception: logger.debug( - "Failed to rename Telegram topic from /title", + "Failed to rename visible conversation from /title", exc_info=True, ) return t("gateway.title.set_to", title=sanitized) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index accede61a234..d4761c7f17a6 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -4558,6 +4558,57 @@ async def _create_thread( # Auto-thread helpers # ------------------------------------------------------------------ + def _sanitize_conversation_title(self, name: str) -> str: + cleaned = re.sub(r"\s+", " ", str(name or "")).strip() + if not cleaned: + return "Hermes Chat" + if len(cleaned) > 100: + cleaned = cleaned[:97].rstrip() + "..." + return cleaned + + async def rename_conversation( + self, + source: Any, + name: str, + *, + session_id: Optional[str] = None, + session_db: Any = None, + ) -> bool: + """Rename the Discord thread/channel backing the current session.""" + if getattr(source, "chat_type", None) != "thread": + return False + thread_id = str(getattr(source, "thread_id", None) or getattr(source, "chat_id", "") or "") + if not thread_id: + return False + return await self.rename_thread(thread_id, self._sanitize_conversation_title(name)) + + async def rename_thread(self, thread_id: str, name: str) -> bool: + """Best-effort rename for an existing Discord thread/channel.""" + if not self._client or not DISCORD_AVAILABLE: + return False + try: + tid = int(thread_id) + except (TypeError, ValueError): + return False + + try: + thread = self._client.get_channel(tid) + if thread is None: + thread = await self._client.fetch_channel(tid) + except Exception as exc: + logger.debug("[%s] Could not resolve Discord thread %s for rename: %s", self.name, thread_id, exc) + return False + + edit = getattr(thread, "edit", None) + if edit is None: + return False + try: + await edit(name=name, reason="Hermes session auto-title") + return True + except Exception as exc: + logger.debug("[%s] Failed to rename Discord thread %s: %s", self.name, thread_id, exc) + return False + async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: """Create a thread from a user message for auto-threading. diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 2a1054b1d2e7..e05d543557cd 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -1791,6 +1791,68 @@ async def ensure_dm_topic(self, chat_id: str, topic_name: str, force_create: boo self._persist_dm_topic_thread_id(chat_id_int, name, int(thread_id), replace_existing=force_create) return str(thread_id) + def _sanitize_conversation_title(self, name: str) -> str: + cleaned = re.sub(r"\s+", " ", str(name or "")).strip() + if not cleaned: + return "Hermes Chat" + if len(cleaned) > 120: + cleaned = cleaned[:117].rstrip() + "..." + return cleaned + + def _topic_auto_rename_disabled(self) -> bool: + value = (getattr(self.config, "extra", None) or {}).get("disable_topic_auto_rename") + if value is None: + return False + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + async def rename_conversation( + self, + source: Any, + name: str, + *, + session_id: Optional[str] = None, + session_db: Any = None, + ) -> bool: + """Rename the Telegram DM topic backing the current session.""" + chat_id = getattr(source, "chat_id", None) + thread_id = getattr(source, "thread_id", None) + if getattr(source, "chat_type", None) != "dm" or not chat_id or not thread_id: + return False + if str(thread_id) in {"", "1"}: + return False + if self._topic_auto_rename_disabled(): + return False + + try: + operator_topic = self._get_dm_topic_info(str(chat_id), str(thread_id)) + except Exception: + operator_topic = None + if isinstance(operator_topic, dict): + return False + + if session_db is not None and session_id is not None: + try: + binding = session_db.get_telegram_topic_binding( + chat_id=str(chat_id), + thread_id=str(thread_id), + ) + if binding and str(binding.get("session_id") or "") != str(session_id): + return False + except Exception: + logger.debug("[%s] Failed to verify Telegram topic binding before rename", self.name, exc_info=True) + return False + + await self.rename_dm_topic( + chat_id=chat_id, + thread_id=thread_id, + name=self._sanitize_conversation_title(name), + ) + return True + async def rename_dm_topic( self, chat_id: int, 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..a9097b7570af --- /dev/null +++ b/tests/gateway/test_discord_thread_title_rename.py @@ -0,0 +1,126 @@ +"""Tests for syncing session titles to Discord thread names.""" + +import asyncio +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 _make_discord_thread_source(*, thread_id: str = "4242") -> SessionSource: + return SessionSource( + platform=Platform.DISCORD, + chat_id=thread_id, + chat_name="old thread name", + chat_type="thread", + user_id="12345", + user_name="tester", + thread_id=thread_id, + parent_chat_id="999", + ) + + +def _make_runner(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")} + ) + adapter = SimpleNamespace(rename_conversation=AsyncMock(return_value=True)) + runner.adapters = {Platform.DISCORD: adapter} + runner._session_db = MagicMock() + return runner + + +@pytest.mark.asyncio +async def test_auto_generated_title_renames_visible_conversation_via_adapter(): + runner = _make_runner() + source = _make_discord_thread_source(thread_id="4242") + + await runner._rename_visible_conversation_for_session_title( + source, + "sess-discord", + " Discord Thread Naming UX ", + ) + + runner.adapters[Platform.DISCORD].rename_conversation.assert_awaited_once_with( + source, + " Discord Thread Naming UX ", + session_id="sess-discord", + session_db=runner._session_db, + ) + + +@pytest.mark.asyncio +async def test_schedule_visible_conversation_rename_uses_running_loop(): + runner = _make_runner() + source = _make_discord_thread_source(thread_id="777") + seen = [] + + async def _spy(copied_source, session_id, title): + seen.append((copied_source, session_id, title)) + + runner._rename_visible_conversation_for_session_title = _spy + + runner._schedule_visible_conversation_title_rename( + source, + "sess-discord", + "Auto Generated Title", + ) + await asyncio.sleep(0.05) + + assert len(seen) == 1 + copied_source, session_id, title = seen[0] + assert copied_source is not source + assert copied_source.thread_id == "777" + assert session_id == "sess-discord" + assert title == "Auto Generated Title" + + +def test_discord_adapter_sanitizes_thread_title(): + from plugins.platforms.discord.adapter import DiscordAdapter + + adapter = object.__new__(DiscordAdapter) + + title = adapter._sanitize_conversation_title(" " + "A" * 140 + " ") + + assert len(title) == 100 + assert title.endswith("...") + + +@pytest.mark.asyncio +async def test_discord_adapter_rename_conversation_edits_fetched_thread(): + from plugins.platforms.discord.adapter import DiscordAdapter + + adapter = object.__new__(DiscordAdapter) + fake_thread = SimpleNamespace(edit=AsyncMock()) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=None), + fetch_channel=AsyncMock(return_value=fake_thread), + ) + + source = _make_discord_thread_source(thread_id="4242") + assert await adapter.rename_conversation(source, "Readable Session Title") is True + + adapter._client.fetch_channel.assert_awaited_once_with(4242) + fake_thread.edit.assert_awaited_once_with( + name="Readable Session Title", + reason="Hermes session auto-title", + ) + + +@pytest.mark.asyncio +async def test_discord_adapter_rename_conversation_skips_non_thread(): + from plugins.platforms.discord.adapter import DiscordAdapter + + adapter = object.__new__(DiscordAdapter) + adapter._client = SimpleNamespace() + source = _make_discord_thread_source() + source.chat_type = "group" + source.thread_id = None + + assert await adapter.rename_conversation(source, "Should Not Rename") is False diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index c887153508c7..a2e82e3ebd63 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -157,6 +157,19 @@ def _switch_session(session_key, target_session_id): return runner +def _make_rename_capable_telegram_adapter(config): + from plugins.platforms.telegram.adapter import TelegramAdapter + + adapter = object.__new__(TelegramAdapter) + adapter.config = config + adapter._bot = SimpleNamespace(edit_forum_topic=AsyncMock()) + adapter._dm_topics = {} + adapter._dm_topics_config = [] + adapter._get_dm_topic_info = MagicMock(return_value=None) + adapter.rename_dm_topic = AsyncMock() + return adapter + + @pytest.mark.asyncio async def test_root_telegram_dm_prompt_is_system_lobby_when_topic_mode_enabled(monkeypatch): import gateway.run as gateway_run @@ -845,15 +858,16 @@ async def test_auto_generated_title_renames_bound_telegram_topic(tmp_path): session_id="sess-topic", ) runner = _make_runner(session_db=db) - runner._telegram_topic_mode_enabled = lambda source: True + adapter = _make_rename_capable_telegram_adapter(runner.config.platforms[Platform.TELEGRAM]) + runner.adapters[Platform.TELEGRAM] = adapter - await runner._rename_telegram_topic_for_session_title( + await runner._rename_visible_conversation_for_session_title( _make_source(thread_id="42"), "sess-topic", " Build Telegram Topic UX ", ) - runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_awaited_once_with( + adapter.rename_dm_topic.assert_awaited_once_with( chat_id="208214988", thread_id="42", name="Build Telegram Topic UX", @@ -873,15 +887,16 @@ async def test_auto_generated_title_does_not_rename_topic_bound_to_other_session session_id="sess-other", ) runner = _make_runner(session_db=db) - runner._telegram_topic_mode_enabled = lambda source: True + adapter = _make_rename_capable_telegram_adapter(runner.config.platforms[Platform.TELEGRAM]) + runner.adapters[Platform.TELEGRAM] = adapter - await runner._rename_telegram_topic_for_session_title( + await runner._rename_visible_conversation_for_session_title( _make_source(thread_id="42"), "sess-topic", "Wrong Session Title", ) - runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_not_called() + adapter.rename_dm_topic.assert_not_called() @pytest.mark.asyncio @@ -898,29 +913,17 @@ async def test_operator_declared_topic_is_not_auto_renamed(tmp_path): session_id="sess-topic", ) runner = _make_runner(session_db=db) - runner._telegram_topic_mode_enabled = lambda source: True - - # Give the adapter a concrete class with _get_dm_topic_info so the - # class-based lookup in _rename_telegram_topic_for_session_title - # actually finds it (a MagicMock auto-attr would be skipped). - class _FakeAdapter: - def _get_dm_topic_info(self, chat_id, thread_id): - return {"name": "Research", "skill": "arxiv"} - - async def rename_dm_topic(self, **kwargs): - return None - - fake = _FakeAdapter() - fake.rename_dm_topic = AsyncMock() - runner.adapters[Platform.TELEGRAM] = fake + adapter = _make_rename_capable_telegram_adapter(runner.config.platforms[Platform.TELEGRAM]) + adapter._get_dm_topic_info = MagicMock(return_value={"name": "Research", "skill": "arxiv"}) + runner.adapters[Platform.TELEGRAM] = adapter - await runner._rename_telegram_topic_for_session_title( + await runner._rename_visible_conversation_for_session_title( _make_source(thread_id="17585"), "sess-topic", "Auto-generated title", ) - fake.rename_dm_topic.assert_not_called() + adapter.rename_dm_topic.assert_not_called() @pytest.mark.asyncio @@ -937,69 +940,61 @@ async def test_disable_topic_auto_rename_extra_skips_rename(tmp_path): session_id="sess-topic", ) runner = _make_runner(session_db=db) - runner._telegram_topic_mode_enabled = lambda source: True + adapter = _make_rename_capable_telegram_adapter(runner.config.platforms[Platform.TELEGRAM]) + runner.adapters[Platform.TELEGRAM] = adapter # Flip the operator switch. runner.config.platforms[Platform.TELEGRAM].extra["disable_topic_auto_rename"] = True - await runner._rename_telegram_topic_for_session_title( + await runner._rename_visible_conversation_for_session_title( _make_source(thread_id="42"), "sess-topic", "Auto-generated title", ) - runner.adapters[Platform.TELEGRAM].rename_dm_topic.assert_not_called() + adapter.rename_dm_topic.assert_not_called() @pytest.mark.asyncio -async def test_schedule_topic_rename_respects_disable_flag(tmp_path): - """The scheduling entry-point must also honour disable_topic_auto_rename.""" +async def test_schedule_visible_rename_delegates_disable_flag_to_adapter(tmp_path): + """The shared scheduler delegates platform-specific rename policy to adapters.""" db = SessionDB(db_path=tmp_path / "state.db") runner = _make_runner(session_db=db) - runner._telegram_topic_mode_enabled = lambda source: True + adapter = _make_rename_capable_telegram_adapter(runner.config.platforms[Platform.TELEGRAM]) + runner.adapters[Platform.TELEGRAM] = adapter runner.config.platforms[Platform.TELEGRAM].extra["disable_topic_auto_rename"] = "yes" - # If the flag is honoured we never schedule the coroutine, so - # _rename_telegram_topic_for_session_title is never invoked. - called = False - - async def _spy(*args, **kwargs): - nonlocal called - called = True - - runner._rename_telegram_topic_for_session_title = _spy - - runner._schedule_telegram_topic_title_rename( + runner._schedule_visible_conversation_title_rename( _make_source(thread_id="42"), "sess-topic", "Auto-generated title", ) - # Give any (incorrectly scheduled) coroutine a chance to run. + # Give the scheduled coroutine a chance to run. import asyncio - await asyncio.sleep(0) - assert called is False + await asyncio.sleep(0.05) + + adapter.rename_dm_topic.assert_not_called() def test_telegram_topic_auto_rename_disabled_string_truthy(tmp_path): """Common truthy string forms ('1', 'true', 'on', 'yes') must disable rename.""" - db = SessionDB(db_path=tmp_path / "state.db") - runner = _make_runner(session_db=db) - source = _make_source(thread_id="42") + runner = _make_runner(session_db=SessionDB(db_path=tmp_path / "state.db")) + adapter = _make_rename_capable_telegram_adapter(runner.config.platforms[Platform.TELEGRAM]) cfg_extra = runner.config.platforms[Platform.TELEGRAM].extra for value in ("1", "true", "TRUE", "yes", "on"): cfg_extra["disable_topic_auto_rename"] = value - assert runner._telegram_topic_auto_rename_disabled(source) is True, value + assert adapter._topic_auto_rename_disabled() is True, value for value in ("0", "false", "no", "off", "", None): cfg_extra["disable_topic_auto_rename"] = value - assert runner._telegram_topic_auto_rename_disabled(source) is False, value + assert adapter._topic_auto_rename_disabled() is False, value # Explicit bools still work. cfg_extra["disable_topic_auto_rename"] = True - assert runner._telegram_topic_auto_rename_disabled(source) is True + assert adapter._topic_auto_rename_disabled() is True cfg_extra["disable_topic_auto_rename"] = False - assert runner._telegram_topic_auto_rename_disabled(source) is False + assert adapter._topic_auto_rename_disabled() is False def test_general_topic_is_treated_as_root_lobby(tmp_path): diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py index 168fc1e708c2..857a53aca674 100644 --- a/tests/gateway/test_title_command.py +++ b/tests/gateway/test_title_command.py @@ -14,14 +14,22 @@ from gateway.session import SessionSource -def _make_event(text="/title", platform=Platform.TELEGRAM, - user_id="12345", chat_id="67890"): +def _make_event( + text="/title", + platform=Platform.TELEGRAM, + user_id="12345", + chat_id="67890", + chat_type="dm", + thread_id=None, +): """Build a MessageEvent for testing.""" source = SessionSource( platform=platform, user_id=user_id, chat_id=chat_id, user_name="testuser", + chat_type=chat_type, + thread_id=thread_id, ) return MessageEvent(text=text, source=source) @@ -173,17 +181,42 @@ async def test_set_title_propagates_to_telegram_topic_rename(self, tmp_path): db.create_session("test_session_123", "telegram") runner = _make_runner(session_db=db) - runner._schedule_telegram_topic_title_rename = MagicMock() + runner._schedule_visible_conversation_title_rename = MagicMock() event = _make_event(text="/title My Topic Name") result = await runner._handle_title_command(event) assert "My Topic Name" in result - runner._schedule_telegram_topic_title_rename.assert_called_once_with( + runner._schedule_visible_conversation_title_rename.assert_called_once_with( event.source, "test_session_123", "My Topic Name" ) db.close() + @pytest.mark.asyncio + async def test_set_title_propagates_to_discord_thread_rename(self, tmp_path): + """/title also renames the visible Discord thread, not just the DB.""" + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("test_session_123", "discord") + + runner = _make_runner(session_db=db) + runner._schedule_visible_conversation_title_rename = MagicMock() + + event = _make_event( + text="/title Better Thread Name", + platform=Platform.DISCORD, + chat_id="4242", + chat_type="thread", + thread_id="4242", + ) + result = await runner._handle_title_command(event) + + assert "Better Thread Name" in result + runner._schedule_visible_conversation_title_rename.assert_called_once_with( + event.source, "test_session_123", "Better Thread Name" + ) + db.close() + @pytest.mark.asyncio async def test_show_title_does_not_rename_topic(self, tmp_path): """Showing the title (no arg) must not trigger a topic rename.""" @@ -193,12 +226,12 @@ async def test_show_title_does_not_rename_topic(self, tmp_path): db.set_session_title("test_session_123", "Existing Title") runner = _make_runner(session_db=db) - runner._schedule_telegram_topic_title_rename = MagicMock() + runner._schedule_visible_conversation_title_rename = MagicMock() event = _make_event(text="/title") await runner._handle_title_command(event) - runner._schedule_telegram_topic_title_rename.assert_not_called() + runner._schedule_visible_conversation_title_rename.assert_not_called() db.close() @pytest.mark.asyncio