# NousResearch/hermes-agent · Discord allowed_channels config reading and wildcard behavior

## Open now

- Nothing open on GitHub.

Multiple issues and fixes around the Discord allowed_channels configuration: the config.yaml field was not being read (only the env var was), and wildcard values like "*" silently dropped all messages instead of allowing all channels.

## TL;DR

- 6 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #15052. Verify it addresses your case.
- **Recommended:** read PR #15052 (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: `partially_resolved` · deciding PR(s): `pr-15052`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- 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

- #14920: Bug: Wildcard config values (allowed_channels: "*") silently drop all Discord messages
- #32263: discord.allowed_channels config.yaml field is non-functional — only DISCORD_ALLOWED_CHANNELS env var is read

## Prior & competing PRs

- **#14930: Fix/discord allowed channels wildcard**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `8598746e8`, `8a1e247c6`, `#15052`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#15052: fix: P1 batch — Discord wildcard, ACP+MCP, NO_PROXY bypass, resume-after-compression**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `acp_adapter/server.py`, `acp_adapter/session.py`, `cli.py`, `gateway/platforms/discord.py`, `hermes_state.py`, `run_agent.py`, `scripts/release.py`, `tests/acp/test_server.py`, `tests/acp/test_session.py`, `tests/gateway/test_discord_allowed_channels.py`, `tests/hermes_state/test_resolve_resume_session_id.py`, `tests/run_agent/test_create_openai_client_proxy_env.py`
  - fix hunks:
  - `acp_adapter/server.py`

    ```diff
    @@ -60,7 +60,7 @@
         make_tool_progress_cb,
     )
     from acp_adapter.permissions import make_approval_callback
    -from acp_adapter.session import SessionManager, SessionState
    +from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
     
     logger = logging.getLogger(__name__)
     
    ```

    ```diff
    @@ -287,7 +287,11 @@ async def _register_session_mcp_servers(
             try:
                 from model_tools import get_tool_definitions
     
    -            enabled_toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
    +            enabled_toolsets = _expand_acp_enabled_toolsets(
    +                getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
    +                mcp_server_names=[server.name for server in mcp_servers],
    +            )
    +            state.agent.enabled_toolsets = enabled_toolsets
                 disabled_toolsets = getattr(state.agent, "disabled_toolsets", None)
                 state.agent.tools = get_tool_definitions(
                     enabled_toolsets=enabled_toolsets,
    ```

    ```diff
    @@ -754,7 +758,9 @@ def _cmd_model(self, args: str, state: SessionState) -> str:
         def _cmd_tools(self, args: str, state: SessionState) -> str:
             try:
                 from model_tools import get_tool_definitions
    -            toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
    +            toolsets = _expand_acp_enabled_toolsets(
    +                getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
    +            )
                 tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
                 if not tools:
                     return "No tools available."
    ```
  - `acp_adapter/session.py`

    ```diff
    @@ -106,6 +106,24 @@ def _register_task_cwd(task_id: str, cwd: str) -> None:
             logger.debug("Failed to register ACP task cwd override", exc_info=True)
     
     
    +def _expand_acp_enabled_toolsets(
    +    toolsets: List[str] | None = None,
    +    mcp_server_names: List[str] | None = None,
    +) -> List[str]:
    +    """Return ACP toolsets plus explicit MCP server toolsets for this session."""
    +    expanded: List[str] = []
    +    for name in list(toolsets or ["hermes-acp"]):
    +        if name and name not in expanded:
    +            expanded.append(name)
    +
    +    for server_name in list(mcp_server_names or []):
    +        toolset_name = f"mcp-{server_name}"
    +        if server_name and toolset_name not in expanded:
    +            expanded.append(toolset_name)
    +
    +    return expanded
    +
    +
     def _clear_task_cwd(task_id: str) -> None:
         """Remove task-specific cwd overrides for an ACP session."""
         if not task_id:
    ```

    ```diff
    @@ -537,9 +555,18 @@ def _make_agent(
             elif isinstance(model_cfg, str) and model_cfg.strip():
                 default_model = model_cfg.strip()
     
    +        configured_mcp_servers = [
    +            name
    +            for name, cfg in (config.get("mcp_servers") or {}).items()
    +            if not isinstance(cfg, dict) or cfg.get("enabled", True) is not False
    +        ]
    +
             kwargs = {
                 "platform": "acp",
    -            "enabled_toolsets": ["hermes-acp"],
    +            "enabled_toolsets": _expand_acp_enabled_toolsets(
    +                ["hermes-acp"],
    +                mcp_server_names=configured_mcp_servers,
    +            ),
                 "quiet_mode": True,
                 "session_id": session_id,
                 "model": model or default_model,
    ```
  - `cli.py`

    ```diff
    @@ -3254,6 +3254,23 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No
                     _cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
                     _cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
                     return False
    +            # If the requested session is the (empty) head of a compression
    +            # chain, walk to the descendant that actually holds the messages.
    +            # See #15000 and SessionDB.resolve_resume_session_id.
    +            try:
    +                resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
    +            except Exception:
    +                resolved_id = self.session_id
    +            if resolved_id and resolved_id != self.session_id:
    +                ChatConsole().print(
    +                    f"[{_DIM}]Session {_escape(self.session_id)} was compressed into "
    +                    f"{_escape(resolved_id)}; resuming the descendant with your "
    +                    f"transcript.[/]"
    +                )
    +                self.session_id = resolved_id
    +                resolved_meta = self._session_db.get_session(self.session_id)
    +                if resolved_meta:
    +                    session_meta = resolved_meta
                 restored = self._session_db.get_messages_as_conversation(self.session_id)
                 if restored:
                     restored = [m for m in restored if m.get("role") != "session_meta"]
    ```

    ```diff
    @@ -3472,6 +3489,22 @@ def _preload_resumed_session(self) -> bool:
                 )
                 return False
     
    +        # If the requested session is the (empty) head of a compression chain,
    +        # walk to the descendant that actually holds the messages. See #15000.
    +        try:
    +            resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
    +        except Exception:
    +            resolved_id = self.session_id
    +        if resolved_id and resolved_id != self.session_id:
    +            self._console_print(
    +                f"[dim]Session {self.session_id} was compressed into "
    +                f"{resolved_id}; resuming the descendant with your transcript.[/]"
    +            )
    +            self.session_id = resolved_id
    +            resolved_meta = self._session_db.get_session(self.session_id)
    +            if resolved_meta:
    +                session_meta = resolved_meta
    +
             restored = self._session_db.get_messages_as_conversation(self.session_id)
             if restored:
                 restored = [m for m in restored if m.get("role") != "session_meta"]
    ```

    ```diff
    @@ -4686,6 +4719,22 @@ def _handle_resume_command(self, cmd_original: str) -> None:
                 _cprint("  Use /history or `hermes sessions list` to see available sessions.")
                 return
     
    +        # If the target is the empty head of a compression chain, redirect to
    +        # the descendant that actually holds the transcript. See #15000.
    +        try:
    +            resolved_id = self._session_db.resolve_resume_session_id(target_id)
    +        except Exception:
    +            resolved_id = target_id
    +        if resolved_id and resolved_id != target_id:
    +            _cprint(
    +                f"  Session {target_id} was compressed into {resolved_id}; "
    +                f"resuming the descendant with your transcript."
    +            )
    +            target_id = resolved_id
    +            resolved_meta = self._session_db.get_session(target_id)
    +            if resolved_meta:
    +                session_meta = resolved_meta
    +
             if target_id == self.session_id:
                 _cprint("  Already on that session.")
                 return
    ```
  - `gateway/platforms/discord.py`

    ```diff
    @@ -2719,7 +2719,12 @@ def _discord_require_mention(self) -> bool:
             return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
     
         def _discord_free_response_channels(self) -> set:
    -        """Return Discord channel IDs where no bot mention is required."""
    +        """Return Discord channel IDs where no bot mention is required.
    +
    +        A single ``"*"`` entry (either from a list or a comma-separated
    +        string) is preserved in the returned set so callers can short-circuit
    +        on wildcard membership, consistent with ``allowed_channels``.
    +        """
             raw = self.config.extra.get("free_response_channels")
             if raw is None:
                 raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
    ```

    ```diff
    @@ -3212,14 +3217,14 @@ async def _handle_message(self, message: DiscordMessage) -> None:
                 allowed_channels_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "")
                 if allowed_channels_raw:
                     allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()}
    -                if not (channel_ids & allowed_channels):
    +                if "*" not in allowed_channels and not (channel_ids & allowed_channels):
                         logger.debug("[%s] Ignoring message in non-allowed channel: %s", self.name, channel_ids)
                         return
     
                 # Check ignored channels - never respond even when mentioned
                 ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "")
                 ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()}
    -            if channel_ids & ignored_channels:
    +            if "*" in ignored_channels or (channel_ids & ignored_channels):
                     logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids)
                     return
     
    ```

    ```diff
    @@ -3233,7 +3238,11 @@ async def _handle_message(self, message: DiscordMessage) -> None:
                 voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()}
                 current_channel_id = str(message.channel.id)
                 is_voice_linked_channel = current_channel_id in voice_linked_ids
    -            is_free_channel = bool(channel_ids & free_channels) or is_voice_linked_channel
    +            is_free_channel = (
    +                "*" in free_channels
    +                or bool(channel_ids & free_channels)
    +                or is_voice_linked_channel
    +            )
     
                 # Skip the mention check if the message is in a thread where
                 # the bot has previously participated (auto-created or replied in).
    ```
  - `hermes_state.py`

    ```diff
    @@ -1039,6 +1039,71 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
                 result.append(msg)
             return result
     
    +    def resolve_resume_session_id(self, session_id: str) -> str:
    +        """Redirect a resume target to the descendant session that holds the messages.
    +
    +        Context compression ends the current session and forks a new child session
    +        (linked via ``parent_session_id``). The flush cursor is reset, so the
    +        child is where new messages actually land — the parent ends up with
    +        ``message_count = 0`` rows unless messages had already been flushed to
    +        it before compression. See #15000.
    +
    +        This helper walks ``parent_session_id`` forward from ``session_id`` and
    +        returns the first descendant in the chain that has at least one message
    +        row. If the original session already has messages, or no descendant
    +        has any, the original ``session_id`` is returned unchanged.
    +
    +        The chain is always walked via the child whose ``started_at`` is
    +        latest; that matches the single-chain shape that compression creates.
    +        A depth cap (32) guards against accidental loops in malformed data.
    +        """
    +        if not session_id:
    +            return session_id
    +
    +        with self._lock:
    +            # If this session already has messages, nothing to redirect.
    +            try:
    +                row = self._conn.execute(
    +                    "SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
    +                    (session_id,),
    +                ).fetchone()
    +            except Exception:
    +                return session_id
    +            if row is not None:
    +                return session_id
    +
    +            # Walk descendants: at each step, pick the most-recently-started
    +                # child session; stop once we find one with messages.
    +            current = session_id
    +            seen = {current}
    +            for _ in range(32):
    +                try:
    +                    child_row = self._conn.execute(
    +                        "SELECT id FROM sessions "
    +                        "WHERE parent_session_id = ? "
    +                        "ORDER BY started_at DESC, id DESC LIMIT 1",
    +                        (current,),
    +                    ).fetchone()
    +                except Exception:
    +                    return session_id
    +                if child_row is None:
    +                    return session_id
    +                child_id = child_row["id"] if hasattr(child_row, "keys") else child_row[0]
    +                if not child_id or child_id in seen:
    +                    return session_id
    +                seen.add(child_id)
    +                try:
    +                    msg_row = self._conn.execute(
    +                        "SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
    +                        (child_id,),
    +                    ).fetchone()
    +                except Exception:
    +                    return session_id
    +                if msg_row is not None:
    +                    return child_id
    +                current = child_id
    +        return session_id
    +
         def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
             """
             Load messages in the OpenAI conversation format (role + content dicts).
    ```
  - `run_agent.py`

    ```diff
    @@ -36,6 +36,7 @@
     import time
     import threading
     from types import SimpleNamespace
    +import urllib.request
     import uuid
     from typing import List, Dict, Any, Optional
     from openai import OpenAI
    ```

    ```diff
    @@ -181,6 +182,25 @@ def _get_proxy_from_env() -> Optional[str]:
         return None
     
     
    +def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]:
    +    """Return an env-configured proxy unless NO_PROXY excludes this base URL."""
    +    proxy = _get_proxy_from_env()
    +    if not proxy or not base_url:
    +        return proxy
    +
    +    host = base_url_hostname(base_url)
    +    if not host:
    +        return proxy
    +
    +    try:
    +        if urllib.request.proxy_bypass_environment(host):
    +            return None
    +    except Exception:
    +        pass
    +
    +    return proxy
    +
    +
     def _install_safe_stdio() -> None:
         """Wrap stdout/stderr so best-effort console output cannot crash the agent."""
         for stream_name in ("stdout", "stderr"):
    ```

    ```diff
    @@ -4466,7 +4486,7 @@ def _is_openai_client_closed(client: Any) -> bool:
             return False
     
         @staticmethod
    -    def _build_keepalive_http_client() -> Any:
    +    def _build_keepalive_http_client(base_url: str = "") -> Any:
             try:
                 import httpx as _httpx
                 import socket as _socket
    ```

    ```diff
    @@ -4480,8 +4500,9 @@ def _build_keepalive_http_client() -> Any:
                     _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPALIVE, 30))
                 # When a custom transport is provided, httpx won't auto-read proxy
                 # from env vars (allow_env_proxies = trust_env and transport is None).
    -            # Explicitly read proxy settings to ensure HTTP_PROXY/HTTPS_PROXY work.
    -            _proxy = _get_proxy_from_env()
    +            # Explicitly read proxy settings while still honoring NO_PROXY for
    +            # loopback / local endpoints such as a locally hosted sub2api.
    +            _proxy = _get_proxy_for_base_url(base_url)
                 return _httpx.Client(
                     transport=_httpx.HTTPTransport(socket_options=_sock_opts),
                     proxy=_proxy,
    ```

    ```diff
    @@ -4539,7 +4560,7 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo
                         if k in {"api_key", "base_url", "default_headers", "timeout", "http_client"}
                     }
                     if "http_client" not in safe_kwargs:
    -                    keepalive_http = self._build_keepalive_http_client()
    +                    keepalive_http = self._build_keepalive_http_client(base_url)
                         if keepalive_http is not None:
                             safe_kwargs["http_client"] = keepalive_http
                     client = GeminiNativeClient(**safe_kwargs)
    ```

    ```diff
    @@ -4568,7 +4589,7 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo
             # Tests in ``tests/run_agent/test_create_openai_client_reuse.py`` and
             # ``tests/run_agent/test_sequential_chats_live.py`` pin this invariant.
             if "http_client" not in client_kwargs:
    -            keepalive_http = self._build_keepalive_http_client()
    +            keepalive_http = self._build_keepalive_http_client(client_kwargs.get("base_url", ""))
                 if keepalive_http is not None:
                     client_kwargs["http_client"] = keepalive_http
             client = OpenAI(**client_kwargs)
    ```
  - `scripts/release.py`

    ```diff
    @@ -438,6 +438,10 @@
         "[redacted:email]": "topcheer",
         "[redacted:email]": "walli",
         "[redacted:email]": "Zhuofeng-Wang",
    +    # April 2026 salvage-PR batch (#14920, #14986, #14966)
    +    "[redacted:email]": "mrunmayee17",
    +    "[redacted:email]": "camaragon",
    +    "[redacted:email]": "shamork",
         # no-github-match — keep as display names
         "[redacted:email]": "Sisyphus",
         "[redacted:email]": "Marco Rutsch",
    ```
  - `tests/acp/test_server.py`

    ```diff
    @@ -904,9 +904,15 @@ async def test_refreshes_agent_tool_surface(self, agent, mock_manager):
             ]
     
             with patch("tools.mcp_tool.register_mcp_servers", return_value=["mcp_srv_search"]), \
    -             patch("model_tools.get_tool_definitions", return_value=fake_tools):
    +             patch("model_tools.get_tool_definitions", return_value=fake_tools) as mock_defs:
                 await agent._register_session_mcp_servers(state, [server])
     
    +        mock_defs.assert_called_once_with(
    +            enabled_toolsets=["hermes-acp", "mcp-srv"],
    +            disabled_toolsets=None,
    +            quiet_mode=True,
    +        )
    +        assert state.agent.enabled_toolsets == ["hermes-acp", "mcp-srv"]
             assert state.agent.tools == fake_tools
             assert state.agent.valid_tool_names == {"mcp_srv_search", "terminal"}
             # _invalidate_system_prompt should have been called
    ```
  - `tests/acp/test_session.py`

    ```diff
    @@ -138,6 +138,43 @@ def test_remove_session(self, manager):
     class TestPersistence:
         """Verify that sessions are persisted to SessionDB and can be restored."""
     
    +    def test_create_session_includes_registered_mcp_toolsets(self, tmp_path, monkeypatch):
    +        captured = {}
    +
    +        def fake_resolve_runtime_provider(requested=None, **kwargs):
    +            return {
    +                "provider": "openrouter",
    +                "api_mode": "chat_completions",
    +                "base_url": "https://openrouter.example/v1",
    +                "api_key": "***",
    +                "command": None,
    +                "args": [],
    +            }
    +
    +        def fake_agent(**kwargs):
    +            captured.update(kwargs)
    +            return SimpleNamespace(model=kwargs.get("model"), enabled_toolsets=kwargs.get("enabled_toolsets"))
    +
    +        monkeypatch.setattr("hermes_cli.config.load_config", lambda: {
    +            "model": {"provider": "openrouter", "default": "test-model"},
    +            "mcp_servers": {
    +                "olympus": {"command": "python", "enabled": True},
    +                "exa": {"url": "https://exa.ai/mcp"},
    +                "disabled": {"command": "python", "enabled": False},
    +            },
    +        })
    +        monkeypatch.setattr(
    +            "hermes_cli.runtime_provider.resolve_runtime_provider",
    +            fake_resolve_runtime_provider,
    +        )
    +        db = SessionDB(tmp_path / "state.db")
    +
    +        with patch("run_agent.AIAgent", side_effect=fake_agent):
    +            manager = SessionManager(db=db)
    +            manager.create_session(cwd="/work")
    +
    +        assert captured["enabled_toolsets"] == ["hermes-acp", "mcp-olympus", "mcp-exa"]
    +
         def test_create_session_writes_to_db(self, manager):
             state = manager.create_session(cwd="/project")
             db = manager._get_db()
    ```
  - `tests/gateway/test_discord_allowed_channels.py`

    ```diff
    @@ -0,0 +1,104 @@
    +"""Regression guard for #14920: wildcard "*" in Discord channel config lists.
    +
    +Setting ``allowed_channels: "*"``, ``free_response_channels: "*"``, or
    +``ignored_channels: "*"`` in config (or their ``DISCORD_*_CHANNELS`` env var
    +equivalents) must behave as a wildcard — i.e. the bot responds in every
    +channel (or is silenced in every channel, for the ignored list). Previously
    +the literal string "*" was placed into a set and compared against numeric
    +channel IDs via set-intersection, which always produced an empty set and
    +caused every message to be silently dropped (for ``allowed_channels``) or
    +every ``free_response`` / ``ignored`` check to fail open.
    +"""
    +
    +import unittest
    +
    +
    +def _channel_is_allowed(channel_id: str, allowed_channels_raw: str) -> bool:
    +    """Replicate the channel-allow-list check from discord.py on_message."""
    +    if not allowed_channels_raw:
    +        return True
    +    allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()}
    +    if "*" in allowed_channels:
    +        return True
    +    return bool({channel_id} & allowed_channels)
    +
    +
    +def _channel_is_ignored(channel_id: str, ignored_channels_raw: str) -> bool:
    +    """Replicate the ignored-channel check from discord.py on_message."""
    +    ignored_channels = {
    +        ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()
    +    }
    +    return "*" in ignored_channels or bool({channel_id} & ignored_channels)
    +
    +
    +def _channel_is_free_response(channel_id: str, free_channels_raw: str) -> bool:
    +    """Replicate the free-response-channel check from discord.py on_message."""
    +    free_channels = {
    +        ch.strip() for ch in free_channels_raw.split(",") if ch.strip()
    +    }
    +    return "*" in free_channels or bool({channel_id} & free_channels)
    +
    +
    +class TestDiscordAllowedChannelsWildcard(unittest.TestCase):
    +    """Wildcard and channel-list behaviour for DISCORD_ALLOWED_CHANNELS."""
    +
    +    def test_wildcard_allows_any_channel(self):
    +        """'*' should allow messages from any channel ID."""
    +        self.assertTrue(_channel_is_allowed("1234567890", "*"))
    +
    +    def test_wildcard_in_list_allows_any_channel(self):
    +        """'*' mixed with other entries still allows any channel."""
    +        self.assertTrue(_channel_is_allowed("9999999999", "111,*,222"))
    +
    +    def test_exact_match_allowed(self):
    +        """Channel ID present in the explicit list is allowed."""
    +        self.assertTrue(_channel_is_allowed("1234567890", "1234567890,9876543210"))
    +
    +    def test_non_matching_channel_blocked(self):
    +        """Channel ID absent from the explicit list is blocked."""
    +        self.assertFalse(_channel_is_allowed("5555555555", "1234567890,9876543210"))
    +
    +    def test_empty_allowlist_allows_all(self):
    +        """Empty DISCORD_ALLOWED_CHANNELS means no restriction."""
    +        self.assertTrue(_channel_is_allowed("1234567890", ""))
    +
    +    def test_whitespace_only_entry_ignored(self):
    +        """Entries that are only whitespace are stripped and ignored."""
    +        self.assertFalse(_channel_is_allowed("1234567890", "  ,  "))
    +
    +
    +class TestDiscordIgnoredChannelsWildcard(unittest.TestCase):
    +    """Wildcard and channel-list behaviour for DISCORD_IGNORED_CHANNELS."""
    +
    +    def test_wildcard_silences_every_channel(self):
    +        """'*' in ignored_channels silences the bot everywhere."""
    +        self.assertTrue(_channel_is_ignored("1234567890", "*"))
    +
    +    def test_empty_ignored_list_silences_nothing(self):
    +        self.assertFalse(_channel_is_ignored("1234567890", ""))
    +
    +    def test_exact_match_is_ignored(self):
    +        self.assertTrue(_channel_is_ignored("111", "111,222"))
    +
    +    def test_non_match_not_ignored(self):
    +        self.assertFalse(_channel_is_ignored("333", "111,222"))
    +
    +
    +class TestDiscordFreeResponseChannelsWildcard(unittest.TestCase):
    +    """Wildcard and channel-list behaviour for DISCORD_FREE_RESPONSE_CHANNELS."""
    +
    +    def test_wildcard_makes_every_channel_free_response(self):
    +        """'*' in free_response_channels exempts every channel from mention-required."""
    +        self.assertTrue(_channel_is_free_response("1234567890", "*"))
    +
    +    def test_wildcard_in_list_applies_everywhere(self):
    +        self.assertTrue(_channel_is_free_response("9999999999", "111,*,222"))
    +
    +    def test_exact_match_is_free_response(self):
    +        self.assertTrue(_channel_is_free_response("111", "111,222"))
    +
    +    def test_non_match_not_free_response(self):
    +        self.assertFalse(_channel_is_free_response("333", "111,222"))
    +
    +    def test_empty_list_no_free_response(self):
    +        self.assertFalse(_channel_is_free_response("111", ""))
    ```
  - `tests/hermes_state/test_resolve_resume_session_id.py`

    ```diff
    @@ -0,0 +1,96 @@
    +"""Regression guard for #15000: --resume <id> after compression loses messages.
    +
    +Context compression ends the current session and forks a new child session
    +(linked by ``parent_session_id``). The SQLite flush cursor is reset, so
    +only the latest descendant ends up with rows in the ``messages`` table —
    +the parent row has ``message_count = 0``. ``hermes --resume <parent_id>``
    +used to load zero rows and show a blank chat.
    +
    +``SessionDB.resolve_resume_session_id()`` walks the parent → child chain
    +and redirects to the first descendant that actually has messages. These
    +tests pin that behaviour.
    +"""
    +import time
    +
    +import pytest
    +
    +from hermes_state import SessionDB
    +
    +
    [redacted:email]
    +def db(tmp_path):
    +    return SessionDB(tmp_path / "state.db")
    +
    +
    +def _make_chain(db: SessionDB, ids_with_parent):
    +    """Create sessions in order, forcing started_at so ordering is deterministic."""
    +    base = int(time.time()) - 10_000
    +    for i, (sid, parent) in enumerate(ids_with_parent):
    +        db.create_session(sid, source="cli", parent_session_id=parent)
    +        db._conn.execute(
    +            "UPDATE sessions SET started_at = ? WHERE id = ?",
    +            (base + i * 100, sid),
    +        )
    +    db._conn.commit()
    +
    +
    +def test_redirects_from_empty_head_to_descendant_with_messages(db):
    +    # Reproducer shape from #15000: 6 sessions, only the 5th holds messages.
    +    _make_chain(db, [
    +        ("head",   None),
    +        ("mid1",   "head"),
    +        ("mid2",   "mid1"),
    +        ("mid3",   "mid2"),
    +        ("bulk",   "mid3"),    # has messages
    +        ("tail",   "bulk"),    # empty tail after another compression
    +    ])
    +    for i in range(5):
    +        db.append_message("bulk", role="user", content=f"msg {i}")
    +
    +    assert db.resolve_resume_session_id("head") == "bulk"
    +
    +
    +def test_returns_self_when_session_has_messages(db):
    +    _make_chain(db, [("root", None), ("child", "root")])
    +    db.append_message("root", role="user", content="hi")
    +    assert db.resolve_resume_session_id("root") == "root"
    +
    +
    +def test_returns_self_when_no_descendant_has_messages(db):
    +    _make_chain(db, [("root", None), ("child1", "root"), ("child2", "child1")])
    +    assert db.resolve_resume_session_id("root") == "root"
    +
    +
    +def test_returns_self_for_isolated_session(db):
    +    db.create_session("isolated", source="cli")
    +    assert db.resolve_resume_session_id("isolated") == "isolated"
    +
    +
    +def test_returns_self_for_nonexistent_session(db):
    +    assert db.resolve_resume_session_id("does_not_exist") == "does_not_exist"
    +
    +
    +def test_empty_session_id_passthrough(db):
    +    assert db.resolve_resume_session_id("") == ""
    +    assert db.resolve_resume_session_id(None) is None
    +
    +
    +def test_walks_from_middle_of_chain(db):
    +    # If the user happens to know an intermediate ID, we still find the msg-bearing descendant.
    +    _make_chain(db, [("a", None), ("b", "a"), ("c", "b"), ("d", "c")])
    +    db.append_message("d", role="user", content="x")
    +    assert db.resolve_resume_session_id("b") == "d"
    +    assert db.resolve_resume_session_id("c") == "d"
    +
    +
    +def test_prefers_most_recent_child_when_fork_exists(db):
    +    # If a session was somehow forked (two children), pick the latest one.
    +    # In practice, compression only produces single-chain shape, but the helper
    +    # should degrade gracefully.
    +    _make_chain(db, [
    +        ("parent", None),
    +        ("older_fork", "parent"),
    +        ("newer_fork", "parent"),
    +    ])
    +    db.append_message("newer_fork", role="user", content="x")
    +    assert db.resolve_resume_session_id("parent") == "newer_fork"
    ```
  - `tests/run_agent/test_create_openai_client_proxy_env.py`

    ```diff
    @@ -22,7 +22,7 @@
     
     import httpx
     
    -from run_agent import AIAgent, _get_proxy_from_env
    +from run_agent import AIAgent, _get_proxy_from_env, _get_proxy_for_base_url
     
     
     def _make_agent():
    ```

    ```diff
    @@ -143,3 +143,78 @@ def test_create_openai_client_no_proxy_when_env_unset(mock_openai, monkeypatch):
             "pools were %r" % (pool_types,)
         )
         http_client.close()
    +
    +
    +def test_get_proxy_for_base_url_returns_none_when_host_bypassed(monkeypatch):
    +    """NO_PROXY must suppress the proxy for matching base_urls.
    +
    +    Regression for #14966: users running a local inference endpoint
    +    (Ollama, LM Studio, llama.cpp) with a global HTTPS_PROXY would see
    +    the keepalive client route loopback traffic through the proxy, which
    +    typically answers 502 for local hosts. NO_PROXY should opt those
    +    hosts out via stdlib ``urllib.request.proxy_bypass_environment``.
    +    """
    +    for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
    +                "https_proxy", "http_proxy", "all_proxy",
    +                "NO_PROXY", "no_proxy"):
    +        monkeypatch.delenv(key, raising=False)
    +    monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
    +    monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,192.168.0.0/16")
    +
    +    # Local endpoint — must bypass the proxy.
    +    assert _get_proxy_for_base_url("http://127.0.0.1:11434/v1") is None
    +    assert _get_proxy_for_base_url("http://localhost:1234/v1") is None
    +
    +    # Non-local endpoint — proxy still applies.
    +    assert _get_proxy_for_base_url("https://api.openai.com/v1") == "http://127.0.0.1:7897"
    +
    +
    +def test_get_proxy_for_base_url_returns_proxy_when_no_proxy_unset(monkeypatch):
    +    for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
    +                "https_proxy", "http_proxy", "all_proxy",
    +                "NO_PROXY", "no_proxy"):
    +        monkeypatch.delenv(key, raising=False)
    +    monkeypatch.setenv("HTTPS_PROXY", "http://corp:8080")
    +    assert _get_proxy_for_base_url("http://127.0.0.1:11434/v1") == "http://corp:8080"
    +
    +
    +def test_get_proxy_for_base_url_returns_none_when_proxy_unset(monkeypatch):
    +    for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
    +                "https_proxy", "http_proxy", "all_proxy",
    +                "NO_PROXY", "no_proxy"):
    +        monkeypatch.delenv(key, raising=False)
    +    monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1")
    +    assert _get_proxy_for_base_url("http://127.0.0.1:11434/v1") is None
    +    assert _get_proxy_for_base_url("https://api.openai.com/v1") is None
    +
    +
    +@patch("run_agent.OpenAI")
    +def test_create_openai_client_bypasses_proxy_for_no_proxy_host(mock_openai, monkeypatch):
    +    """E2E: with HTTPS_PROXY + NO_PROXY=localhost, a local base_url gets a
    +    keepalive client with NO HTTPProxy mount."""
    +    for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
    +                "https_proxy", "http_proxy", "all_proxy",
    +                "NO_PROXY", "no_proxy"):
    +        monkeypatch.delenv(key, raising=False)
    +    monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
    +    monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1")
    +
    +    agent = _make_agent()
    +    kwargs = {
    +        "api_key": "***",
    +        "base_url": "http://127.0.0.1:11434/v1",
    +    }
    +    agent._create_openai_client(kwargs, reason="test", shared=False)
    +
    +    forwarded = mock_openai.call_args.kwargs
    +    http_client = _extract_http_client(forwarded)
    +    assert isinstance(http_client, httpx.Client)
    +    pool_types = [
    +        type(mount._pool).__name__
    +        for mount in http_client._mounts.values()
    +        if mount is not None and hasattr(mount, "_pool")
    +    ]
    +    assert "HTTPProxy" not in pool_types, (
    +        "NO_PROXY host must not route through HTTPProxy; pools were %r" % (pool_types,)
    +    )
    +    http_client.close()
    ```
- **#32289: fix(discord): read allowed_channels/ignored_channels from config.yaml**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `cc8e5ec2afbfd10a3cff4e710210dd9ecae64a33`, `#16078`
- **#32307: fix: honor discord.allowed_channels from config.yaml when env var unset**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#32289`

## Fix status

- Merged to main in PR #15052 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-24`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
