# NousResearch/hermes-agent · Discord thread auto-rename from session titles

## Open now

- 0 issues, 1 PR open on GitHub.
- PR #114717: feat(gateway): mirror final session titles to Telegram groups

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.

## TL;DR

- 7 member(s): 0 open issue(s), 1 closed issue(s), 1 open PR(s), 1 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #60187. Verify it addresses your case.
- **Recommended:** read PR #60187 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-60187`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.29`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #18430: feat(discord): auto-rename threads from generated session titles

## Prior & competing PRs

- **#60187: feat(discord): rename auto-created threads to generated session titles**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `gateway/platforms/base.py`

    ```diff
    @@ -5445,6 +5445,8 @@ def build_source(
             parent_chat_id: Optional[str] = None,
             message_id: Optional[str] = None,
             role_authorized: bool = False,
    +        auto_thread_created: bool = False,
    +        auto_thread_initial_name: Optional[str] = None,
         ) -> SessionSource:
             """Helper to build a SessionSource for this platform."""
             # Normalize empty topic to None
    ```

    ```diff
    @@ -5466,6 +5468,8 @@ def build_source(
                 parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
                 message_id=str(message_id) if message_id else None,
                 role_authorized=role_authorized,
    +            auto_thread_created=auto_thread_created,
    +            auto_thread_initial_name=auto_thread_initial_name,
             )
         
         @abstractmethod
    ```
  - `gateway/run.py`

    ```diff
    @@ -1761,8 +1761,10 @@ def _profile_runtime_scope(profile_home: "Path"):
         EphemeralReply,
         MessageEvent,
         MessageType,
    +    _prefix_within_utf16_limit,
         _reply_anchor_for_event,
         merge_pending_message_event,
    +    utf16_len,
     )
     from gateway.restart import (
         DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT,
    ```

    ```diff
    @@ -13317,6 +13319,91 @@ def _sanitize_telegram_topic_title(self, title: str) -> str:
                 cleaned = cleaned[:117].rstrip() + "..."
             return cleaned
     
    +    def _is_discord_auto_thread_lane(self, source: SessionSource) -> bool:
    +        """Return True only for Discord threads Hermes just auto-created."""
    +        return (
    +            source.platform == Platform.DISCORD
    +            and source.chat_type == "thread"
    +            and bool(getattr(source, "auto_thread_created", False))
    +            and bool(source.thread_id)
    +            and bool(getattr(source, "auto_thread_initial_name", None))
    +        )
    +
    +    def _sanitize_discord_thread_title(self, title: str) -> str:
    +        """Return a Discord-safe semantic thread title from a session title.
    +
    +        Discord thread names are capped at 100 characters measured in UTF-16
    +        code units (emoji count double), so truncate with the UTF-16 helpers
    +        rather than Python code-point slices.
    +        """
    +        cleaned = re.sub(r"\s+", " ", str(title or "")).strip()
    +        if not cleaned:
    +            return "Hermes Chat"
    +        if utf16_len(cleaned) > 80:
    +            cleaned = _prefix_within_utf16_limit(cleaned, 77).rstrip() + "..."
    +        return cleaned
    +
    +    async def _rename_discord_auto_thread_for_session_title(
    +        self,
    +        source: SessionSource,
    +        session_id: str,
    +        title: str,
    +    ) -> None:
    +        """Best-effort semantic rename of a newly auto-created Discord thread."""
    +        if not await asyncio.to_thread(self._is_discord_auto_thread_lane, source):
    +            return
    +        adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
    +        if adapter is None:
    +            return
    +        rename_thread = getattr(adapter, "rename_thread", None)
    +        if rename_thread is None:
    +            return
    +        thread_name = self._sanitize_discord_thread_title(title)
    +        try:
    +            await rename_thread(
    +                str(source.thread_id),
    +                thread_name,
    +                only_if_current_name=getattr(source, "auto_thread_initial_name", None),
    +            )
    +        except Exception:
    +            logger.debug("Failed to rename Discord auto-thread for generated session title", exc_info=True)
    +
    +    def _schedule_discord_semantic_thread_rename(
    +        self,
    +        source: SessionSource,
    +        session_id: str,
    +        title: str,
    +    ) -> None:
    +        """Schedule Discord auto-thread rename from the auto-title background thread."""
    +        if not title or not self._is_discord_auto_thread_lane(source):
    +            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:
    +            copied_source = dataclasses.replace(source)
    +        except Exception:
    +            copied_source = source
    +        future = safe_schedule_threadsafe(
    +            self._rename_discord_auto_thread_for_session_title(copied_source, session_id, title),
    +            loop,
    +            logger=logger,
    +            log_message="Discord semantic thread rename failed to schedule",
    +        )
    +        if future is None:
    +            return
    +
    +        def _log_rename_failure(fut) -> None:
    +            try:
    +                fut.result()
    +            except Exception:
    +                logger.debug("Discord semantic thread rename failed", exc_info=True)
    +
    +        future.add_done_callback(_log_rename_failure)
    +
         async def _rename_telegram_topic_for_session_title(
             self,
             source: SessionSource,
    ```

    ```diff
    @@ -18700,6 +18787,12 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:
                                 effective_session_id,
                                 title,
                             )
    +                    elif self._is_discord_auto_thread_lane(source):
    +                        maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_discord_semantic_thread_rename(
    +                            source,
    +                            effective_session_id,
    +                            title,
    +                        )
                         maybe_auto_title(
                             getattr(self._session_db, "_db", self._session_db),
                             effective_session_id,
    ```
  - `gateway/session.py`

    ```diff
    @@ -181,6 +181,14 @@ class SessionSource:
         # namespacing and the per-turn config/credential scope.
         profile: Optional[str] = None
     
    +    # Discord auto-thread metadata.  Newly auto-created Discord threads start
    +    # with a fast placeholder title from the raw message, then the gateway can
    +    # rename them after the first agent turn using the generated session title.
    +    # Keep this explicit so pre-existing or human-renamed threads are not
    +    # mistaken for safe rename targets.
    +    auto_thread_created: bool = False
    +    auto_thread_initial_name: Optional[str] = None
    +
         # Internal, wire-INVISIBLE trust signal: True when this event was delivered
         # to the gateway over the per-instance-authenticated relay WebSocket (the
         # Team Gateway connector). The connector authenticates the gateway's socket
    ```

    ```diff
    @@ -254,6 +262,10 @@ def to_dict(self) -> Dict[str, Any]:
                 d["message_id"] = self.message_id
             if self.profile:
                 d["profile"] = self.profile
    +        if self.auto_thread_created:
    +            d["auto_thread_created"] = True
    +        if self.auto_thread_initial_name:
    +            d["auto_thread_initial_name"] = self.auto_thread_initial_name
             return d
     
         @classmethod
    ```

    ```diff
    @@ -275,6 +287,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
                 parent_chat_id=data.get("parent_chat_id"),
                 message_id=data.get("message_id"),
                 profile=data.get("profile"),
    +            auto_thread_created=bool(data.get("auto_thread_created", False)),
    +            auto_thread_initial_name=data.get("auto_thread_initial_name"),
             )
         
     
    ```
  - `plugins/platforms/discord/adapter.py`

    ```diff
    @@ -5171,20 +5171,17 @@ async def _create_thread(
         # Auto-thread helpers
         # ------------------------------------------------------------------
     
    -    async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
    -        """Create a thread from a user message for auto-threading.
    -
    -        Returns the created thread object, or ``None`` on failure. Both the
    -        primary ``message.create_thread`` and the seed-message fallback are
    -        retried once after a short backoff so transient connect errors
    -        (e.g. ``Cannot connect to host discord.com:443``) don't immediately
    -        burn through to the caller's failure path (#20243).
    +    def _derive_auto_thread_name(self, content: str) -> str:
    +        """Return the fast placeholder name used at Discord thread creation time.
    +
    +        Strip Discord mention syntax (users / roles / channels) so thread
    +        titles don't show raw <@id>, <@&id>, or <#id> markers — the ID
    +        isn't meaningful to humans glancing at the thread list (#6336).
    +        Real semantic naming is done after the first agent turn, when
    +        Hermes has an LLM-generated session title and can safely rename
    +        only this newly-created thread.
             """
    -        # Build a short thread name from the message. Strip Discord mention
    -        # syntax (users / roles / channels) so thread titles don't end up
    -        # showing raw <@id>, <@&id>, or <#id> markers — the ID isn't
    -        # meaningful to humans glancing at the thread list (#6336).
    -        content = (message.content or "").strip()
    +        content = (content or "").strip()
             # <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces.
             content = re.sub(r"<@[!&]?\d+>", "", content)
             content = re.sub(r"<#\d+>", "", content)
    ```

    ```diff
    @@ -5192,7 +5189,18 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
             thread_name = content[:80] if content else "Hermes"
             if len(content) > 80:
                 thread_name = thread_name[:77] + "..."
    +        return thread_name
    +
    +    async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
    +        """Create a thread from a user message for auto-threading.
     
    +        Returns the created thread object, or ``None`` on failure. Both the
    +        primary ``message.create_thread`` and the seed-message fallback are
    +        retried once after a short backoff so transient connect errors
    +        (e.g. ``Cannot connect to host discord.com:443``) don't immediately
    +        burn through to the caller's failure path (#20243).
    +        """
    +        thread_name = self._derive_auto_thread_name(message.content or "")
             display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user"
             reason = f"Auto-threaded from mention by {display_name}"
     
    ```

    ```diff
    @@ -5202,6 +5210,10 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
             for attempt in range(2):
                 try:
                     thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
    +                try:
    +                    setattr(thread, "_hermes_auto_thread_initial_name", thread_name)
    +                except Exception:
    +                    pass
                     return thread
                 except Exception as direct_error:
                     last_direct_error = direct_error
    ```

    ```diff
    @@ -5214,6 +5226,10 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
                             auto_archive_duration=1440,
                             reason=reason,
                         )
    +                    try:
    +                        setattr(thread, "_hermes_auto_thread_initial_name", thread_name)
    +                    except Exception:
    +                        pass
                         return thread
                     except Exception as fallback_error:
                         last_fallback_error = fallback_error
    ```

    ```diff
    @@ -5232,6 +5248,67 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
             )
             return None
     
    +    async def rename_thread(
    +        self,
    +        thread_id: str,
    +        name: str,
    +        *,
    +        only_if_current_name: Optional[str] = None,
    +    ) -> bool:
    +        """Best-effort Discord thread rename.
    +
    +        ``only_if_current_name`` prevents overwriting human-renamed or
    +        pre-existing threads.  This is intentionally a no-op on mismatch.
    +        """
    +        if not self._client or not DISCORD_AVAILABLE:
    +            return False
    +
    +        try:
    +            thread_id_int = int(str(thread_id))
    +        except (TypeError, ValueError):
    +            return False
    +
    +        cleaned = re.sub(r"\s+", " ", str(name or "")).strip()
    +        if not cleaned:
    +            return False
    +        # Discord thread names are budgeted in UTF-16 code units (emoji count
    +        # double) — truncate with the UTF-16 helpers, not code-point slices.
    +        from gateway.platforms.base import utf16_len, _prefix_within_utf16_limit
    +        if utf16_len(cleaned) > 80:
    +            cleaned = _prefix_within_utf16_limit(cleaned, 77).rstrip() + "..."
    +
    +        try:
    +            thread = self._client.get_channel(thread_id_int)
    +            if thread is None:
    +                thread = await self._client.fetch_channel(thread_id_int)
    +        except Exception:
    +            logger.debug("[%s] Failed to resolve Discord thread %s for rename", self.name, thread_id, exc_info=True)
    +            return False
    +
    +        current_name = getattr(thread, "name", None)
    +        if only_if_current_name is not None and current_name != only_if_current_name:
    +            logger.info(
    +                "[%s] Discord semantic thread rename skipped for %s: current name %r != expected %r",
    +                self.name, thread_id, current_name, only_if_current_name,
    +            )
    +            return False
    +        if current_name == cleaned:
    +            return True
    +
    +        edit = getattr(thread, "edit", None)
    +        if edit is None:
    +            return False
    +        try:
    +            await edit(name=cleaned, reason="Hermes semantic session title")
    +            logger.info(
    +                "[%s] Renamed Discord thread %s from %r to %r",
    +                self.name, thread_id, current_name, cleaned,
    +            )
    +            return True
    +        except Exception:
    +            logger.debug("[%s] Failed to rename Discord thread %s", self.name, thread_id, exc_info=True)
    +            return False
    +
         async def create_handoff_thread(
             self,
             parent_chat_id: str,
    ```

    ```diff
    @@ -6015,6 +6092,11 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool =
                 parent_chat_id=parent_channel_id,
                 message_id=str(message.id),
                 role_authorized=role_authorized,
    +            auto_thread_created=auto_threaded_channel is not None,
    +            auto_thread_initial_name=(
    +                getattr(auto_threaded_channel, "_hermes_auto_thread_initial_name", None)
    +                or self._derive_auto_thread_name(message.content or "")
    +            ) if auto_threaded_channel is not None else None,
             )
     
             # Build media URLs -- download image attachments to local cache so the
    ```
  - `scripts/release.py`

    ```diff
    @@ -290,6 +290,7 @@
         "290859878+synapsesx@users.noreply.github.com": "synapsesx",
         "157689911+itsflownium@users.noreply.github.com": "itsflownium",
         "dirtyren@users.noreply.github.com": "dirtyren",
    +    "210088133+rungmc357@users.noreply.github.com": "rungmc357",
         "[redacted:email]": "flo1t",
         "[redacted:email]": "fanyangCS",
         "[redacted:email]": "bigstar0920",
    ```
  - `tests/gateway/test_discord_slash_commands.py`

    ```diff
    @@ -562,6 +562,7 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
         call_kwargs = message.create_thread.await_args[1]
         assert call_kwargs["name"] == "Hello world, how are you?"
         assert call_kwargs["auto_archive_duration"] == 1440
    +    assert thread._hermes_auto_thread_initial_name == "Hello world, how are you?"
     
     
     @pytest.mark.asyncio
    ```

    ```diff
    @@ -659,6 +660,47 @@ async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(ada
         assert result is None
     
     
    [redacted:email]
    +async def test_rename_thread_edits_only_when_current_name_matches(adapter):
    +    thread = SimpleNamespace(
    +        id=999,
    +        name="raw user prompt",
    +        edit=AsyncMock(),
    +    )
    +    adapter._client.get_channel = lambda _id: thread
    +
    +    result = await adapter.rename_thread(
    +        "999",
    +        "Semantic Session Title",
    +        only_if_current_name="raw user prompt",
    +    )
    +
    +    assert result is True
    +    thread.edit.assert_awaited_once_with(
    +        name="Semantic Session Title",
    +        reason="Hermes semantic session title",
    +    )
    +
    +
    [redacted:email]
    +async def test_rename_thread_skips_when_human_renamed(adapter):
    +    thread = SimpleNamespace(
    +        id=999,
    +        name="human fixed this already",
    +        edit=AsyncMock(),
    +    )
    +    adapter._client.get_channel = lambda _id: thread
    +
    +    result = await adapter.rename_thread(
    +        "999",
    +        "Semantic Session Title",
    +        only_if_current_name="raw user prompt",
    +    )
    +
    +    assert result is False
    +    thread.edit.assert_not_awaited()
    +
    +
     # ------------------------------------------------------------------
     # Auto-thread integration in _handle_message
     # ------------------------------------------------------------------
    ```

    ```diff
    @@ -742,6 +784,35 @@ async def capture_handle(event):
         assert event.source.chat_id == "999"  # redirected to thread
         assert event.source.chat_type == "thread"
         assert event.source.thread_id == "999"
    +    assert event.source.auto_thread_created is True
    +
    +
    [redacted:email]
    +async def test_auto_thread_source_carries_initial_name_for_semantic_rename(adapter, monkeypatch):
    +    monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
    +    monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
    +
    +    thread = SimpleNamespace(
    +        id=999,
    +        name="raw user prompt",
    +        _hermes_auto_thread_initial_name="raw user prompt",
    +    )
    +    adapter._auto_create_thread = AsyncMock(return_value=thread)
    +
    +    captured_events = []
    +
    +    async def capture_handle(event):
    +        captured_events.append(event)
    +
    +    adapter.handle_message = capture_handle
    +
    +    msg = _fake_message(_FakeTextChannel(), content="raw user prompt")
    +
    +    await adapter._handle_message(msg)
    +
    +    source = captured_events[0].source
    +    assert source.auto_thread_created is True
    +    assert source.auto_thread_initial_name == "raw user prompt"
     
     
     @pytest.mark.asyncio
    ```
  - `tests/gateway/test_fast_command.py`

    ```diff
    @@ -4,7 +4,7 @@
     import threading
     import types
     from types import SimpleNamespace
    -from unittest.mock import AsyncMock
    +from unittest.mock import AsyncMock, MagicMock, patch
     
     import pytest
     import yaml
    ```

    ```diff
    @@ -87,6 +87,19 @@ def _make_source() -> SessionSource:
         )
     
     
    +def _make_discord_auto_thread_source() -> SessionSource:
    +    return SessionSource(
    +        platform=Platform.DISCORD,
    +        chat_id="999",
    +        chat_type="thread",
    +        user_id="user-1",
    +        thread_id="999",
    +        parent_chat_id="100",
    +        auto_thread_created=True,
    +        auto_thread_initial_name="raw user prompt",
    +    )
    +
    +
     def _make_event(text: str) -> MessageEvent:
         return MessageEvent(text=text, source=_make_source(), message_id="m1")
     
    ```

    ```diff
    @@ -193,3 +206,57 @@ async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch
         assert result["final_response"] == "ok"
         assert _CapturingAgent.last_init["service_tier"] == "priority"
         assert _CapturingAgent.last_init["request_overrides"] == {"service_tier": "priority"}
    +
    +
    [redacted:email]
    +async def test_run_agent_passes_discord_auto_thread_title_callback(monkeypatch, tmp_path):
    +    _install_fake_agent(monkeypatch)
    +    runner = _make_runner()
    +    runner._session_db = SimpleNamespace(_db=MagicMock())  # type: ignore[assignment]
    +
    +    monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
    +    monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env")
    +    monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
    +    monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
    +    monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {})
    +    monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
    +    monkeypatch.setattr(
    +        gateway_run,
    +        "_resolve_runtime_agent_kwargs",
    +        lambda: {
    +            "provider": "openrouter",
    +            "api_mode": "chat_completions",
    +            "base_url": "https://openrouter.ai/api/v1",
    +            "api_key": "***",
    +        },
    +    )
    +
    +    import hermes_cli.tools_config as tools_config
    +    monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"})
    +
    +    with patch("agent.title_generator.maybe_auto_title") as mock_title:
    +        await runner._run_agent(
    +            message="raw user prompt",
    +            context_prompt="",
    +            history=[],
    +            source=_make_discord_auto_thread_source(),
    +            session_id="session-1",
    +            session_key="agent:main:discord:thread:999",
    +        )
    +
    +    mock_title.assert_called_once()
    +    callback = mock_title.call_args.kwargs["title_callback"]
    +    with patch.object(runner, "_schedule_discord_semantic_thread_rename") as mock_schedule:
    +        callback("Semantic Session Title")
    +    mock_schedule.assert_called_once()
    +    assert mock_schedule.call_args.args[1] == "session-1"
    +    assert mock_schedule.call_args.args[2] == "Semantic Session Title"
    +
    +
    +def test_session_source_preserves_discord_auto_thread_metadata():
    +    source = _make_discord_auto_thread_source()
    +
    +    restored = SessionSource.from_dict(source.to_dict())
    +
    +    assert restored.auto_thread_created is True
    +    assert restored.auto_thread_initial_name == "raw user prompt"
    ```
