{"repo":"NousResearch/hermes-agent","public_id":"18430","display_label":"Discord thread auto-rename from session titles","summary":"These issues and PRs concern a feature to automatically rename Discord threads to match generated session titles. Work spans initial feature proposals, implementation variants, and a related fix.","subsystem":"gateway","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60187,"merged_at":"2026-07-07T12:12:01+00:00"},"resolution":{"state":"resolved","by":"pr-60187"},"freshness":{"state":"verified","last_verified_at":"2026-07-07T12:12:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":18430,"pr_number":60187,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-07T12:12:01+00:00"}],"members":[{"kind":"issue","number":18430,"title":"feat(discord): auto-rename threads from generated session titles","url":null,"state":"closed"},{"kind":"pr","number":26477,"title":"feat(discord): sync thread names from session titles","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":27800,"title":"fix: rename Discord threads after auto-title","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18430","number":18430},{"kind":"pr","value":"#26477","number":26477},{"kind":"pr","value":"#15757","number":15757}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":29814,"title":"feat(discord): auto-rename thread after first bot reply","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18430","number":18430}],"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":57679,"title":"feat(discord): sync thread names from session titles","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60187","number":60187},{"kind":"pr","value":"#56792","number":56792}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":60187,"title":"feat(discord): rename auto-created threads to generated session titles","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/run.py","gateway/session.py","plugins/platforms/discord/adapter.py","scripts/release.py","tests/gateway/test_discord_slash_commands.py","tests/gateway/test_fast_command.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -5445,6 +5445,8 @@ def build_source(\n         parent_chat_id: Optional[str] = None,\n         message_id: Optional[str] = None,\n         role_authorized: bool = False,\n+        auto_thread_created: bool = False,\n+        auto_thread_initial_name: Optional[str] = None,\n     ) -> SessionSource:\n         \"\"\"Helper to build a SessionSource for this platform.\"\"\"\n         # Normalize empty topic to None","@@ -5466,6 +5468,8 @@ def build_source(\n             parent_chat_id=str(parent_chat_id) if parent_chat_id else None,\n             message_id=str(message_id) if message_id else None,\n             role_authorized=role_authorized,\n+            auto_thread_created=auto_thread_created,\n+            auto_thread_initial_name=auto_thread_initial_name,\n         )\n     \n     @abstractmethod"]},{"file":"gateway/run.py","hunks":["@@ -1761,8 +1761,10 @@ def _profile_runtime_scope(profile_home: \"Path\"):\n     EphemeralReply,\n     MessageEvent,\n     MessageType,\n+    _prefix_within_utf16_limit,\n     _reply_anchor_for_event,\n     merge_pending_message_event,\n+    utf16_len,\n )\n from gateway.restart import (\n     DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,","@@ -13317,6 +13319,91 @@ def _sanitize_telegram_topic_title(self, title: str) -> str:\n             cleaned = cleaned[:117].rstrip() + \"...\"\n         return cleaned\n \n+    def _is_discord_auto_thread_lane(self, source: SessionSource) -> bool:\n+        \"\"\"Return True only for Discord threads Hermes just auto-created.\"\"\"\n+        return (\n+            source.platform == Platform.DISCORD\n+            and source.chat_type == \"thread\"\n+            and bool(getattr(source, \"auto_thread_created\", False))\n+            and bool(source.thread_id)\n+            and bool(getattr(source, \"auto_thread_initial_name\", None))\n+        )\n+\n+    def _sanitize_discord_thread_title(self, title: str) -> str:\n+        \"\"\"Return a Discord-safe semantic thread title from a session title.\n+\n+        Discord thread names are capped at 100 characters measured in UTF-16\n+        code units (emoji count double), so truncate with the UTF-16 helpers\n+        rather than Python code-point slices.\n+        \"\"\"\n+        cleaned = re.sub(r\"\\s+\", \" \", str(title or \"\")).strip()\n+        if not cleaned:\n+            return \"Hermes Chat\"\n+        if utf16_len(cleaned) > 80:\n+            cleaned = _prefix_within_utf16_limit(cleaned, 77).rstrip() + \"...\"\n+        return cleaned\n+\n+    async def _rename_discord_auto_thread_for_session_title(\n+        self,\n+        source: SessionSource,\n+        session_id: str,\n+        title: str,\n+    ) -> None:\n+        \"\"\"Best-effort semantic rename of a newly auto-created Discord thread.\"\"\"\n+        if not await asyncio.to_thread(self._is_discord_auto_thread_lane, source):\n+            return\n+        adapter = self.adapters.get(source.platform) if getattr(self, \"adapters\", None) else None\n+        if adapter is None:\n+            return\n+        rename_thread = getattr(adapter, \"rename_thread\", None)\n+        if rename_thread is None:\n+            return\n+        thread_name = self._sanitize_discord_thread_title(title)\n+        try:\n+            await rename_thread(\n+                str(source.thread_id),\n+                thread_name,\n+                only_if_current_name=getattr(source, \"auto_thread_initial_name\", None),\n+            )\n+        except Exception:\n+            logger.debug(\"Failed to rename Discord auto-thread for generated session title\", exc_info=True)\n+\n+    def _schedule_discord_semantic_thread_rename(\n+        self,\n+        source: SessionSource,\n+        session_id: str,\n+        title: str,\n+    ) -> None:\n+        \"\"\"Schedule Discord auto-thread rename from the auto-title background thread.\"\"\"\n+        if not title or not self._is_discord_auto_thread_lane(source):\n+            return\n+        try:\n+            loop = asyncio.get_running_loop()\n+        except RuntimeError:\n+            loop = getattr(self, \"_gateway_loop\", None)\n+        if loop is None or loop.is_closed():\n+            return\n+        try:\n+            copied_source = dataclasses.replace(source)\n+        except Exception:\n+            copied_source = source\n+        future = safe_schedule_threadsafe(\n+            self._rename_discord_auto_thread_for_session_title(copied_source, session_id, title),\n+            loop,\n+            logger=logger,\n+            log_message=\"Discord semantic thread rename failed to schedule\",\n+        )\n+        if future is None:\n+            return\n+\n+        def _log_rename_failure(fut) -> None:\n+            try:\n+                fut.result()\n+            except Exception:\n+                logger.debug(\"Discord semantic thread rename failed\", exc_info=True)\n+\n+        future.add_done_callback(_log_rename_failure)\n+\n     async def _rename_telegram_topic_for_session_title(\n         self,\n         source: SessionSource,","@@ -18700,6 +18787,12 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:\n                             effective_session_id,\n                             title,\n                         )\n+                    elif self._is_discord_auto_thread_lane(source):\n+                        maybe_auto_title_kwargs[\"title_callback\"] = lambda title: self._schedule_discord_semantic_thread_rename(\n+                            source,\n+                            effective_session_id,\n+                            title,\n+                        )\n                     maybe_auto_title(\n                         getattr(self._session_db, \"_db\", self._session_db),\n                         effective_session_id,"]},{"file":"gateway/session.py","hunks":["@@ -181,6 +181,14 @@ class SessionSource:\n     # namespacing and the per-turn config/credential scope.\n     profile: Optional[str] = None\n \n+    # Discord auto-thread metadata.  Newly auto-created Discord threads start\n+    # with a fast placeholder title from the raw message, then the gateway can\n+    # rename them after the first agent turn using the generated session title.\n+    # Keep this explicit so pre-existing or human-renamed threads are not\n+    # mistaken for safe rename targets.\n+    auto_thread_created: bool = False\n+    auto_thread_initial_name: Optional[str] = None\n+\n     # Internal, wire-INVISIBLE trust signal: True when this event was delivered\n     # to the gateway over the per-instance-authenticated relay WebSocket (the\n     # Team Gateway connector). The connector authenticates the gateway's socket","@@ -254,6 +262,10 @@ def to_dict(self) -> Dict[str, Any]:\n             d[\"message_id\"] = self.message_id\n         if self.profile:\n             d[\"profile\"] = self.profile\n+        if self.auto_thread_created:\n+            d[\"auto_thread_created\"] = True\n+        if self.auto_thread_initial_name:\n+            d[\"auto_thread_initial_name\"] = self.auto_thread_initial_name\n         return d\n \n     @classmethod","@@ -275,6 +287,8 @@ def from_dict(cls, data: Dict[str, Any]) -> \"SessionSource\":\n             parent_chat_id=data.get(\"parent_chat_id\"),\n             message_id=data.get(\"message_id\"),\n             profile=data.get(\"profile\"),\n+            auto_thread_created=bool(data.get(\"auto_thread_created\", False)),\n+            auto_thread_initial_name=data.get(\"auto_thread_initial_name\"),\n         )\n     \n "]},{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -5171,20 +5171,17 @@ async def _create_thread(\n     # Auto-thread helpers\n     # ------------------------------------------------------------------\n \n-    async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n-        \"\"\"Create a thread from a user message for auto-threading.\n-\n-        Returns the created thread object, or ``None`` on failure. Both the\n-        primary ``message.create_thread`` and the seed-message fallback are\n-        retried once after a short backoff so transient connect errors\n-        (e.g. ``Cannot connect to host discord.com:443``) don't immediately\n-        burn through to the caller's failure path (#20243).\n+    def _derive_auto_thread_name(self, content: str) -> str:\n+        \"\"\"Return the fast placeholder name used at Discord thread creation time.\n+\n+        Strip Discord mention syntax (users / roles / channels) so thread\n+        titles don't show raw <@id>, <@&id>, or <#id> markers — the ID\n+        isn't meaningful to humans glancing at the thread list (#6336).\n+        Real semantic naming is done after the first agent turn, when\n+        Hermes has an LLM-generated session title and can safely rename\n+        only this newly-created thread.\n         \"\"\"\n-        # Build a short thread name from the message. Strip Discord mention\n-        # syntax (users / roles / channels) so thread titles don't end up\n-        # showing raw <@id>, <@&id>, or <#id> markers — the ID isn't\n-        # meaningful to humans glancing at the thread list (#6336).\n-        content = (message.content or \"\").strip()\n+        content = (content or \"\").strip()\n         # <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces.\n         content = re.sub(r\"<@[!&]?\\d+>\", \"\", content)\n         content = re.sub(r\"<#\\d+>\", \"\", content)","@@ -5192,7 +5189,18 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n         thread_name = content[:80] if content else \"Hermes\"\n         if len(content) > 80:\n             thread_name = thread_name[:77] + \"...\"\n+        return thread_name\n+\n+    async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n+        \"\"\"Create a thread from a user message for auto-threading.\n \n+        Returns the created thread object, or ``None`` on failure. Both the\n+        primary ``message.create_thread`` and the seed-message fallback are\n+        retried once after a short backoff so transient connect errors\n+        (e.g. ``Cannot connect to host discord.com:443``) don't immediately\n+        burn through to the caller's failure path (#20243).\n+        \"\"\"\n+        thread_name = self._derive_auto_thread_name(message.content or \"\")\n         display_name = getattr(getattr(message, \"author\", None), \"display_name\", None) or \"unknown user\"\n         reason = f\"Auto-threaded from mention by {display_name}\"\n ","@@ -5202,6 +5210,10 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n         for attempt in range(2):\n             try:\n                 thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)\n+                try:\n+                    setattr(thread, \"_hermes_auto_thread_initial_name\", thread_name)\n+                except Exception:\n+                    pass\n                 return thread\n             except Exception as direct_error:\n                 last_direct_error = direct_error","@@ -5214,6 +5226,10 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n                         auto_archive_duration=1440,\n                         reason=reason,\n                     )\n+                    try:\n+                        setattr(thread, \"_hermes_auto_thread_initial_name\", thread_name)\n+                    except Exception:\n+                        pass\n                     return thread\n                 except Exception as fallback_error:\n                     last_fallback_error = fallback_error","@@ -5232,6 +5248,67 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:\n         )\n         return None\n \n+    async def rename_thread(\n+        self,\n+        thread_id: str,\n+        name: str,\n+        *,\n+        only_if_current_name: Optional[str] = None,\n+    ) -> bool:\n+        \"\"\"Best-effort Discord thread rename.\n+\n+        ``only_if_current_name`` prevents overwriting human-renamed or\n+        pre-existing threads.  This is intentionally a no-op on mismatch.\n+        \"\"\"\n+        if not self._client or not DISCORD_AVAILABLE:\n+            return False\n+\n+        try:\n+            thread_id_int = int(str(thread_id))\n+        except (TypeError, ValueError):\n+            return False\n+\n+        cleaned = re.sub(r\"\\s+\", \" \", str(name or \"\")).strip()\n+        if not cleaned:\n+            return False\n+        # Discord thread names are budgeted in UTF-16 code units (emoji count\n+        # double) — truncate with the UTF-16 helpers, not code-point slices.\n+        from gateway.platforms.base import utf16_len, _prefix_within_utf16_limit\n+        if utf16_len(cleaned) > 80:\n+            cleaned = _prefix_within_utf16_limit(cleaned, 77).rstrip() + \"...\"\n+\n+        try:\n+            thread = self._client.get_channel(thread_id_int)\n+            if thread is None:\n+                thread = await self._client.fetch_channel(thread_id_int)\n+        except Exception:\n+            logger.debug(\"[%s] Failed to resolve Discord thread %s for rename\", self.name, thread_id, exc_info=True)\n+            return False\n+\n+        current_name = getattr(thread, \"name\", None)\n+        if only_if_current_name is not None and current_name != only_if_current_name:\n+            logger.info(\n+                \"[%s] Discord semantic thread rename skipped for %s: current name %r != expected %r\",\n+                self.name, thread_id, current_name, only_if_current_name,\n+            )\n+            return False\n+        if current_name == cleaned:\n+            return True\n+\n+        edit = getattr(thread, \"edit\", None)\n+        if edit is None:\n+            return False\n+        try:\n+            await edit(name=cleaned, reason=\"Hermes semantic session title\")\n+            logger.info(\n+                \"[%s] Renamed Discord thread %s from %r to %r\",\n+                self.name, thread_id, current_name, cleaned,\n+            )\n+            return True\n+        except Exception:\n+            logger.debug(\"[%s] Failed to rename Discord thread %s\", self.name, thread_id, exc_info=True)\n+            return False\n+\n     async def create_handoff_thread(\n         self,\n         parent_chat_id: str,","@@ -6015,6 +6092,11 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool =\n             parent_chat_id=parent_channel_id,\n             message_id=str(message.id),\n             role_authorized=role_authorized,\n+            auto_thread_created=auto_threaded_channel is not None,\n+            auto_thread_initial_name=(\n+                getattr(auto_threaded_channel, \"_hermes_auto_thread_initial_name\", None)\n+                or self._derive_auto_thread_name(message.content or \"\")\n+            ) if auto_threaded_channel is not None else None,\n         )\n \n         # Build media URLs -- download image attachments to local cache so the"]},{"file":"scripts/release.py","hunks":["@@ -290,6 +290,7 @@\n     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"157689911+itsflownium@users.noreply.github.com\": \"itsflownium\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"210088133+rungmc357@users.noreply.github.com\": \"rungmc357\",\n     \"[redacted:email]\": \"flo1t\",\n     \"[redacted:email]\": \"fanyangCS\",\n     \"[redacted:email]\": \"bigstar0920\","]},{"file":"tests/gateway/test_discord_slash_commands.py","hunks":["@@ -562,6 +562,7 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):\n     call_kwargs = message.create_thread.await_args[1]\n     assert call_kwargs[\"name\"] == \"Hello world, how are you?\"\n     assert call_kwargs[\"auto_archive_duration\"] == 1440\n+    assert thread._hermes_auto_thread_initial_name == \"Hello world, how are you?\"\n \n \n @pytest.mark.asyncio","@@ -659,6 +660,47 @@ async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(ada\n     assert result is None\n \n \n[redacted:email]\n+async def test_rename_thread_edits_only_when_current_name_matches(adapter):\n+    thread = SimpleNamespace(\n+        id=999,\n+        name=\"raw user prompt\",\n+        edit=AsyncMock(),\n+    )\n+    adapter._client.get_channel = lambda _id: thread\n+\n+    result = await adapter.rename_thread(\n+        \"999\",\n+        \"Semantic Session Title\",\n+        only_if_current_name=\"raw user prompt\",\n+    )\n+\n+    assert result is True\n+    thread.edit.assert_awaited_once_with(\n+        name=\"Semantic Session Title\",\n+        reason=\"Hermes semantic session title\",\n+    )\n+\n+\n[redacted:email]\n+async def test_rename_thread_skips_when_human_renamed(adapter):\n+    thread = SimpleNamespace(\n+        id=999,\n+        name=\"human fixed this already\",\n+        edit=AsyncMock(),\n+    )\n+    adapter._client.get_channel = lambda _id: thread\n+\n+    result = await adapter.rename_thread(\n+        \"999\",\n+        \"Semantic Session Title\",\n+        only_if_current_name=\"raw user prompt\",\n+    )\n+\n+    assert result is False\n+    thread.edit.assert_not_awaited()\n+\n+\n # ------------------------------------------------------------------\n # Auto-thread integration in _handle_message\n # ------------------------------------------------------------------","@@ -742,6 +784,35 @@ async def capture_handle(event):\n     assert event.source.chat_id == \"999\"  # redirected to thread\n     assert event.source.chat_type == \"thread\"\n     assert event.source.thread_id == \"999\"\n+    assert event.source.auto_thread_created is True\n+\n+\n[redacted:email]\n+async def test_auto_thread_source_carries_initial_name_for_semantic_rename(adapter, monkeypatch):\n+    monkeypatch.setenv(\"DISCORD_AUTO_THREAD\", \"true\")\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+\n+    thread = SimpleNamespace(\n+        id=999,\n+        name=\"raw user prompt\",\n+        _hermes_auto_thread_initial_name=\"raw user prompt\",\n+    )\n+    adapter._auto_create_thread = AsyncMock(return_value=thread)\n+\n+    captured_events = []\n+\n+    async def capture_handle(event):\n+        captured_events.append(event)\n+\n+    adapter.handle_message = capture_handle\n+\n+    msg = _fake_message(_FakeTextChannel(), content=\"raw user prompt\")\n+\n+    await adapter._handle_message(msg)\n+\n+    source = captured_events[0].source\n+    assert source.auto_thread_created is True\n+    assert source.auto_thread_initial_name == \"raw user prompt\"\n \n \n @pytest.mark.asyncio"]},{"file":"tests/gateway/test_fast_command.py","hunks":["@@ -4,7 +4,7 @@\n import threading\n import types\n from types import SimpleNamespace\n-from unittest.mock import AsyncMock\n+from unittest.mock import AsyncMock, MagicMock, patch\n \n import pytest\n import yaml","@@ -87,6 +87,19 @@ def _make_source() -> SessionSource:\n     )\n \n \n+def _make_discord_auto_thread_source() -> SessionSource:\n+    return SessionSource(\n+        platform=Platform.DISCORD,\n+        chat_id=\"999\",\n+        chat_type=\"thread\",\n+        user_id=\"user-1\",\n+        thread_id=\"999\",\n+        parent_chat_id=\"100\",\n+        auto_thread_created=True,\n+        auto_thread_initial_name=\"raw user prompt\",\n+    )\n+\n+\n def _make_event(text: str) -> MessageEvent:\n     return MessageEvent(text=text, source=_make_source(), message_id=\"m1\")\n ","@@ -193,3 +206,57 @@ async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch\n     assert result[\"final_response\"] == \"ok\"\n     assert _CapturingAgent.last_init[\"service_tier\"] == \"priority\"\n     assert _CapturingAgent.last_init[\"request_overrides\"] == {\"service_tier\": \"priority\"}\n+\n+\n[redacted:email]\n+async def test_run_agent_passes_discord_auto_thread_title_callback(monkeypatch, tmp_path):\n+    _install_fake_agent(monkeypatch)\n+    runner = _make_runner()\n+    runner._session_db = SimpleNamespace(_db=MagicMock())  # type: ignore[assignment]\n+\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.setattr(gateway_run, \"_env_path\", tmp_path / \".env\")\n+    monkeypatch.setattr(gateway_run, \"load_dotenv\", lambda *args, **kwargs: None)\n+    monkeypatch.setattr(gateway_run, \"_load_gateway_config\", lambda: {})\n+    monkeypatch.setattr(gateway_run, \"_load_gateway_runtime_config\", lambda: {})\n+    monkeypatch.setattr(gateway_run, \"_resolve_gateway_model\", lambda config=None: \"gpt-5.4\")\n+    monkeypatch.setattr(\n+        gateway_run,\n+        \"_resolve_runtime_agent_kwargs\",\n+        lambda: {\n+            \"provider\": \"openrouter\",\n+            \"api_mode\": \"chat_completions\",\n+            \"base_url\": \"https://openrouter.ai/api/v1\",\n+            \"api_key\": \"***\",\n+        },\n+    )\n+\n+    import hermes_cli.tools_config as tools_config\n+    monkeypatch.setattr(tools_config, \"_get_platform_tools\", lambda user_config, platform_key: {\"core\"})\n+\n+    with patch(\"agent.title_generator.maybe_auto_title\") as mock_title:\n+        await runner._run_agent(\n+            message=\"raw user prompt\",\n+            context_prompt=\"\",\n+            history=[],\n+            source=_make_discord_auto_thread_source(),\n+            session_id=\"session-1\",\n+            session_key=\"agent:main:discord:thread:999\",\n+        )\n+\n+    mock_title.assert_called_once()\n+    callback = mock_title.call_args.kwargs[\"title_callback\"]\n+    with patch.object(runner, \"_schedule_discord_semantic_thread_rename\") as mock_schedule:\n+        callback(\"Semantic Session Title\")\n+    mock_schedule.assert_called_once()\n+    assert mock_schedule.call_args.args[1] == \"session-1\"\n+    assert mock_schedule.call_args.args[2] == \"Semantic Session Title\"\n+\n+\n+def test_session_source_preserves_discord_auto_thread_metadata():\n+    source = _make_discord_auto_thread_source()\n+\n+    restored = SessionSource.from_dict(source.to_dict())\n+\n+    assert restored.auto_thread_created is True\n+    assert restored.auto_thread_initial_name == \"raw user prompt\"\n"]}]},{"kind":"pr","number":114717,"title":"feat(gateway): mirror final session titles to Telegram groups","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}