{"repo":"NousResearch/hermes-agent","public_id":"14920","display_label":"Discord allowed_channels config reading and wildcard behavior","summary":"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.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15052},"resolution":{"state":"partially_resolved","by":"pr-15052"},"freshness":{"state":"stale","last_verified_at":"2026-04-24T10:04:42+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":14920,"pr_number":15052,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":14920,"title":"Bug: Wildcard config values (allowed_channels: \"*\") silently drop all Discord messages","url":null,"state":"closed"},{"kind":"issue","number":32263,"title":"discord.allowed_channels config.yaml field is non-functional — only DISCORD_ALLOWED_CHANNELS env var is read","url":null,"state":"closed"},{"kind":"pr","number":14930,"title":"Fix/discord allowed channels wildcard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8598746e8","number":0},{"kind":"commit","value":"8a1e247c6","number":0},{"kind":"pr","value":"#15052","number":15052}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15052,"title":"fix: P1 batch — Discord wildcard, ACP+MCP, NO_PROXY bypass, resume-after-compression","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"acp_adapter/server.py","hunks":["@@ -60,7 +60,7 @@\n     make_tool_progress_cb,\n )\n from acp_adapter.permissions import make_approval_callback\n-from acp_adapter.session import SessionManager, SessionState\n+from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets\n \n logger = logging.getLogger(__name__)\n ","@@ -287,7 +287,11 @@ async def _register_session_mcp_servers(\n         try:\n             from model_tools import get_tool_definitions\n \n-            enabled_toolsets = getattr(state.agent, \"enabled_toolsets\", None) or [\"hermes-acp\"]\n+            enabled_toolsets = _expand_acp_enabled_toolsets(\n+                getattr(state.agent, \"enabled_toolsets\", None) or [\"hermes-acp\"],\n+                mcp_server_names=[server.name for server in mcp_servers],\n+            )\n+            state.agent.enabled_toolsets = enabled_toolsets\n             disabled_toolsets = getattr(state.agent, \"disabled_toolsets\", None)\n             state.agent.tools = get_tool_definitions(\n                 enabled_toolsets=enabled_toolsets,","@@ -754,7 +758,9 @@ def _cmd_model(self, args: str, state: SessionState) -> str:\n     def _cmd_tools(self, args: str, state: SessionState) -> str:\n         try:\n             from model_tools import get_tool_definitions\n-            toolsets = getattr(state.agent, \"enabled_toolsets\", None) or [\"hermes-acp\"]\n+            toolsets = _expand_acp_enabled_toolsets(\n+                getattr(state.agent, \"enabled_toolsets\", None) or [\"hermes-acp\"]\n+            )\n             tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)\n             if not tools:\n                 return \"No tools available.\""]},{"file":"acp_adapter/session.py","hunks":["@@ -106,6 +106,24 @@ def _register_task_cwd(task_id: str, cwd: str) -> None:\n         logger.debug(\"Failed to register ACP task cwd override\", exc_info=True)\n \n \n+def _expand_acp_enabled_toolsets(\n+    toolsets: List[str] | None = None,\n+    mcp_server_names: List[str] | None = None,\n+) -> List[str]:\n+    \"\"\"Return ACP toolsets plus explicit MCP server toolsets for this session.\"\"\"\n+    expanded: List[str] = []\n+    for name in list(toolsets or [\"hermes-acp\"]):\n+        if name and name not in expanded:\n+            expanded.append(name)\n+\n+    for server_name in list(mcp_server_names or []):\n+        toolset_name = f\"mcp-{server_name}\"\n+        if server_name and toolset_name not in expanded:\n+            expanded.append(toolset_name)\n+\n+    return expanded\n+\n+\n def _clear_task_cwd(task_id: str) -> None:\n     \"\"\"Remove task-specific cwd overrides for an ACP session.\"\"\"\n     if not task_id:","@@ -537,9 +555,18 @@ def _make_agent(\n         elif isinstance(model_cfg, str) and model_cfg.strip():\n             default_model = model_cfg.strip()\n \n+        configured_mcp_servers = [\n+            name\n+            for name, cfg in (config.get(\"mcp_servers\") or {}).items()\n+            if not isinstance(cfg, dict) or cfg.get(\"enabled\", True) is not False\n+        ]\n+\n         kwargs = {\n             \"platform\": \"acp\",\n-            \"enabled_toolsets\": [\"hermes-acp\"],\n+            \"enabled_toolsets\": _expand_acp_enabled_toolsets(\n+                [\"hermes-acp\"],\n+                mcp_server_names=configured_mcp_servers,\n+            ),\n             \"quiet_mode\": True,\n             \"session_id\": session_id,\n             \"model\": model or default_model,"]},{"file":"cli.py","hunks":["@@ -3254,6 +3254,23 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No\n                 _cprint(f\"\\033[1;31mSession not found: {self.session_id}{_RST}\")\n                 _cprint(f\"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}\")\n                 return False\n+            # If the requested session is the (empty) head of a compression\n+            # chain, walk to the descendant that actually holds the messages.\n+            # See #15000 and SessionDB.resolve_resume_session_id.\n+            try:\n+                resolved_id = self._session_db.resolve_resume_session_id(self.session_id)\n+            except Exception:\n+                resolved_id = self.session_id\n+            if resolved_id and resolved_id != self.session_id:\n+                ChatConsole().print(\n+                    f\"[{_DIM}]Session {_escape(self.session_id)} was compressed into \"\n+                    f\"{_escape(resolved_id)}; resuming the descendant with your \"\n+                    f\"transcript.[/]\"\n+                )\n+                self.session_id = resolved_id\n+                resolved_meta = self._session_db.get_session(self.session_id)\n+                if resolved_meta:\n+                    session_meta = resolved_meta\n             restored = self._session_db.get_messages_as_conversation(self.session_id)\n             if restored:\n                 restored = [m for m in restored if m.get(\"role\") != \"session_meta\"]","@@ -3472,6 +3489,22 @@ def _preload_resumed_session(self) -> bool:\n             )\n             return False\n \n+        # If the requested session is the (empty) head of a compression chain,\n+        # walk to the descendant that actually holds the messages. See #15000.\n+        try:\n+            resolved_id = self._session_db.resolve_resume_session_id(self.session_id)\n+        except Exception:\n+            resolved_id = self.session_id\n+        if resolved_id and resolved_id != self.session_id:\n+            self._console_print(\n+                f\"[dim]Session {self.session_id} was compressed into \"\n+                f\"{resolved_id}; resuming the descendant with your transcript.[/]\"\n+            )\n+            self.session_id = resolved_id\n+            resolved_meta = self._session_db.get_session(self.session_id)\n+            if resolved_meta:\n+                session_meta = resolved_meta\n+\n         restored = self._session_db.get_messages_as_conversation(self.session_id)\n         if restored:\n             restored = [m for m in restored if m.get(\"role\") != \"session_meta\"]","@@ -4686,6 +4719,22 @@ def _handle_resume_command(self, cmd_original: str) -> None:\n             _cprint(\"  Use /history or `hermes sessions list` to see available sessions.\")\n             return\n \n+        # If the target is the empty head of a compression chain, redirect to\n+        # the descendant that actually holds the transcript. See #15000.\n+        try:\n+            resolved_id = self._session_db.resolve_resume_session_id(target_id)\n+        except Exception:\n+            resolved_id = target_id\n+        if resolved_id and resolved_id != target_id:\n+            _cprint(\n+                f\"  Session {target_id} was compressed into {resolved_id}; \"\n+                f\"resuming the descendant with your transcript.\"\n+            )\n+            target_id = resolved_id\n+            resolved_meta = self._session_db.get_session(target_id)\n+            if resolved_meta:\n+                session_meta = resolved_meta\n+\n         if target_id == self.session_id:\n             _cprint(\"  Already on that session.\")\n             return"]},{"file":"gateway/platforms/discord.py","hunks":["@@ -2719,7 +2719,12 @@ def _discord_require_mention(self) -> bool:\n         return os.getenv(\"DISCORD_REQUIRE_MENTION\", \"true\").lower() not in (\"false\", \"0\", \"no\", \"off\")\n \n     def _discord_free_response_channels(self) -> set:\n-        \"\"\"Return Discord channel IDs where no bot mention is required.\"\"\"\n+        \"\"\"Return Discord channel IDs where no bot mention is required.\n+\n+        A single ``\"*\"`` entry (either from a list or a comma-separated\n+        string) is preserved in the returned set so callers can short-circuit\n+        on wildcard membership, consistent with ``allowed_channels``.\n+        \"\"\"\n         raw = self.config.extra.get(\"free_response_channels\")\n         if raw is None:\n             raw = os.getenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", \"\")","@@ -3212,14 +3217,14 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n             allowed_channels_raw = os.getenv(\"DISCORD_ALLOWED_CHANNELS\", \"\")\n             if allowed_channels_raw:\n                 allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(\",\") if ch.strip()}\n-                if not (channel_ids & allowed_channels):\n+                if \"*\" not in allowed_channels and not (channel_ids & allowed_channels):\n                     logger.debug(\"[%s] Ignoring message in non-allowed channel: %s\", self.name, channel_ids)\n                     return\n \n             # Check ignored channels - never respond even when mentioned\n             ignored_channels_raw = os.getenv(\"DISCORD_IGNORED_CHANNELS\", \"\")\n             ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(\",\") if ch.strip()}\n-            if channel_ids & ignored_channels:\n+            if \"*\" in ignored_channels or (channel_ids & ignored_channels):\n                 logger.debug(\"[%s] Ignoring message in ignored channel: %s\", self.name, channel_ids)\n                 return\n ","@@ -3233,7 +3238,11 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n             voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()}\n             current_channel_id = str(message.channel.id)\n             is_voice_linked_channel = current_channel_id in voice_linked_ids\n-            is_free_channel = bool(channel_ids & free_channels) or is_voice_linked_channel\n+            is_free_channel = (\n+                \"*\" in free_channels\n+                or bool(channel_ids & free_channels)\n+                or is_voice_linked_channel\n+            )\n \n             # Skip the mention check if the message is in a thread where\n             # the bot has previously participated (auto-created or replied in)."]},{"file":"hermes_state.py","hunks":["@@ -1039,6 +1039,71 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]:\n             result.append(msg)\n         return result\n \n+    def resolve_resume_session_id(self, session_id: str) -> str:\n+        \"\"\"Redirect a resume target to the descendant session that holds the messages.\n+\n+        Context compression ends the current session and forks a new child session\n+        (linked via ``parent_session_id``). The flush cursor is reset, so the\n+        child is where new messages actually land — the parent ends up with\n+        ``message_count = 0`` rows unless messages had already been flushed to\n+        it before compression. See #15000.\n+\n+        This helper walks ``parent_session_id`` forward from ``session_id`` and\n+        returns the first descendant in the chain that has at least one message\n+        row. If the original session already has messages, or no descendant\n+        has any, the original ``session_id`` is returned unchanged.\n+\n+        The chain is always walked via the child whose ``started_at`` is\n+        latest; that matches the single-chain shape that compression creates.\n+        A depth cap (32) guards against accidental loops in malformed data.\n+        \"\"\"\n+        if not session_id:\n+            return session_id\n+\n+        with self._lock:\n+            # If this session already has messages, nothing to redirect.\n+            try:\n+                row = self._conn.execute(\n+                    \"SELECT 1 FROM messages WHERE session_id = ? LIMIT 1\",\n+                    (session_id,),\n+                ).fetchone()\n+            except Exception:\n+                return session_id\n+            if row is not None:\n+                return session_id\n+\n+            # Walk descendants: at each step, pick the most-recently-started\n+                # child session; stop once we find one with messages.\n+            current = session_id\n+            seen = {current}\n+            for _ in range(32):\n+                try:\n+                    child_row = self._conn.execute(\n+                        \"SELECT id FROM sessions \"\n+                        \"WHERE parent_session_id = ? \"\n+                        \"ORDER BY started_at DESC, id DESC LIMIT 1\",\n+                        (current,),\n+                    ).fetchone()\n+                except Exception:\n+                    return session_id\n+                if child_row is None:\n+                    return session_id\n+                child_id = child_row[\"id\"] if hasattr(child_row, \"keys\") else child_row[0]\n+                if not child_id or child_id in seen:\n+                    return session_id\n+                seen.add(child_id)\n+                try:\n+                    msg_row = self._conn.execute(\n+                        \"SELECT 1 FROM messages WHERE session_id = ? LIMIT 1\",\n+                        (child_id,),\n+                    ).fetchone()\n+                except Exception:\n+                    return session_id\n+                if msg_row is not None:\n+                    return child_id\n+                current = child_id\n+        return session_id\n+\n     def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:\n         \"\"\"\n         Load messages in the OpenAI conversation format (role + content dicts)."]},{"file":"run_agent.py","hunks":["@@ -36,6 +36,7 @@\n import time\n import threading\n from types import SimpleNamespace\n+import urllib.request\n import uuid\n from typing import List, Dict, Any, Optional\n from openai import OpenAI","@@ -181,6 +182,25 @@ def _get_proxy_from_env() -> Optional[str]:\n     return None\n \n \n+def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]:\n+    \"\"\"Return an env-configured proxy unless NO_PROXY excludes this base URL.\"\"\"\n+    proxy = _get_proxy_from_env()\n+    if not proxy or not base_url:\n+        return proxy\n+\n+    host = base_url_hostname(base_url)\n+    if not host:\n+        return proxy\n+\n+    try:\n+        if urllib.request.proxy_bypass_environment(host):\n+            return None\n+    except Exception:\n+        pass\n+\n+    return proxy\n+\n+\n def _install_safe_stdio() -> None:\n     \"\"\"Wrap stdout/stderr so best-effort console output cannot crash the agent.\"\"\"\n     for stream_name in (\"stdout\", \"stderr\"):","@@ -4466,7 +4486,7 @@ def _is_openai_client_closed(client: Any) -> bool:\n         return False\n \n     @staticmethod\n-    def _build_keepalive_http_client() -> Any:\n+    def _build_keepalive_http_client(base_url: str = \"\") -> Any:\n         try:\n             import httpx as _httpx\n             import socket as _socket","@@ -4480,8 +4500,9 @@ def _build_keepalive_http_client() -> Any:\n                 _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPALIVE, 30))\n             # When a custom transport is provided, httpx won't auto-read proxy\n             # from env vars (allow_env_proxies = trust_env and transport is None).\n-            # Explicitly read proxy settings to ensure HTTP_PROXY/HTTPS_PROXY work.\n-            _proxy = _get_proxy_from_env()\n+            # Explicitly read proxy settings while still honoring NO_PROXY for\n+            # loopback / local endpoints such as a locally hosted sub2api.\n+            _proxy = _get_proxy_for_base_url(base_url)\n             return _httpx.Client(\n                 transport=_httpx.HTTPTransport(socket_options=_sock_opts),\n                 proxy=_proxy,","@@ -4539,7 +4560,7 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo\n                     if k in {\"api_key\", \"base_url\", \"default_headers\", \"timeout\", \"http_client\"}\n                 }\n                 if \"http_client\" not in safe_kwargs:\n-                    keepalive_http = self._build_keepalive_http_client()\n+                    keepalive_http = self._build_keepalive_http_client(base_url)\n                     if keepalive_http is not None:\n                         safe_kwargs[\"http_client\"] = keepalive_http\n                 client = GeminiNativeClient(**safe_kwargs)","@@ -4568,7 +4589,7 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo\n         # Tests in ``tests/run_agent/test_create_openai_client_reuse.py`` and\n         # ``tests/run_agent/test_sequential_chats_live.py`` pin this invariant.\n         if \"http_client\" not in client_kwargs:\n-            keepalive_http = self._build_keepalive_http_client()\n+            keepalive_http = self._build_keepalive_http_client(client_kwargs.get(\"base_url\", \"\"))\n             if keepalive_http is not None:\n                 client_kwargs[\"http_client\"] = keepalive_http\n         client = OpenAI(**client_kwargs)"]},{"file":"scripts/release.py","hunks":["@@ -438,6 +438,10 @@\n     \"[redacted:email]\": \"topcheer\",\n     \"[redacted:email]\": \"walli\",\n     \"[redacted:email]\": \"Zhuofeng-Wang\",\n+    # April 2026 salvage-PR batch (#14920, #14986, #14966)\n+    \"[redacted:email]\": \"mrunmayee17\",\n+    \"[redacted:email]\": \"camaragon\",\n+    \"[redacted:email]\": \"shamork\",\n     # no-github-match — keep as display names\n     \"[redacted:email]\": \"Sisyphus\",\n     \"[redacted:email]\": \"Marco Rutsch\","]},{"file":"tests/acp/test_server.py","hunks":["@@ -904,9 +904,15 @@ async def test_refreshes_agent_tool_surface(self, agent, mock_manager):\n         ]\n \n         with patch(\"tools.mcp_tool.register_mcp_servers\", return_value=[\"mcp_srv_search\"]), \\\n-             patch(\"model_tools.get_tool_definitions\", return_value=fake_tools):\n+             patch(\"model_tools.get_tool_definitions\", return_value=fake_tools) as mock_defs:\n             await agent._register_session_mcp_servers(state, [server])\n \n+        mock_defs.assert_called_once_with(\n+            enabled_toolsets=[\"hermes-acp\", \"mcp-srv\"],\n+            disabled_toolsets=None,\n+            quiet_mode=True,\n+        )\n+        assert state.agent.enabled_toolsets == [\"hermes-acp\", \"mcp-srv\"]\n         assert state.agent.tools == fake_tools\n         assert state.agent.valid_tool_names == {\"mcp_srv_search\", \"terminal\"}\n         # _invalidate_system_prompt should have been called"]},{"file":"tests/acp/test_session.py","hunks":["@@ -138,6 +138,43 @@ def test_remove_session(self, manager):\n class TestPersistence:\n     \"\"\"Verify that sessions are persisted to SessionDB and can be restored.\"\"\"\n \n+    def test_create_session_includes_registered_mcp_toolsets(self, tmp_path, monkeypatch):\n+        captured = {}\n+\n+        def fake_resolve_runtime_provider(requested=None, **kwargs):\n+            return {\n+                \"provider\": \"openrouter\",\n+                \"api_mode\": \"chat_completions\",\n+                \"base_url\": \"https://openrouter.example/v1\",\n+                \"api_key\": \"***\",\n+                \"command\": None,\n+                \"args\": [],\n+            }\n+\n+        def fake_agent(**kwargs):\n+            captured.update(kwargs)\n+            return SimpleNamespace(model=kwargs.get(\"model\"), enabled_toolsets=kwargs.get(\"enabled_toolsets\"))\n+\n+        monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: {\n+            \"model\": {\"provider\": \"openrouter\", \"default\": \"test-model\"},\n+            \"mcp_servers\": {\n+                \"olympus\": {\"command\": \"python\", \"enabled\": True},\n+                \"exa\": {\"url\": \"https://exa.ai/mcp\"},\n+                \"disabled\": {\"command\": \"python\", \"enabled\": False},\n+            },\n+        })\n+        monkeypatch.setattr(\n+            \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+            fake_resolve_runtime_provider,\n+        )\n+        db = SessionDB(tmp_path / \"state.db\")\n+\n+        with patch(\"run_agent.AIAgent\", side_effect=fake_agent):\n+            manager = SessionManager(db=db)\n+            manager.create_session(cwd=\"/work\")\n+\n+        assert captured[\"enabled_toolsets\"] == [\"hermes-acp\", \"mcp-olympus\", \"mcp-exa\"]\n+\n     def test_create_session_writes_to_db(self, manager):\n         state = manager.create_session(cwd=\"/project\")\n         db = manager._get_db()"]},{"file":"tests/gateway/test_discord_allowed_channels.py","hunks":["@@ -0,0 +1,104 @@\n+\"\"\"Regression guard for #14920: wildcard \"*\" in Discord channel config lists.\n+\n+Setting ``allowed_channels: \"*\"``, ``free_response_channels: \"*\"``, or\n+``ignored_channels: \"*\"`` in config (or their ``DISCORD_*_CHANNELS`` env var\n+equivalents) must behave as a wildcard — i.e. the bot responds in every\n+channel (or is silenced in every channel, for the ignored list). Previously\n+the literal string \"*\" was placed into a set and compared against numeric\n+channel IDs via set-intersection, which always produced an empty set and\n+caused every message to be silently dropped (for ``allowed_channels``) or\n+every ``free_response`` / ``ignored`` check to fail open.\n+\"\"\"\n+\n+import unittest\n+\n+\n+def _channel_is_allowed(channel_id: str, allowed_channels_raw: str) -> bool:\n+    \"\"\"Replicate the channel-allow-list check from discord.py on_message.\"\"\"\n+    if not allowed_channels_raw:\n+        return True\n+    allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(\",\") if ch.strip()}\n+    if \"*\" in allowed_channels:\n+        return True\n+    return bool({channel_id} & allowed_channels)\n+\n+\n+def _channel_is_ignored(channel_id: str, ignored_channels_raw: str) -> bool:\n+    \"\"\"Replicate the ignored-channel check from discord.py on_message.\"\"\"\n+    ignored_channels = {\n+        ch.strip() for ch in ignored_channels_raw.split(\",\") if ch.strip()\n+    }\n+    return \"*\" in ignored_channels or bool({channel_id} & ignored_channels)\n+\n+\n+def _channel_is_free_response(channel_id: str, free_channels_raw: str) -> bool:\n+    \"\"\"Replicate the free-response-channel check from discord.py on_message.\"\"\"\n+    free_channels = {\n+        ch.strip() for ch in free_channels_raw.split(\",\") if ch.strip()\n+    }\n+    return \"*\" in free_channels or bool({channel_id} & free_channels)\n+\n+\n+class TestDiscordAllowedChannelsWildcard(unittest.TestCase):\n+    \"\"\"Wildcard and channel-list behaviour for DISCORD_ALLOWED_CHANNELS.\"\"\"\n+\n+    def test_wildcard_allows_any_channel(self):\n+        \"\"\"'*' should allow messages from any channel ID.\"\"\"\n+        self.assertTrue(_channel_is_allowed(\"1234567890\", \"*\"))\n+\n+    def test_wildcard_in_list_allows_any_channel(self):\n+        \"\"\"'*' mixed with other entries still allows any channel.\"\"\"\n+        self.assertTrue(_channel_is_allowed(\"9999999999\", \"111,*,222\"))\n+\n+    def test_exact_match_allowed(self):\n+        \"\"\"Channel ID present in the explicit list is allowed.\"\"\"\n+        self.assertTrue(_channel_is_allowed(\"1234567890\", \"1234567890,9876543210\"))\n+\n+    def test_non_matching_channel_blocked(self):\n+        \"\"\"Channel ID absent from the explicit list is blocked.\"\"\"\n+        self.assertFalse(_channel_is_allowed(\"5555555555\", \"1234567890,9876543210\"))\n+\n+    def test_empty_allowlist_allows_all(self):\n+        \"\"\"Empty DISCORD_ALLOWED_CHANNELS means no restriction.\"\"\"\n+        self.assertTrue(_channel_is_allowed(\"1234567890\", \"\"))\n+\n+    def test_whitespace_only_entry_ignored(self):\n+        \"\"\"Entries that are only whitespace are stripped and ignored.\"\"\"\n+        self.assertFalse(_channel_is_allowed(\"1234567890\", \"  ,  \"))\n+\n+\n+class TestDiscordIgnoredChannelsWildcard(unittest.TestCase):\n+    \"\"\"Wildcard and channel-list behaviour for DISCORD_IGNORED_CHANNELS.\"\"\"\n+\n+    def test_wildcard_silences_every_channel(self):\n+        \"\"\"'*' in ignored_channels silences the bot everywhere.\"\"\"\n+        self.assertTrue(_channel_is_ignored(\"1234567890\", \"*\"))\n+\n+    def test_empty_ignored_list_silences_nothing(self):\n+        self.assertFalse(_channel_is_ignored(\"1234567890\", \"\"))\n+\n+    def test_exact_match_is_ignored(self):\n+        self.assertTrue(_channel_is_ignored(\"111\", \"111,222\"))\n+\n+    def test_non_match_not_ignored(self):\n+        self.assertFalse(_channel_is_ignored(\"333\", \"111,222\"))\n+\n+\n+class TestDiscordFreeResponseChannelsWildcard(unittest.TestCase):\n+    \"\"\"Wildcard and channel-list behaviour for DISCORD_FREE_RESPONSE_CHANNELS.\"\"\"\n+\n+    def test_wildcard_makes_every_channel_free_response(self):\n+        \"\"\"'*' in free_response_channels exempts every channel from mention-required.\"\"\"\n+        self.assertTrue(_channel_is_free_response(\"1234567890\", \"*\"))\n+\n+    def test_wildcard_in_list_applies_everywhere(self):\n+        self.assertTrue(_channel_is_free_response(\"9999999999\", \"111,*,222\"))\n+\n+    def test_exact_match_is_free_response(self):\n+        self.assertTrue(_channel_is_free_response(\"111\", \"111,222\"))\n+\n+    def test_non_match_not_free_response(self):\n+        self.assertFalse(_channel_is_free_response(\"333\", \"111,222\"))\n+\n+    def test_empty_list_no_free_response(self):\n+        self.assertFalse(_channel_is_free_response(\"111\", \"\"))"]},{"file":"tests/hermes_state/test_resolve_resume_session_id.py","hunks":["@@ -0,0 +1,96 @@\n+\"\"\"Regression guard for #15000: --resume <id> after compression loses messages.\n+\n+Context compression ends the current session and forks a new child session\n+(linked by ``parent_session_id``). The SQLite flush cursor is reset, so\n+only the latest descendant ends up with rows in the ``messages`` table —\n+the parent row has ``message_count = 0``. ``hermes --resume <parent_id>``\n+used to load zero rows and show a blank chat.\n+\n+``SessionDB.resolve_resume_session_id()`` walks the parent → child chain\n+and redirects to the first descendant that actually has messages. These\n+tests pin that behaviour.\n+\"\"\"\n+import time\n+\n+import pytest\n+\n+from hermes_state import SessionDB\n+\n+\n[redacted:email]\n+def db(tmp_path):\n+    return SessionDB(tmp_path / \"state.db\")\n+\n+\n+def _make_chain(db: SessionDB, ids_with_parent):\n+    \"\"\"Create sessions in order, forcing started_at so ordering is deterministic.\"\"\"\n+    base = int(time.time()) - 10_000\n+    for i, (sid, parent) in enumerate(ids_with_parent):\n+        db.create_session(sid, source=\"cli\", parent_session_id=parent)\n+        db._conn.execute(\n+            \"UPDATE sessions SET started_at = ? WHERE id = ?\",\n+            (base + i * 100, sid),\n+        )\n+    db._conn.commit()\n+\n+\n+def test_redirects_from_empty_head_to_descendant_with_messages(db):\n+    # Reproducer shape from #15000: 6 sessions, only the 5th holds messages.\n+    _make_chain(db, [\n+        (\"head\",   None),\n+        (\"mid1\",   \"head\"),\n+        (\"mid2\",   \"mid1\"),\n+        (\"mid3\",   \"mid2\"),\n+        (\"bulk\",   \"mid3\"),    # has messages\n+        (\"tail\",   \"bulk\"),    # empty tail after another compression\n+    ])\n+    for i in range(5):\n+        db.append_message(\"bulk\", role=\"user\", content=f\"msg {i}\")\n+\n+    assert db.resolve_resume_session_id(\"head\") == \"bulk\"\n+\n+\n+def test_returns_self_when_session_has_messages(db):\n+    _make_chain(db, [(\"root\", None), (\"child\", \"root\")])\n+    db.append_message(\"root\", role=\"user\", content=\"hi\")\n+    assert db.resolve_resume_session_id(\"root\") == \"root\"\n+\n+\n+def test_returns_self_when_no_descendant_has_messages(db):\n+    _make_chain(db, [(\"root\", None), (\"child1\", \"root\"), (\"child2\", \"child1\")])\n+    assert db.resolve_resume_session_id(\"root\") == \"root\"\n+\n+\n+def test_returns_self_for_isolated_session(db):\n+    db.create_session(\"isolated\", source=\"cli\")\n+    assert db.resolve_resume_session_id(\"isolated\") == \"isolated\"\n+\n+\n+def test_returns_self_for_nonexistent_session(db):\n+    assert db.resolve_resume_session_id(\"does_not_exist\") == \"does_not_exist\"\n+\n+\n+def test_empty_session_id_passthrough(db):\n+    assert db.resolve_resume_session_id(\"\") == \"\"\n+    assert db.resolve_resume_session_id(None) is None\n+\n+\n+def test_walks_from_middle_of_chain(db):\n+    # If the user happens to know an intermediate ID, we still find the msg-bearing descendant.\n+    _make_chain(db, [(\"a\", None), (\"b\", \"a\"), (\"c\", \"b\"), (\"d\", \"c\")])\n+    db.append_message(\"d\", role=\"user\", content=\"x\")\n+    assert db.resolve_resume_session_id(\"b\") == \"d\"\n+    assert db.resolve_resume_session_id(\"c\") == \"d\"\n+\n+\n+def test_prefers_most_recent_child_when_fork_exists(db):\n+    # If a session was somehow forked (two children), pick the latest one.\n+    # In practice, compression only produces single-chain shape, but the helper\n+    # should degrade gracefully.\n+    _make_chain(db, [\n+        (\"parent\", None),\n+        (\"older_fork\", \"parent\"),\n+        (\"newer_fork\", \"parent\"),\n+    ])\n+    db.append_message(\"newer_fork\", role=\"user\", content=\"x\")\n+    assert db.resolve_resume_session_id(\"parent\") == \"newer_fork\""]},{"file":"tests/run_agent/test_create_openai_client_proxy_env.py","hunks":["@@ -22,7 +22,7 @@\n \n import httpx\n \n-from run_agent import AIAgent, _get_proxy_from_env\n+from run_agent import AIAgent, _get_proxy_from_env, _get_proxy_for_base_url\n \n \n def _make_agent():","@@ -143,3 +143,78 @@ def test_create_openai_client_no_proxy_when_env_unset(mock_openai, monkeypatch):\n         \"pools were %r\" % (pool_types,)\n     )\n     http_client.close()\n+\n+\n+def test_get_proxy_for_base_url_returns_none_when_host_bypassed(monkeypatch):\n+    \"\"\"NO_PROXY must suppress the proxy for matching base_urls.\n+\n+    Regression for #14966: users running a local inference endpoint\n+    (Ollama, LM Studio, llama.cpp) with a global HTTPS_PROXY would see\n+    the keepalive client route loopback traffic through the proxy, which\n+    typically answers 502 for local hosts. NO_PROXY should opt those\n+    hosts out via stdlib ``urllib.request.proxy_bypass_environment``.\n+    \"\"\"\n+    for key in (\"HTTPS_PROXY\", \"HTTP_PROXY\", \"ALL_PROXY\",\n+                \"https_proxy\", \"http_proxy\", \"all_proxy\",\n+                \"NO_PROXY\", \"no_proxy\"):\n+        monkeypatch.delenv(key, raising=False)\n+    monkeypatch.setenv(\"HTTPS_PROXY\", \"http://127.0.0.1:7897\")\n+    monkeypatch.setenv(\"NO_PROXY\", \"localhost,127.0.0.1,192.168.0.0/16\")\n+\n+    # Local endpoint — must bypass the proxy.\n+    assert _get_proxy_for_base_url(\"http://127.0.0.1:11434/v1\") is None\n+    assert _get_proxy_for_base_url(\"http://localhost:1234/v1\") is None\n+\n+    # Non-local endpoint — proxy still applies.\n+    assert _get_proxy_for_base_url(\"https://api.openai.com/v1\") == \"http://127.0.0.1:7897\"\n+\n+\n+def test_get_proxy_for_base_url_returns_proxy_when_no_proxy_unset(monkeypatch):\n+    for key in (\"HTTPS_PROXY\", \"HTTP_PROXY\", \"ALL_PROXY\",\n+                \"https_proxy\", \"http_proxy\", \"all_proxy\",\n+                \"NO_PROXY\", \"no_proxy\"):\n+        monkeypatch.delenv(key, raising=False)\n+    monkeypatch.setenv(\"HTTPS_PROXY\", \"http://corp:8080\")\n+    assert _get_proxy_for_base_url(\"http://127.0.0.1:11434/v1\") == \"http://corp:8080\"\n+\n+\n+def test_get_proxy_for_base_url_returns_none_when_proxy_unset(monkeypatch):\n+    for key in (\"HTTPS_PROXY\", \"HTTP_PROXY\", \"ALL_PROXY\",\n+                \"https_proxy\", \"http_proxy\", \"all_proxy\",\n+                \"NO_PROXY\", \"no_proxy\"):\n+        monkeypatch.delenv(key, raising=False)\n+    monkeypatch.setenv(\"NO_PROXY\", \"localhost,127.0.0.1\")\n+    assert _get_proxy_for_base_url(\"http://127.0.0.1:11434/v1\") is None\n+    assert _get_proxy_for_base_url(\"https://api.openai.com/v1\") is None\n+\n+\n+@patch(\"run_agent.OpenAI\")\n+def test_create_openai_client_bypasses_proxy_for_no_proxy_host(mock_openai, monkeypatch):\n+    \"\"\"E2E: with HTTPS_PROXY + NO_PROXY=localhost, a local base_url gets a\n+    keepalive client with NO HTTPProxy mount.\"\"\"\n+    for key in (\"HTTPS_PROXY\", \"HTTP_PROXY\", \"ALL_PROXY\",\n+                \"https_proxy\", \"http_proxy\", \"all_proxy\",\n+                \"NO_PROXY\", \"no_proxy\"):\n+        monkeypatch.delenv(key, raising=False)\n+    monkeypatch.setenv(\"HTTPS_PROXY\", \"http://127.0.0.1:7897\")\n+    monkeypatch.setenv(\"NO_PROXY\", \"localhost,127.0.0.1\")\n+\n+    agent = _make_agent()\n+    kwargs = {\n+        \"api_key\": \"***\",\n+        \"base_url\": \"http://127.0.0.1:11434/v1\",\n+    }\n+    agent._create_openai_client(kwargs, reason=\"test\", shared=False)\n+\n+    forwarded = mock_openai.call_args.kwargs\n+    http_client = _extract_http_client(forwarded)\n+    assert isinstance(http_client, httpx.Client)\n+    pool_types = [\n+        type(mount._pool).__name__\n+        for mount in http_client._mounts.values()\n+        if mount is not None and hasattr(mount, \"_pool\")\n+    ]\n+    assert \"HTTPProxy\" not in pool_types, (\n+        \"NO_PROXY host must not route through HTTPProxy; pools were %r\" % (pool_types,)\n+    )\n+    http_client.close()\n"]}]},{"kind":"pr","number":32289,"title":"fix(discord): read allowed_channels/ignored_channels from config.yaml","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cc8e5ec2afbfd10a3cff4e710210dd9ecae64a33","number":0},{"kind":"pr","value":"#16078","number":16078}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":32307,"title":"fix: honor discord.allowed_channels from config.yaml when env var unset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32289","number":32289}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}