- **#26477: feat(discord): sync thread names from session titles**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#27800: fix: rename Discord threads after auto-title**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#18430`, `#26477`, `#15757`
- **#29814: feat(discord): auto-rename thread after first bot reply**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`; refs `#18430`
- **#57679: feat(discord): sync thread names from session titles**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#60187`, `#56792`
- **#114717: feat(gateway): mirror final session titles to Telegram groups**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `.env.example`, `.github/actions/detect-changes/action.yml`, `.github/actions/setup-pm/README.md`, `.github/workflows/archive-inputs.yml`, `.github/workflows/bootstrap-installer-build.yml`, `.github/workflows/bootstrap-installer.yml`, `.github/workflows/canary-release.yml`, `.github/workflows/case-collision-check.yml`, `.github/workflows/ci-review-comment.yml`, `.github/workflows/ci.yaml`, `.github/workflows/contributor-check.yml`, `.github/workflows/deploy-site.yml`, `.github/workflows/desktop-bundle-smoke.yml`, `.github/workflows/desktop-bundled-release.yml`, `.github/workflows/docker-lint.yml`, `.github/workflows/docker.yml`, `.github/workflows/docs-site-checks.yml`, `.github/workflows/e2e-desktop-core.yml`, `.github/workflows/e2e-desktop-update.yml`, `.github/workflows/e2e-desktop.yml` (+1740 more)

## Fix status

- Merged to main in PR #60187 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-07`.
- Subsystem: `gateway`
