{"repo":"NousResearch/hermes-agent","public_id":"6218","display_label":"Slack adapter require_mention config handling","summary":"These issues and PRs concern the Slack adapter's handling of the require_mention configuration option, including reading it from config, respecting it for channel messages, and related Slack adapter configuration improvements such as free_response_channels and enable_reactions.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":6809,"merged_at":"2026-04-09T21:07:32+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-09T21:07:32+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":3198,"pr_number":6809,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-09T21:07:32+00:00"},{"issue_number":6218,"pr_number":6809,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-09T21:07:32+00:00"}],"members":[{"kind":"issue","number":6218,"title":"Slack adapter ignores require_mention: false config for channel messages","url":null,"state":"closed"},{"kind":"pr","number":6268,"title":"fix(slack): respect require_mention config for channel messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#6809","number":6809},{"kind":"pr","value":"#5885","number":5885}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":6303,"title":"fix(gateway): read require_mention from config.extra for Slack adapter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#6809","number":6809},{"kind":"pr","value":"#5885","number":5885}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":6809,"title":"feat(slack): consolidated Slack adapter improvements — 7 community PRs salvaged","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/slack.py","gateway/platforms/telegram.py","hermes_state.py","tests/gateway/test_slack.py","tests/gateway/test_slack_mention.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -532,6 +532,8 @@ def load_gateway_config() -> GatewayConfig:\n                     bridged[\"reply_prefix\"] = platform_cfg[\"reply_prefix\"]\n                 if \"require_mention\" in platform_cfg:\n                     bridged[\"require_mention\"] = platform_cfg[\"require_mention\"]\n+                if \"free_response_channels\" in platform_cfg:\n+                    bridged[\"free_response_channels\"] = platform_cfg[\"free_response_channels\"]\n                 if \"mention_patterns\" in platform_cfg:\n                     bridged[\"mention_patterns\"] = platform_cfg[\"mention_patterns\"]\n                 if not bridged:","@@ -546,6 +548,19 @@ def load_gateway_config() -> GatewayConfig:\n                     plat_data[\"extra\"] = extra\n                 extra.update(bridged)\n \n+            # Slack settings → env vars (env vars take precedence)\n+            slack_cfg = yaml_cfg.get(\"slack\", {})\n+            if isinstance(slack_cfg, dict):\n+                if \"require_mention\" in slack_cfg and not os.getenv(\"SLACK_REQUIRE_MENTION\"):\n+                    os.environ[\"SLACK_REQUIRE_MENTION\"] = str(slack_cfg[\"require_mention\"]).lower()\n+                if \"allow_bots\" in slack_cfg and not os.getenv(\"SLACK_ALLOW_BOTS\"):\n+                    os.environ[\"SLACK_ALLOW_BOTS\"] = str(slack_cfg[\"allow_bots\"]).lower()\n+                frc = slack_cfg.get(\"free_response_channels\")\n+                if frc is not None and not os.getenv(\"SLACK_FREE_RESPONSE_CHANNELS\"):\n+                    if isinstance(frc, list):\n+                        frc = \",\".join(str(v) for v in frc)\n+                    os.environ[\"SLACK_FREE_RESPONSE_CHANNELS\"] = str(frc)\n+\n             # Discord settings → env vars (env vars take precedence)\n             discord_cfg = yaml_cfg.get(\"discord\", {})\n             if isinstance(discord_cfg, dict):"]},{"file":"gateway/platforms/slack.py","hunks":["@@ -14,6 +14,7 @@\n import os\n import re\n import time\n+from dataclasses import dataclass, field\n from typing import Dict, Optional, Any, Tuple\n \n try:","@@ -45,6 +46,14 @@\n logger = logging.getLogger(__name__)\n \n \n+@dataclass\n+class _ThreadContextCache:\n+    \"\"\"Cache entry for fetched thread context.\"\"\"\n+    content: str\n+    fetched_at: float = field(default_factory=time.monotonic)\n+    message_count: int = 0\n+\n+\n def check_slack_requirements() -> bool:\n     \"\"\"Check if Slack dependencies are available.\"\"\"\n     return SLACK_AVAILABLE","@@ -101,6 +110,9 @@ def __init__(self, config: PlatformConfig):\n         # session + memory scoping.\n         self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {}\n         self._ASSISTANT_THREADS_MAX = 5000\n+        # Cache for _fetch_thread_context results: cache_key → _ThreadContextCache\n+        self._thread_context_cache: Dict[str, _ThreadContextCache] = {}\n+        self._THREAD_CACHE_TTL = 60.0\n \n     async def connect(self) -> bool:\n         \"\"\"Connect to Slack via Socket Mode.\"\"\"","@@ -281,6 +293,7 @@ async def send(\n                 kwargs = {\n                     \"channel\": chat_id,\n                     \"text\": chunk,\n+                    \"mrkdwn\": True,\n                 }\n                 if thread_ts:\n                     kwargs[\"thread_ts\"] = thread_ts","@@ -323,9 +336,7 @@ async def edit_message(\n         if not self._app:\n             return SendResult(success=False, error=\"Not connected\")\n         try:\n-            # Convert standard markdown → Slack mrkdwn\n             formatted = self.format_message(content)\n-\n             await self._get_client(chat_id).chat_update(\n                 channel=chat_id,\n                 ts=message_id,","@@ -457,13 +468,36 @@ def _ph(value: str) -> str:\n         text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text)\n \n         # 3) Convert markdown links [text](url) → <url|text>\n+        def _convert_markdown_link(m):\n+            label = m.group(1)\n+            url = m.group(2).strip()\n+            if url.startswith('<') and url.endswith('>'):\n+                url = url[1:-1].strip()\n+            return _ph(f'<{url}|{label}>')\n+\n+        text = re.sub(\n+            r'\\[([^\\]]+)\\]\\(([^()]*(?:\\([^()]*\\)[^()]*)*)\\)',\n+            _convert_markdown_link,\n+            text,\n+        )\n+\n+        # 4) Protect existing Slack entities/manual links so escaping and later\n+        #    formatting passes don't break them.\n         text = re.sub(\n-            r'\\[([^\\]]+)\\]\\(([^)]+)\\)',\n-            lambda m: _ph(f'<{m.group(2)}|{m.group(1)}>'),\n+            r'(<(?:[@#!]|(?:https?|mailto|tel):)[^>\\n]+>)',\n+            lambda m: _ph(m.group(1)),\n             text,\n         )\n \n-        # 4) Convert headers (## Title) → *Title* (bold)\n+        # 5) Protect blockquote markers before escaping\n+        text = re.sub(r'^(>+\\s)', lambda m: _ph(m.group(0)), text, flags=re.MULTILINE)\n+\n+        # 6) Escape Slack control characters in remaining plain text.\n+        # Unescape first so already-escaped input doesn't get double-escaped.\n+        text = text.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>')\n+        text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')\n+\n+        # 7) Convert headers (## Title) → *Title* (bold)\n         def _convert_header(m):\n             inner = m.group(1).strip()\n             # Strip redundant bold markers inside a header","@@ -474,34 +508,39 @@ def _convert_header(m):\n             r'^#{1,6}\\s+(.+)$', _convert_header, text, flags=re.MULTILINE\n         )\n \n-        # 5) Convert bold: **text** → *text* (Slack bold)\n+        # 8) Convert bold+italic: ***text*** → *_text_* (Slack bold wrapping italic)\n+        text = re.sub(\n+            r'\\*\\*\\*(.+?)\\*\\*\\*',\n+            lambda m: _ph(f'*_{m.group(1)}_*'),\n+            text,\n+        )\n+\n+        # 9) Convert bold: **text** → *text* (Slack bold)\n         text = re.sub(\n             r'\\*\\*(.+?)\\*\\*',\n             lambda m: _ph(f'*{m.group(1)}*'),\n             text,\n         )\n \n-        # 6) Convert italic: _text_ stays as _text_ (already Slack italic)\n-        #    Single *text* → _text_ (Slack italic)\n+        # 10) Convert italic: _text_ stays as _text_ (already Slack italic)\n+        #     Single *text* → _text_ (Slack italic)\n         text = re.sub(\n             r'(?<!\\*)\\*([^*\\n]+)\\*(?!\\*)',\n             lambda m: _ph(f'_{m.group(1)}_'),\n             text,\n         )\n \n-        # 7) Convert strikethrough: ~~text~~ → ~text~\n+        # 11) Convert strikethrough: ~~text~~ → ~text~\n         text = re.sub(\n             r'~~(.+?)~~',\n             lambda m: _ph(f'~{m.group(1)}~'),\n             text,\n         )\n \n-        # 8) Convert blockquotes: > text → > text (same syntax, just ensure\n-        #    no extra escaping happens to the > character)\n-        # Slack uses the same > prefix, so this is a no-op for content.\n+        # 12) Blockquotes: > prefix is already protected by step 5 above.\n \n-        # 9) Restore placeholders in reverse order\n-        for key in reversed(list(placeholders.keys())):\n+        # 13) Restore placeholders in reverse order\n+        for key in reversed(placeholders):\n             text = text.replace(key, placeholders[key])\n \n         return text","@@ -914,9 +953,26 @@ async def _handle_slack_message(self, event: dict) -> None:\n                     if v > cutoff\n                 }\n \n-        # Ignore bot messages (including our own)\n+        # Bot message filtering (SLACK_ALLOW_BOTS / config allow_bots):\n+        #   \"none\"     — ignore all bot messages (default, backward-compatible)\n+        #   \"mentions\" — accept bot messages only when they @mention us\n+        #   \"all\"      — accept all bot messages (except our own)\n         if event.get(\"bot_id\") or event.get(\"subtype\") == \"bot_message\":\n-            return\n+            allow_bots = self.config.extra.get(\"allow_bots\", \"\")\n+            if not allow_bots:\n+                allow_bots = os.getenv(\"SLACK_ALLOW_BOTS\", \"none\")\n+            allow_bots = str(allow_bots).lower().strip()\n+            if allow_bots == \"none\":\n+                return\n+            elif allow_bots == \"mentions\":\n+                text_check = event.get(\"text\", \"\")\n+                if self._bot_user_id and f\"<@{self._bot_user_id}>\" not in text_check:\n+                    return\n+            # \"all\" falls through to process the message\n+            # Always ignore our own messages to prevent echo loops\n+            msg_user = event.get(\"user\", \"\")\n+            if msg_user and self._bot_user_id and msg_user == self._bot_user_id:\n+                return\n \n         # Ignore message edits and deletions\n         subtype = event.get(\"subtype\")","@@ -948,7 +1004,7 @@ async def _handle_slack_message(self, event: dict) -> None:\n         channel_type = event.get(\"channel_type\", \"\")\n         if not channel_type and channel_id.startswith(\"D\"):\n             channel_type = \"im\"\n-        is_dm = channel_type == \"im\"\n+        is_dm = channel_type in (\"im\", \"mpim\")  # Both 1:1 and group DMs\n \n         # Build thread_ts for session keying.\n         # In channels: fall back to ts so each top-level @mention starts a","@@ -961,6 +1017,8 @@ async def _handle_slack_message(self, event: dict) -> None:\n             thread_ts = event.get(\"thread_ts\") or ts  # ts fallback for channels\n \n         # In channels, respond if:\n+        #   0. Channel is in free_response_channels, OR require_mention is\n+        #      disabled — always process regardless of mention.\n         #   1. The bot is @mentioned in this message, OR\n         #   2. The message is a reply in a thread the bot started/participated in, OR\n         #   3. The message is in a thread where the bot was previously @mentioned, OR","@@ -970,24 +1028,29 @@ async def _handle_slack_message(self, event: dict) -> None:\n         event_thread_ts = event.get(\"thread_ts\")\n         is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)\n \n-        if not is_dm and bot_uid and not is_mentioned:\n-            reply_to_bot_thread = (\n-                is_thread_reply and event_thread_ts in self._bot_message_ts\n-            )\n-            in_mentioned_thread = (\n-                event_thread_ts is not None\n-                and event_thread_ts in self._mentioned_threads\n-            )\n-            has_session = (\n-                is_thread_reply\n-                and self._has_active_session_for_thread(\n-                    channel_id=channel_id,\n-                    thread_ts=event_thread_ts,\n-                    user_id=user_id,\n+        if not is_dm and bot_uid:\n+            if channel_id in self._slack_free_response_channels():\n+                pass  # Free-response channel — always process\n+            elif not self._slack_require_mention():\n+                pass  # Mention requirement disabled globally for Slack\n+            elif not is_mentioned:\n+                reply_to_bot_thread = (\n+                    is_thread_reply and event_thread_ts in self._bot_message_ts\n                 )\n-            )\n-            if not reply_to_bot_thread and not in_mentioned_thread and not has_session:\n-                return\n+                in_mentioned_thread = (\n+                    event_thread_ts is not None\n+                    and event_thread_ts in self._mentioned_threads\n+                )\n+                has_session = (\n+                    is_thread_reply\n+                    and self._has_active_session_for_thread(\n+                        channel_id=channel_id,\n+                        thread_ts=event_thread_ts,\n+                        user_id=user_id,\n+                    )\n+                )\n+                if not reply_to_bot_thread and not in_mentioned_thread and not has_session:\n+                    return\n \n         if is_mentioned:\n             # Strip the bot mention from the text","@@ -1128,14 +1191,19 @@ async def _handle_slack_message(self, event: dict) -> None:\n             reply_to_message_id=thread_ts if thread_ts != ts else None,\n         )\n \n-        # Add 👀 reaction to acknowledge receipt\n-        await self._add_reaction(channel_id, ts, \"eyes\")\n+        # Only react when bot is directly addressed (DM or @mention).\n+        # In listen-all channels (require_mention=false), reacting to every\n+        # casual message would be noisy.\n+        _should_react = is_dm or is_mentioned\n+\n+        if _should_react:\n+            await self._add_reaction(channel_id, ts, \"eyes\")\n \n         await self.handle_message(msg_event)\n \n-        # Replace 👀 with ✅ when done\n-        await self._remove_reaction(channel_id, ts, \"eyes\")\n-        await self._add_reaction(channel_id, ts, \"white_check_mark\")\n+        if _should_react:\n+            await self._remove_reaction(channel_id, ts, \"eyes\")\n+            await self._add_reaction(channel_id, ts, \"white_check_mark\")\n \n     # ----- Approval button support (Block Kit) -----\n ","@@ -1229,6 +1297,20 @@ async def _handle_approval_action(self, ack, body, action) -> None:\n         msg_ts = message.get(\"ts\", \"\")\n         channel_id = body.get(\"channel\", {}).get(\"id\", \"\")\n         user_name = body.get(\"user\", {}).get(\"name\", \"unknown\")\n+        user_id = body.get(\"user\", {}).get(\"id\", \"\")\n+\n+        # Only authorized users may click approval buttons.  Button clicks\n+        # bypass the normal message auth flow in gateway/run.py, so we must\n+        # check here as well.\n+        allowed_csv = os.getenv(\"SLACK_ALLOWED_USERS\", \"\").strip()\n+        if allowed_csv:\n+            allowed_ids = {uid.strip() for uid in allowed_csv.split(\",\") if uid.strip()}\n+            if \"*\" not in allowed_ids and user_id not in allowed_ids:\n+                logger.warning(\n+                    \"[Slack] Unauthorized approval click by %s (%s) — ignoring\",\n+                    user_name, user_id,\n+                )\n+                return\n \n         # Map action_id to approval choice\n         choice_map = {","@@ -1239,10 +1321,9 @@ async def _handle_approval_action(self, ack, body, action) -> None:\n         }\n         choice = choice_map.get(action_id, \"deny\")\n \n-        # Prevent double-clicks\n-        if self._approval_resolved.get(msg_ts, False):\n+        # Prevent double-clicks — atomic pop; first caller gets False, others get True (default)\n+        if self._approval_resolved.pop(msg_ts, True):\n             return\n-        self._approval_resolved[msg_ts] = True\n \n         # Update the message to show the decision and remove buttons\n         label_map = {","@@ -1297,8 +1378,7 @@ async def _handle_approval_action(self, ack, body, action) -> None:\n         except Exception as exc:\n             logger.error(\"Failed to resolve gateway approval from Slack button: %s\", exc)\n \n-        # Clean up stale approval state\n-        self._approval_resolved.pop(msg_ts, None)\n+        # (approval state already consumed by atomic pop above)\n \n     # ----- Thread context fetching -----\n ","@@ -1309,57 +1389,104 @@ async def _fetch_thread_context(\n         \"\"\"Fetch recent thread messages to provide context when the bot is\n         mentioned mid-thread for the first time.\n \n-        Returns a formatted string with thread history, or empty string on\n-        failure or if the thread is empty (just the parent message).\n+        This method is only called when there is NO active session for the\n+        thread (guarded at the call site by _has_active_session_for_thread).\n+        That guard ensures thread messages are prepended only on the very\n+        first turn — after that the session history already holds them, so\n+        there is no duplication across subsequent turns.\n+\n+        Results are cached for _THREAD_CACHE_TTL seconds per thread to avoid\n+        hammering conversations.replies (Tier 3, ~50 req/min).\n+\n+        Returns a formatted string with prior thread history, or empty string\n+        on failure or if the thread has no prior messages.\n         \"\"\"\n+        cache_key = f\"{channel_id}:{thread_ts}\"\n+        now = time.monotonic()\n+        cached = self._thread_context_cache.get(cache_key)\n+        if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:\n+            return cached.content\n+\n         try:\n             client = self._get_client(channel_id)\n-            result = await client.conversations_replies(\n-                channel=channel_id,\n-                ts=thread_ts,\n-                limit=limit + 1,  # +1 because it includes the current message\n-                inclusive=True,\n-            )\n+\n+            # Retry with exponential backoff for Tier-3 rate limits (429).\n+            result = None\n+            for attempt in range(3):\n+                try:\n+                    result = await client.conversations_replies(\n+                        channel=channel_id,\n+                        ts=thread_ts,\n+                        limit=limit + 1,  # +1 because it includes the current message\n+                        inclusive=True,\n+                    )\n+                    break\n+                except Exception as exc:\n+                    # Check for rate-limit error from slack_sdk\n+                    err_str = str(exc).lower()\n+                    is_rate_limit = (\n+                        \"ratelimited\" in err_str\n+                        or \"429\" in err_str\n+                        or \"rate_limited\" in err_str\n+                    )\n+                    if is_rate_limit and attempt < 2:\n+                        retry_after = 1.0 * (2 ** attempt)  # 1s, 2s\n+                        logger.warning(\n+                            \"[Slack] conversations.replies rate limited; retrying in %.1fs (attempt %d/3)\",\n+                            retry_after, attempt + 1,\n+                        )\n+                        await asyncio.sleep(retry_after)\n+                        continue\n+                    raise\n+\n+            if result is None:\n+                return \"\"\n+\n             messages = result.get(\"messages\", [])\n             if not messages:\n                 return \"\"\n \n+            bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)\n             context_parts = []\n             for msg in messages:\n                 msg_ts = msg.get(\"ts\", \"\")\n-                # Skip the current message (the one that triggered this fetch)\n+                # Exclude the current triggering message — it will be delivered\n+                # as the user message itself, so including it here would duplicate it.\n                 if msg_ts == current_ts:\n                     continue\n-                # Skip bot messages from ourselves\n+                # Exclude our own bot messages to avoid circular context.\n                 if msg.get(\"bot_id\") or msg.get(\"subtype\") == \"bot_message\":\n                     continue\n \n-                msg_user = msg.get(\"user\", \"unknown\")\n                 msg_text = msg.get(\"text\", \"\").strip()\n                 if not msg_text:\n                     continue\n \n                 # Strip bot mentions from context messages\n-                bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)\n                 if bot_uid:\n                     msg_text = msg_text.replace(f\"<@{bot_uid}>\", \"\").strip()\n \n-                # Mark the thread parent\n+                msg_user = msg.get(\"user\", \"unknown\")\n                 is_parent = msg_ts == thread_ts\n                 prefix = \"[thread parent] \" if is_parent else \"\"\n-\n-                # Resolve user name (cached)\n                 name = await self._resolve_user_name(msg_user, chat_id=channel_id)\n                 context_parts.append(f\"{prefix}{name}: {msg_text}\")\n \n-            if not context_parts:\n-                return \"\"\n+            content = \"\"\n+            if context_parts:\n+                content = (\n+                    \"[Thread context — prior messages in this thread (not yet in conversation history):]\\n\"\n+                    + \"\\n\".join(context_parts)\n+                    + \"\\n[End of thread context]\\n\\n\"\n+                )\n \n-            return (\n-                \"[Thread context — previous messages in this thread:]\\n\"\n-                + \"\\n\".join(context_parts)\n-                + \"\\n[End of thread context]\\n\\n\"\n+            self._thread_context_cache[cache_key] = _ThreadContextCache(\n+                content=content,\n+                fetched_at=now,\n+                message_count=len(context_parts),\n             )\n+            return content\n+\n         except Exception as e:\n             logger.warning(\"[Slack] Failed to fetch thread context: %s\", e)\n             return \"\"","@@ -1515,3 +1642,30 @@ async def _download_slack_file_bytes(self, url: str, team_id: str = \"\") -> bytes\n                         continue\n                     raise\n         raise last_exc\n+\n+    # ── Channel mention gating ─────────────────────────────────────────────\n+\n+    def _slack_require_mention(self) -> bool:\n+        \"\"\"Return whether channel messages require an explicit bot mention.\n+\n+        Uses explicit-false parsing (like Discord/Matrix) rather than\n+        truthy parsing, since the safe default is True (gating on).\n+        Unrecognised or empty values keep gating enabled.\n+        \"\"\"\n+        configured = self.config.extra.get(\"require_mention\")\n+        if configured is not None:\n+            if isinstance(configured, str):\n+                return configured.lower() not in (\"false\", \"0\", \"no\", \"off\")\n+            return bool(configured)\n+        return os.getenv(\"SLACK_REQUIRE_MENTION\", \"true\").lower() not in (\"false\", \"0\", \"no\", \"off\")\n+\n+    def _slack_free_response_channels(self) -> set:\n+        \"\"\"Return channel IDs where no @mention is required.\"\"\"\n+        raw = self.config.extra.get(\"free_response_channels\")\n+        if raw is None:\n+            raw = os.getenv(\"SLACK_FREE_RESPONSE_CHANNELS\", \"\")\n+        if isinstance(raw, list):\n+            return {str(part).strip() for part in raw if str(part).strip()}\n+        if isinstance(raw, str) and raw.strip():\n+            return {part.strip() for part in raw.split(\",\") if part.strip()}\n+        return set()"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -1398,6 +1398,15 @@ async def _handle_callback_query(\n                     await query.answer(text=\"Invalid approval data.\")\n                     return\n \n+                # Only authorized users may click approval buttons.\n+                caller_id = str(getattr(query.from_user, \"id\", \"\"))\n+                allowed_csv = os.getenv(\"TELEGRAM_ALLOWED_USERS\", \"\").strip()\n+                if allowed_csv:\n+                    allowed_ids = {uid.strip() for uid in allowed_csv.split(\",\") if uid.strip()}\n+                    if \"*\" not in allowed_ids and caller_id not in allowed_ids:\n+                        await query.answer(text=\"⛔ You are not authorized to approve commands.\")\n+                        return\n+\n                 session_key = self._approval_state.pop(approval_id, None)\n                 if not session_key:\n                     await query.answer(text=\"This approval has already been resolved.\")"]},{"file":"hermes_state.py","hunks":["@@ -944,7 +944,8 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]:\n                 try:\n                     msg[\"tool_calls\"] = json.loads(msg[\"tool_calls\"])\n                 except (json.JSONDecodeError, TypeError):\n-                    pass\n+                    logger.warning(\"Failed to deserialize tool_calls in get_messages, falling back to []\")\n+                    msg[\"tool_calls\"] = []\n             result.append(msg)\n         return result\n ","@@ -972,7 +973,8 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:\n                 try:\n                     msg[\"tool_calls\"] = json.loads(row[\"tool_calls\"])\n                 except (json.JSONDecodeError, TypeError):\n-                    pass\n+                    logger.warning(\"Failed to deserialize tool_calls in conversation replay, falling back to []\")\n+                    msg[\"tool_calls\"] = []\n             # Restore reasoning fields on assistant messages so providers\n             # that replay reasoning (OpenRouter, OpenAI, Nous) receive\n             # coherent multi-turn reasoning context.","@@ -983,12 +985,14 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:\n                     try:\n                         msg[\"reasoning_details\"] = json.loads(row[\"reasoning_details\"])\n                     except (json.JSONDecodeError, TypeError):\n-                        pass\n+                        logger.warning(\"Failed to deserialize reasoning_details, falling back to None\")\n+                        msg[\"reasoning_details\"] = None\n                 if row[\"codex_reasoning_items\"]:\n                     try:\n                         msg[\"codex_reasoning_items\"] = json.loads(row[\"codex_reasoning_items\"])\n                     except (json.JSONDecodeError, TypeError):\n-                        pass\n+                        logger.warning(\"Failed to deserialize codex_reasoning_items, falling back to None\")\n+                        msg[\"codex_reasoning_items\"] = None\n             messages.append(msg)\n         return messages\n "]},{"file":"tests/gateway/test_slack.py","hunks":["@@ -619,6 +619,18 @@ def test_link_conversion(self, adapter):\n         result = adapter.format_message(\"[click here](https://example.com)\")\n         assert result == \"<https://example.com|click here>\"\n \n+    def test_link_conversion_strips_markdown_angle_brackets(self, adapter):\n+        result = adapter.format_message(\"[click here](<https://example.com>)\")\n+        assert result == \"<https://example.com|click here>\"\n+\n+    def test_escapes_control_characters(self, adapter):\n+        result = adapter.format_message(\"AT&T < 5 > 3\")\n+        assert result == \"AT&amp;T &lt; 5 &gt; 3\"\n+\n+    def test_preserves_existing_slack_entities(self, adapter):\n+        text = \"Hey <@U123>, see <https://example.com|example> and <!here>\"\n+        assert adapter.format_message(text) == text\n+\n     def test_strikethrough(self, adapter):\n         assert adapter.format_message(\"~~deleted~~\") == \"~deleted~\"\n ","@@ -643,6 +655,325 @@ def test_empty_string(self, adapter):\n     def test_none_passthrough(self, adapter):\n         assert adapter.format_message(None) is None\n \n+    def test_blockquote_preserved(self, adapter):\n+        \"\"\"Single-line blockquote > marker is preserved.\"\"\"\n+        assert adapter.format_message(\"> quoted text\") == \"> quoted text\"\n+\n+    def test_multiline_blockquote(self, adapter):\n+        \"\"\"Multi-line blockquote preserves > on each line.\"\"\"\n+        text = \"> line one\\n> line two\"\n+        assert adapter.format_message(text) == \"> line one\\n> line two\"\n+\n+    def test_blockquote_with_formatting(self, adapter):\n+        \"\"\"Blockquote containing bold text.\"\"\"\n+        assert adapter.format_message(\"> **bold quote**\") == \"> *bold quote*\"\n+\n+    def test_nested_blockquote(self, adapter):\n+        \"\"\"Multiple > characters for nested quotes.\"\"\"\n+        assert adapter.format_message(\">> deeply quoted\") == \">> deeply quoted\"\n+\n+    def test_blockquote_mixed_with_plain(self, adapter):\n+        \"\"\"Blockquote lines interleaved with plain text.\"\"\"\n+        text = \"normal\\n> quoted\\nnormal again\"\n+        result = adapter.format_message(text)\n+        assert \"> quoted\" in result\n+        assert \"normal\" in result\n+\n+    def test_non_prefix_gt_still_escaped(self, adapter):\n+        \"\"\"Greater-than in mid-line is still escaped.\"\"\"\n+        assert adapter.format_message(\"5 > 3\") == \"5 &gt; 3\"\n+\n+    def test_blockquote_with_code(self, adapter):\n+        \"\"\"Blockquote containing inline code.\"\"\"\n+        result = adapter.format_message(\"> use `fmt.Println`\")\n+        assert result.startswith(\">\")\n+        assert \"`fmt.Println`\" in result\n+\n+    def test_bold_italic_combined(self, adapter):\n+        \"\"\"Triple-star ***text*** converts to Slack bold+italic *_text_*.\"\"\"\n+        assert adapter.format_message(\"***hello***\") == \"*_hello_*\"\n+\n+    def test_bold_italic_with_surrounding_text(self, adapter):\n+        \"\"\"Bold+italic in a sentence.\"\"\"\n+        result = adapter.format_message(\"This is ***important*** stuff\")\n+        assert \"*_important_*\" in result\n+\n+    def test_bold_italic_does_not_break_plain_bold(self, adapter):\n+        \"\"\"**bold** still works after adding ***bold italic*** support.\"\"\"\n+        assert adapter.format_message(\"**bold**\") == \"*bold*\"\n+\n+    def test_bold_italic_does_not_break_plain_italic(self, adapter):\n+        \"\"\"*italic* still works after adding ***bold italic*** support.\"\"\"\n+        assert adapter.format_message(\"*italic*\") == \"_italic_\"\n+\n+    def test_bold_italic_mixed_with_bold(self, adapter):\n+        \"\"\"Both ***bold italic*** and **bold** in the same message.\"\"\"\n+        result = adapter.format_message(\"***important*** and **bold**\")\n+        assert \"*_important_*\" in result\n+        assert \"*bold*\" in result\n+\n+    def test_pre_escaped_ampersand_not_double_escaped(self, adapter):\n+        \"\"\"Already-escaped &amp; must not become &amp;amp;.\"\"\"\n+        assert adapter.format_message(\"&amp;\") == \"&amp;\"\n+\n+    def test_pre_escaped_lt_not_double_escaped(self, adapter):\n+        \"\"\"Already-escaped &lt; must not become &amp;lt;.\"\"\"\n+        assert adapter.format_message(\"&lt;\") == \"&lt;\"\n+\n+    def test_pre_escaped_gt_not_double_escaped(self, adapter):\n+        \"\"\"Already-escaped &gt; in plain text must not become &amp;gt;.\"\"\"\n+        assert adapter.format_message(\"5 &gt; 3\") == \"5 &gt; 3\"\n+\n+    def test_mixed_raw_and_escaped_entities(self, adapter):\n+        \"\"\"Raw & and pre-escaped &amp; coexist correctly.\"\"\"\n+        result = adapter.format_message(\"AT&T and &amp; entity\")\n+        assert result == \"AT&amp;T and &amp; entity\"\n+\n+    def test_link_with_parentheses_in_url(self, adapter):\n+        \"\"\"Wikipedia-style URL with balanced parens is not truncated.\"\"\"\n+        result = adapter.format_message(\"[Foo](https://en.wikipedia.org/wiki/Foo_(bar))\")\n+        assert result == \"<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>\"\n+\n+    def test_link_with_multiple_paren_pairs(self, adapter):\n+        \"\"\"URL with multiple balanced paren pairs.\"\"\"\n+        result = adapter.format_message(\"[text](https://example.com/a_(b)_c_(d))\")\n+        assert result == \"<https://example.com/a_(b)_c_(d)|text>\"\n+\n+    def test_link_without_parens_still_works(self, adapter):\n+        \"\"\"Normal URL without parens is unaffected by regex change.\"\"\"\n+        result = adapter.format_message(\"[click](https://example.com/path?q=1)\")\n+        assert result == \"<https://example.com/path?q=1|click>\"\n+\n+    def test_link_with_angle_brackets_and_parens(self, adapter):\n+        \"\"\"Angle-bracket URL with parens (CommonMark syntax).\"\"\"\n+        result = adapter.format_message(\"[Foo](<https://en.wikipedia.org/wiki/Foo_(bar)>)\")\n+        assert result == \"<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>\"\n+\n+    def test_escaping_is_idempotent(self, adapter):\n+        \"\"\"Formatting already-formatted text produces the same result.\"\"\"\n+        original = \"AT&T < 5 > 3\"\n+        once = adapter.format_message(original)\n+        twice = adapter.format_message(once)\n+        assert once == twice\n+\n+    # --- Entity preservation (spec-compliance) ---\n+\n+    def test_channel_mention_preserved(self, adapter):\n+        \"\"\"<!channel> special mention passes through unchanged.\"\"\"\n+        assert adapter.format_message(\"Attention <!channel>\") == \"Attention <!channel>\"\n+\n+    def test_everyone_mention_preserved(self, adapter):\n+        \"\"\"<!everyone> special mention passes through unchanged.\"\"\"\n+        assert adapter.format_message(\"Hey <!everyone>\") == \"Hey <!everyone>\"\n+\n+    def test_subteam_mention_preserved(self, adapter):\n+        \"\"\"<!subteam^ID> user group mention passes through unchanged.\"\"\"\n+        assert adapter.format_message(\"Paging <!subteam^S12345>\") == \"Paging <!subteam^S12345>\"\n+\n+    def test_date_formatting_preserved(self, adapter):\n+        \"\"\"<!date^...> formatting token passes through unchanged.\"\"\"\n+        text = \"Posted <!date^1392734382^{date_pretty}|Feb 18, 2014>\"\n+        assert adapter.format_message(text) == text\n+\n+    def test_channel_link_preserved(self, adapter):\n+        \"\"\"<#CHANNEL_ID> channel link passes through unchanged.\"\"\"\n+        assert adapter.format_message(\"Join <#C12345>\") == \"Join <#C12345>\"\n+\n+    # --- Additional edge cases ---\n+\n+    def test_message_only_code_block(self, adapter):\n+        \"\"\"Entire message is a fenced code block — no conversion.\"\"\"\n+        code = \"```python\\nx = 1\\n```\"\n+        assert adapter.format_message(code) == code\n+\n+    def test_multiline_mixed_formatting(self, adapter):\n+        \"\"\"Multi-line message with headers, bold, links, code, and blockquotes.\"\"\"\n+        text = \"## Title\\n**bold** and [link](https://x.com)\\n> quote\\n`code`\"\n+        result = adapter.format_message(text)\n+        assert result.startswith(\"*Title*\")\n+        assert \"*bold*\" in result\n+        assert \"<https://x.com|link>\" in result\n+        assert \"> quote\" in result\n+        assert \"`code`\" in result\n+\n+    def test_markdown_unordered_list_with_asterisk(self, adapter):\n+        \"\"\"Asterisk list items must not trigger italic conversion.\"\"\"\n+        text = \"* item one\\n* item two\"\n+        result = adapter.format_message(text)\n+        assert \"item one\" in result\n+        assert \"item two\" in result\n+\n+    def test_nested_bold_in_link(self, adapter):\n+        \"\"\"Bold inside link label — label is stashed before bold pass.\"\"\"\n+        result = adapter.format_message(\"[**bold**](https://example.com)\")\n+        assert \"https://example.com\" in result\n+        assert \"bold\" in result\n+\n+    def test_url_with_query_string_and_ampersand(self, adapter):\n+        \"\"\"Ampersand in URL query string must not be escaped.\"\"\"\n+        result = adapter.format_message(\"[link](https://x.com?a=1&b=2)\")\n+        assert result == \"<https://x.com?a=1&b=2|link>\"\n+\n+    def test_emoji_shortcodes_passthrough(self, adapter):\n+        \"\"\"Emoji shortcodes like :smile: pass through unchanged.\"\"\"\n+        assert adapter.format_message(\":smile: hello :wave:\") == \":smile: hello :wave:\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# TestEditMessage\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestEditMessage:\n+    \"\"\"Verify that edit_message() applies mrkdwn formatting before sending.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_formats_bold(self, adapter):\n+        \"\"\"edit_message converts **bold** to Slack *bold*.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+        await adapter.edit_message(\"C123\", \"1234.5678\", \"**hello world**\")\n+        kwargs = adapter._app.client.chat_update.call_args.kwargs\n+        assert kwargs[\"text\"] == \"*hello world*\"\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_formats_links(self, adapter):\n+        \"\"\"edit_message converts markdown links to Slack format.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+        await adapter.edit_message(\"C123\", \"1234.5678\", \"[click](https://example.com)\")\n+        kwargs = adapter._app.client.chat_update.call_args.kwargs\n+        assert kwargs[\"text\"] == \"<https://example.com|click>\"\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_preserves_blockquotes(self, adapter):\n+        \"\"\"edit_message preserves blockquote > markers.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+        await adapter.edit_message(\"C123\", \"1234.5678\", \"> quoted text\")\n+        kwargs = adapter._app.client.chat_update.call_args.kwargs\n+        assert kwargs[\"text\"] == \"> quoted text\"\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_escapes_control_chars(self, adapter):\n+        \"\"\"edit_message escapes & < > in plain text.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+        await adapter.edit_message(\"C123\", \"1234.5678\", \"AT&T < 5 > 3\")\n+        kwargs = adapter._app.client.chat_update.call_args.kwargs\n+        assert kwargs[\"text\"] == \"AT&amp;T &lt; 5 &gt; 3\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# TestEditMessageStreamingPipeline\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestEditMessageStreamingPipeline:\n+    \"\"\"E2E: verify that sequential streaming edits all go through format_message.\n+\n+    Simulates the GatewayStreamConsumer pattern where edit_message is called\n+    repeatedly with progressively longer accumulated text.  Every call must\n+    produce properly formatted mrkdwn in the chat_update payload.\n+    \"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_formats_streaming_updates(self, adapter):\n+        \"\"\"Simulates streaming: multiple edits, each should be formatted.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+\n+        # First streaming update — bold\n+        result1 = await adapter.edit_message(\"C123\", \"ts1\", \"**Processing**...\")\n+        assert result1.success is True\n+        kwargs1 = adapter._app.client.chat_update.call_args.kwargs\n+        assert kwargs1[\"text\"] == \"*Processing*...\"\n+\n+        # Second streaming update — bold + link\n+        result2 = await adapter.edit_message(\n+            \"C123\", \"ts1\", \"**Done!** See [results](https://example.com)\"\n+        )\n+        assert result2.success is True\n+        kwargs2 = adapter._app.client.chat_update.call_args.kwargs\n+        assert kwargs2[\"text\"] == \"*Done!* See <https://example.com|results>\"\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_formats_code_and_bold(self, adapter):\n+        \"\"\"Streaming update with code block and bold — code must be preserved.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+\n+        content = \"**Result:**\\n```python\\nprint('hello')\\n```\"\n+        result = await adapter.edit_message(\"C123\", \"ts1\", content)\n+        assert result.success is True\n+        kwargs = adapter._app.client.chat_update.call_args.kwargs\n+        assert kwargs[\"text\"].startswith(\"*Result:*\")\n+        assert \"```python\\nprint('hello')\\n```\" in kwargs[\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_formats_blockquote_in_stream(self, adapter):\n+        \"\"\"Streaming update with blockquote — '>' marker must survive.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+\n+        content = \"> **Important:** do this\\nnormal line\"\n+        result = await adapter.edit_message(\"C123\", \"ts1\", content)\n+        assert result.success is True\n+        kwargs = adapter._app.client.chat_update.call_args.kwargs\n+        assert kwargs[\"text\"].startswith(\"> *Important:*\")\n+        assert \"normal line\" in kwargs[\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_formats_progressive_accumulation(self, adapter):\n+        \"\"\"Simulate real streaming: text grows with each edit, all formatted.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+\n+        updates = [\n+            (\"**Step 1**\", \"*Step 1*\"),\n+            (\"**Step 1**\\n**Step 2**\", \"*Step 1*\\n*Step 2*\"),\n+            (\n+                \"**Step 1**\\n**Step 2**\\nSee [docs](https://docs.example.com)\",\n+                \"*Step 1*\\n*Step 2*\\nSee <https://docs.example.com|docs>\",\n+            ),\n+        ]\n+\n+        for raw, expected in updates:\n+            result = await adapter.edit_message(\"C123\", \"ts1\", raw)\n+            assert result.success is True\n+            kwargs = adapter._app.client.chat_update.call_args.kwargs\n+            assert kwargs[\"text\"] == expected, f\"Failed for input: {raw!r}\"\n+\n+        # Total edit count should match number of updates\n+        assert adapter._app.client.chat_update.call_count == len(updates)\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_formats_bold_italic(self, adapter):\n+        \"\"\"Bold+italic ***text*** is formatted as *_text_* in edited messages.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+        await adapter.edit_message(\"C123\", \"ts1\", \"***important*** update\")\n+        kwargs = adapter._app.client.chat_update.call_args.kwargs\n+        assert \"*_important_*\" in kwargs[\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_does_not_double_escape(self, adapter):\n+        \"\"\"Pre-escaped entities in edited messages must not get double-escaped.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+        await adapter.edit_message(\"C123\", \"ts1\", \"5 &gt; 3 and &amp; entity\")\n+        kwargs = adapter._app.client.chat_update.call_args.kwargs\n+        assert \"&amp;gt;\" not in kwargs[\"text\"]\n+        assert \"&amp;amp;\" not in kwargs[\"text\"]\n+        assert \"&gt;\" in kwargs[\"text\"]\n+        assert \"&amp;\" in kwargs[\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_formats_url_with_parens(self, adapter):\n+        \"\"\"Wikipedia-style URL with parens survives edit pipeline.\"\"\"\n+        adapter._app.client.chat_update = AsyncMock(return_value={\"ok\": True})\n+        await adapter.edit_message(\"C123\", \"ts1\", \"See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))\")\n+        kwargs = adapter._app.client.chat_update.call_args.kwargs\n+        assert \"<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>\" in kwargs[\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_edit_message_not_connected(self, adapter):\n+        \"\"\"edit_message returns failure when adapter is not connected.\"\"\"\n+        adapter._app = None\n+        result = await adapter.edit_message(\"C123\", \"ts1\", \"**hello**\")\n+        assert result.success is False\n+        assert \"Not connected\" in result.error\n+\n \n # ---------------------------------------------------------------------------\n # TestReactions","@@ -1085,6 +1416,48 @@ async def test_short_message_single_send(self, adapter):\n         await adapter.send(\"C123\", \"hello world\")\n         assert adapter._app.client.chat_postMessage.call_count == 1\n \n+    @pytest.mark.asyncio\n+    async def test_send_preserves_blockquote_formatting(self, adapter):\n+        \"\"\"Blockquote '>' markers must survive format → chunk → send pipeline.\"\"\"\n+        adapter._app.client.chat_postMessage = AsyncMock(return_value={\"ts\": \"ts1\"})\n+        await adapter.send(\"C123\", \"> quoted text\\nnormal text\")\n+        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs\n+        sent_text = kwargs[\"text\"]\n+        assert sent_text.startswith(\"> quoted text\")\n+        assert \"normal text\" in sent_text\n+\n+    @pytest.mark.asyncio\n+    async def test_send_formats_bold_italic(self, adapter):\n+        \"\"\"Bold+italic ***text*** is formatted as *_text_* in sent messages.\"\"\"\n+        adapter._app.client.chat_postMessage = AsyncMock(return_value={\"ts\": \"ts1\"})\n+        await adapter.send(\"C123\", \"***important*** update\")\n+        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs\n+        assert \"*_important_*\" in kwargs[\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_send_explicitly_enables_mrkdwn(self, adapter):\n+        adapter._app.client.chat_postMessage = AsyncMock(return_value={\"ts\": \"ts1\"})\n+        await adapter.send(\"C123\", \"**hello**\")\n+        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs\n+        assert kwargs.get(\"mrkdwn\") is True\n+\n+    @pytest.mark.asyncio\n+    async def test_send_does_not_double_escape_entities(self, adapter):\n+        \"\"\"Pre-escaped &amp; in sent messages must not become &amp;amp;.\"\"\"\n+        adapter._app.client.chat_postMessage = AsyncMock(return_value={\"ts\": \"ts1\"})\n+        await adapter.send(\"C123\", \"Use &amp; for ampersand\")\n+        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs\n+        assert \"&amp;amp;\" not in kwargs[\"text\"]\n+        assert \"&amp;\" in kwargs[\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_send_formats_url_with_parens(self, adapter):\n+        \"\"\"Wikipedia-style URL with parens survives send pipeline.\"\"\"\n+        adapter._app.client.chat_postMessage = AsyncMock(return_value={\"ts\": \"ts1\"})\n+        await adapter.send(\"C123\", \"See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))\")\n+        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs\n+        assert \"<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>\" in kwargs[\"text\"]\n+\n \n # ---------------------------------------------------------------------------\n # TestReplyBroadcast"]},{"file":"tests/gateway/test_slack_mention.py","hunks":["@@ -0,0 +1,312 @@\n+\"\"\"\n+Tests for Slack mention gating (require_mention / free_response_channels).\n+\n+Follows the same pattern as test_whatsapp_group_gating.py.\n+\"\"\"\n+\n+import sys\n+from unittest.mock import MagicMock\n+\n+from gateway.config import Platform, PlatformConfig\n+\n+\n+# ---------------------------------------------------------------------------\n+# Mock slack-bolt if not installed (same as test_slack.py)\n+# ---------------------------------------------------------------------------\n+\n+def _ensure_slack_mock():\n+    if \"slack_bolt\" in sys.modules and hasattr(sys.modules[\"slack_bolt\"], \"__file__\"):\n+        return\n+\n+    slack_bolt = MagicMock()\n+    slack_bolt.async_app.AsyncApp = MagicMock\n+    slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock\n+\n+    slack_sdk = MagicMock()\n+    slack_sdk.web.async_client.AsyncWebClient = MagicMock\n+\n+    for name, mod in [\n+        (\"slack_bolt\", slack_bolt),\n+        (\"slack_bolt.async_app\", slack_bolt.async_app),\n+        (\"slack_bolt.adapter\", slack_bolt.adapter),\n+        (\"slack_bolt.adapter.socket_mode\", slack_bolt.adapter.socket_mode),\n+        (\"slack_bolt.adapter.socket_mode.async_handler\", slack_bolt.adapter.socket_mode.async_handler),\n+        (\"slack_sdk\", slack_sdk),\n+        (\"slack_sdk.web\", slack_sdk.web),\n+        (\"slack_sdk.web.async_client\", slack_sdk.web.async_client),\n+    ]:\n+        sys.modules.setdefault(name, mod)\n+\n+\n+_ensure_slack_mock()\n+\n+import gateway.platforms.slack as _slack_mod\n+_slack_mod.SLACK_AVAILABLE = True\n+\n+from gateway.platforms.slack import SlackAdapter  # noqa: E402\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+BOT_USER_ID = \"U_BOT_123\"\n+CHANNEL_ID = \"C0AQWDLHY9M\"\n+OTHER_CHANNEL_ID = \"C9999999999\"\n+\n+\n+def _make_adapter(require_mention=None, free_response_channels=None):\n+    extra = {}\n+    if require_mention is not None:\n+        extra[\"require_mention\"] = require_mention\n+    if free_response_channels is not None:\n+        extra[\"free_response_channels\"] = free_response_channels\n+\n+    adapter = object.__new__(SlackAdapter)\n+    adapter.platform = Platform.SLACK\n+    adapter.config = PlatformConfig(enabled=True, extra=extra)\n+    adapter._bot_user_id = BOT_USER_ID\n+    adapter._team_bot_user_ids = {}\n+    return adapter\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: _slack_require_mention\n+# ---------------------------------------------------------------------------\n+\n+def test_require_mention_defaults_to_true(monkeypatch):\n+    monkeypatch.delenv(\"SLACK_REQUIRE_MENTION\", raising=False)\n+    adapter = _make_adapter()\n+    assert adapter._slack_require_mention() is True\n+\n+\n+def test_require_mention_false():\n+    adapter = _make_adapter(require_mention=False)\n+    assert adapter._slack_require_mention() is False\n+\n+\n+def test_require_mention_true():\n+    adapter = _make_adapter(require_mention=True)\n+    assert adapter._slack_require_mention() is True\n+\n+\n+def test_require_mention_string_true():\n+    adapter = _make_adapter(require_mention=\"true\")\n+    assert adapter._slack_require_mention() is True\n+\n+\n+def test_require_mention_string_false():\n+    adapter = _make_adapter(require_mention=\"false\")\n+    assert adapter._slack_require_mention() is False\n+\n+\n+def test_require_mention_string_no():\n+    adapter = _make_adapter(require_mention=\"no\")\n+    assert adapter._slack_require_mention() is False\n+\n+\n+def test_require_mention_string_yes():\n+    adapter = _make_adapter(require_mention=\"yes\")\n+    assert adapter._slack_require_mention() is True\n+\n+\n+def test_require_mention_empty_string_stays_true():\n+    \"\"\"Empty/malformed strings keep gating ON (explicit-false parser).\"\"\"\n+    adapter = _make_adapter(require_mention=\"\")\n+    assert adapter._slack_require_mention() is True\n+\n+\n+def test_require_mention_malformed_string_stays_true():\n+    \"\"\"Unrecognised values keep gating ON (fail-closed).\"\"\"\n+    adapter = _make_adapter(require_mention=\"maybe\")\n+    assert adapter._slack_require_mention() is True\n+\n+\n+def test_require_mention_env_var_fallback(monkeypatch):\n+    monkeypatch.setenv(\"SLACK_REQUIRE_MENTION\", \"false\")\n+    adapter = _make_adapter()  # no config value -> falls back to env\n+    assert adapter._slack_require_mention() is False\n+\n+\n+def test_require_mention_env_var_default_true(monkeypatch):\n+    monkeypatch.delenv(\"SLACK_REQUIRE_MENTION\", raising=False)\n+    adapter = _make_adapter()\n+    assert adapter._slack_require_mention() is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: _slack_free_response_channels\n+# ---------------------------------------------------------------------------\n+\n+def test_free_response_channels_default_empty(monkeypatch):\n+    monkeypatch.delenv(\"SLACK_FREE_RESPONSE_CHANNELS\", raising=False)\n+    adapter = _make_adapter()\n+    assert adapter._slack_free_response_channels() == set()\n+\n+\n+def test_free_response_channels_list():\n+    adapter = _make_adapter(free_response_channels=[CHANNEL_ID, OTHER_CHANNEL_ID])\n+    result = adapter._slack_free_response_channels()\n+    assert CHANNEL_ID in result\n+    assert OTHER_CHANNEL_ID in result\n+\n+\n+def test_free_response_channels_csv_string():\n+    adapter = _make_adapter(free_response_channels=f\"{CHANNEL_ID}, {OTHER_CHANNEL_ID}\")\n+    result = adapter._slack_free_response_channels()\n+    assert CHANNEL_ID in result\n+    assert OTHER_CHANNEL_ID in result\n+\n+\n+def test_free_response_channels_empty_string():\n+    adapter = _make_adapter(free_response_channels=\"\")\n+    assert adapter._slack_free_response_channels() == set()\n+\n+\n+def test_free_response_channels_env_var_fallback(monkeypatch):\n+    monkeypatch.setenv(\"SLACK_FREE_RESPONSE_CHANNELS\", f\"{CHANNEL_ID},{OTHER_CHANNEL_ID}\")\n+    adapter = _make_adapter()  # no config value → falls back to env\n+    result = adapter._slack_free_response_channels()\n+    assert CHANNEL_ID in result\n+    assert OTHER_CHANNEL_ID in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: mention gating integration (simulating _handle_slack_message logic)\n+# ---------------------------------------------------------------------------\n+\n+def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID,\n+                   text=\"hello\", mentioned=False, thread_reply=False,\n+                   active_session=False):\n+    \"\"\"Simulate the mention gating logic from _handle_slack_message.\n+\n+    Returns True if the message would be processed, False if it would be\n+    skipped (returned early).\n+    \"\"\"\n+    bot_uid = adapter._team_bot_user_ids.get(\"T1\", adapter._bot_user_id)\n+    if mentioned:\n+        text = f\"<@{bot_uid}> {text}\"\n+    is_mentioned = bot_uid and f\"<@{bot_uid}>\" in text\n+\n+    if not is_dm:\n+        if channel_id in adapter._slack_free_response_channels():\n+            return True\n+        elif not adapter._slack_require_mention():\n+            return True\n+        elif not is_mentioned:\n+            if thread_reply and active_session:\n+                return True\n+            else:\n+                return False\n+    return True\n+\n+\n+def test_default_require_mention_channel_without_mention_ignored():\n+    adapter = _make_adapter()  # default: require_mention=True\n+    assert _would_process(adapter, text=\"hello everyone\") is False\n+\n+\n+def test_require_mention_false_channel_without_mention_processed():\n+    adapter = _make_adapter(require_mention=False)\n+    assert _would_process(adapter, text=\"hello everyone\") is True\n+\n+\n+def test_channel_in_free_response_processed_without_mention():\n+    adapter = _make_adapter(\n+        require_mention=True,\n+        free_response_channels=[CHANNEL_ID],\n+    )\n+    assert _would_process(adapter, channel_id=CHANNEL_ID, text=\"hello\") is True\n+\n+\n+def test_other_channel_not_in_free_response_still_gated():\n+    adapter = _make_adapter(\n+        require_mention=True,\n+        free_response_channels=[CHANNEL_ID],\n+    )\n+    assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text=\"hello\") is False\n+\n+\n+def test_dm_always_processed_regardless_of_setting():\n+    adapter = _make_adapter(require_mention=True)\n+    assert _would_process(adapter, is_dm=True, text=\"hello\") is True\n+\n+\n+def test_mentioned_message_always_processed():\n+    adapter = _make_adapter(require_mention=True)\n+    assert _would_process(adapter, mentioned=True, text=\"what's up\") is True\n+\n+\n+def test_thread_reply_with_active_session_processed():\n+    adapter = _make_adapter(require_mention=True)\n+    assert _would_process(\n+        adapter, text=\"followup\",\n+        thread_reply=True, active_session=True,\n+    ) is True\n+\n+\n+def test_thread_reply_without_active_session_ignored():\n+    adapter = _make_adapter(require_mention=True)\n+    assert _would_process(\n+        adapter, text=\"followup\",\n+        thread_reply=True, active_session=False,\n+    ) is False\n+\n+\n+def test_bot_uid_none_processes_channel_message():\n+    \"\"\"When bot_uid is None (before auth_test), channel messages pass through.\n+\n+    This preserves the old behavior: the gating block is skipped entirely\n+    when bot_uid is falsy, so messages are not silently dropped during\n+    startup or for new workspaces.\n+    \"\"\"\n+    adapter = _make_adapter(require_mention=True)\n+    adapter._bot_user_id = None\n+    adapter._team_bot_user_ids = {}\n+\n+    # With bot_uid=None, the `if not is_dm and bot_uid:` condition is False,\n+    # so the gating block is skipped — message passes through.\n+    bot_uid = adapter._team_bot_user_ids.get(\"T1\", adapter._bot_user_id)\n+    assert bot_uid is None\n+\n+    # Simulate: gating block not entered when bot_uid is falsy\n+    is_dm = False\n+    if not is_dm and bot_uid:\n+        result = False  # would enter gating\n+    else:\n+        result = True  # gating skipped, message processed\n+    assert result is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests: config bridging\n+# ---------------------------------------------------------------------------\n+\n+def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):\n+    from gateway.config import load_gateway_config\n+\n+    hermes_home = tmp_path / \".hermes\"\n+    hermes_home.mkdir()\n+    (hermes_home / \"config.yaml\").write_text(\n+        \"slack:\\n\"\n+        \"  require_mention: false\\n\"\n+        \"  free_response_channels:\\n\"\n+        \"    - C0AQWDLHY9M\\n\"\n+        \"    - C9999999999\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    monkeypatch.delenv(\"SLACK_REQUIRE_MENTION\", raising=False)\n+    monkeypatch.delenv(\"SLACK_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    config = load_gateway_config()\n+\n+    assert config is not None\n+    slack_extra = config.platforms[Platform.SLACK].extra\n+    assert slack_extra.get(\"require_mention\") is False\n+    assert slack_extra.get(\"free_response_channels\") == [\"C0AQWDLHY9M\", \"C9999999999\"]\n+    # Verify env vars were set by config bridging\n+    import os as _os\n+    assert _os.environ[\"SLACK_REQUIRE_MENTION\"] == \"false\"\n+    assert _os.environ[\"SLACK_FREE_RESPONSE_CHANNELS\"] == \"C0AQWDLHY9M,C9999999999\""]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -32,6 +32,30 @@ def _install_telegram_mock(monkeypatch, bot):\n     monkeypatch.setitem(sys.modules, \"telegram.constants\", constants_mod)\n \n \n+def _ensure_slack_mock(monkeypatch):\n+    if \"slack_bolt\" in sys.modules and hasattr(sys.modules[\"slack_bolt\"], \"__file__\"):\n+        return\n+\n+    slack_bolt = MagicMock()\n+    slack_bolt.async_app.AsyncApp = MagicMock\n+    slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock\n+\n+    slack_sdk = MagicMock()\n+    slack_sdk.web.async_client.AsyncWebClient = MagicMock\n+\n+    for name, mod in [\n+        (\"slack_bolt\", slack_bolt),\n+        (\"slack_bolt.async_app\", slack_bolt.async_app),\n+        (\"slack_bolt.adapter\", slack_bolt.adapter),\n+        (\"slack_bolt.adapter.socket_mode\", slack_bolt.adapter.socket_mode),\n+        (\"slack_bolt.adapter.socket_mode.async_handler\", slack_bolt.adapter.socket_mode.async_handler),\n+        (\"slack_sdk\", slack_sdk),\n+        (\"slack_sdk.web\", slack_sdk.web),\n+        (\"slack_sdk.web.async_client\", slack_sdk.web.async_client),\n+    ]:\n+        monkeypatch.setitem(sys.modules, name, mod)\n+\n+\n class TestSendMessageTool:\n     def test_cron_duplicate_target_is_skipped_and_explained(self):\n         home = SimpleNamespace(chat_id=\"-1001\")","@@ -426,7 +450,7 @@ def test_long_message_is_chunked(self):\n             result = asyncio.run(\n                 _send_to_platform(\n                     Platform.DISCORD,\n-                    SimpleNamespace(enabled=True, token=\"tok\", extra={}),\n+                    SimpleNamespace(enabled=True, token=\"***\", extra={}),\n                     \"ch\", long_msg,\n                 )\n             )","@@ -435,8 +459,115 @@ def test_long_message_is_chunked(self):\n         for call in send.await_args_list:\n             assert len(call.args[2]) <= 2020  # each chunk fits the limit\n \n+    def test_slack_messages_are_formatted_before_send(self, monkeypatch):\n+        _ensure_slack_mock(monkeypatch)\n+\n+        import gateway.platforms.slack as slack_mod\n+\n+        monkeypatch.setattr(slack_mod, \"SLACK_AVAILABLE\", True)\n+        send = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n+\n+        with patch(\"tools.send_message_tool._send_slack\", send):\n+            result = asyncio.run(\n+                _send_to_platform(\n+                    Platform.SLACK,\n+                    SimpleNamespace(enabled=True, token=\"***\", extra={}),\n+                    \"C123\",\n+                    \"**hello** from [Hermes](<https://example.com>)\",\n+                )\n+            )\n+\n+        assert result[\"success\"] is True\n+        send.assert_awaited_once_with(\n+            \"***\",\n+            \"C123\",\n+            \"*hello* from <https://example.com|Hermes>\",\n+        )\n+\n+    def test_slack_bold_italic_formatted_before_send(self, monkeypatch):\n+        \"\"\"Bold+italic ***text*** survives tool-layer formatting.\"\"\"\n+        _ensure_slack_mock(monkeypatch)\n+        import gateway.platforms.slack as slack_mod\n+\n+        monkeypatch.setattr(slack_mod, \"SLACK_AVAILABLE\", True)\n+        send = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n+        with patch(\"tools.send_message_tool._send_slack\", send):\n+            result = asyncio.run(\n+                _send_to_platform(\n+                    Platform.SLACK,\n+                    SimpleNamespace(enabled=True, token=\"***\", extra={}),\n+                    \"C123\",\n+                    \"***important*** update\",\n+                )\n+            )\n+        assert result[\"success\"] is True\n+        sent_text = send.await_args.args[2]\n+        assert \"*_important_*\" in sent_text\n+\n+    def test_slack_blockquote_formatted_before_send(self, monkeypatch):\n+        \"\"\"Blockquote '>' markers must survive formatting (not escaped to '&gt;').\"\"\"\n+        _ensure_slack_mock(monkeypatch)\n+        import gateway.platforms.slack as slack_mod\n+\n+        monkeypatch.setattr(slack_mod, \"SLACK_AVAILABLE\", True)\n+        send = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n+        with patch(\"tools.send_message_tool._send_slack\", send):\n+            result = asyncio.run(\n+                _send_to_platform(\n+                    Platform.SLACK,\n+                    SimpleNamespace(enabled=True, token=\"***\", extra={}),\n+                    \"C123\",\n+                    \"> important quote\\n\\nnormal text & stuff\",\n+                )\n+            )\n+        assert result[\"success\"] is True\n+        sent_text = send.await_args.args[2]\n+        assert sent_text.startswith(\"> important quote\")\n+        assert \"&amp;\" in sent_text  # & is escaped\n+        assert \"&gt;\" not in sent_text.split(\"\\n\")[0]  # > in blockquote is NOT escaped\n+\n+    def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch):\n+        \"\"\"Pre-escaped HTML entities survive tool-layer formatting without double-escaping.\"\"\"\n+        _ensure_slack_mock(monkeypatch)\n+        import gateway.platforms.slack as slack_mod\n+        monkeypatch.setattr(slack_mod, \"SLACK_AVAILABLE\", True)\n+        send = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n+        with patch(\"tools.send_message_tool._send_slack\", send):\n+            result = asyncio.run(\n+                _send_to_platform(\n+                    Platform.SLACK,\n+                    SimpleNamespace(enabled=True, token=\"***\", extra={}),\n+                    \"C123\",\n+                    \"AT&amp;T &lt;tag&gt; test\",\n+                )\n+            )\n+        assert result[\"success\"] is True\n+        sent_text = send.await_args.args[2]\n+        assert \"&amp;amp;\" not in sent_text\n+        assert \"&amp;lt;\" not in sent_text\n+        assert \"AT&amp;T\" in sent_text\n+\n+    def test_slack_url_with_parens_formatted_before_send(self, monkeypatch):\n+        \"\"\"Wikipedia-style URL with parens survives tool-layer formatting.\"\"\"\n+        _ensure_slack_mock(monkeypatch)\n+        import gateway.platforms.slack as slack_mod\n+        monkeypatch.setattr(slack_mod, \"SLACK_AVAILABLE\", True)\n+        send = AsyncMock(return_value={\"success\": True, \"message_id\": \"1\"})\n+        with patch(\"tools.send_message_tool._send_slack\", send):\n+            result = asyncio.run(\n+                _send_to_platform(\n+                    Platform.SLACK,\n+                    SimpleNamespace(enabled=True, token=\"***\", extra={}),\n+                    \"C123\",\n+                    \"See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))\",\n+                )\n+            )\n+        assert result[\"success\"] is True\n+        sent_text = send.await_args.args[2]\n+        assert \"<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>\" in sent_text\n+\n     def test_telegram_media_attaches_to_last_chunk(self):\n-        \"\"\"When chunked, media files are sent only with the last chunk.\"\"\"\n+\n         sent_calls = []\n \n         async def fake_send(token, chat_id, message, media_files=None, thread_id=None):"]},{"file":"tools/send_message_tool.py","hunks":["@@ -322,6 +322,13 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n \n     media_files = media_files or []\n \n+    if platform == Platform.SLACK and message:\n+        try:\n+            slack_adapter = SlackAdapter.__new__(SlackAdapter)\n+            message = slack_adapter.format_message(message)\n+        except Exception:\n+            logger.debug(\"Failed to apply Slack mrkdwn formatting in _send_to_platform\", exc_info=True)\n+\n     # Platform message length limits (from adapter class attributes)\n     _MAX_LENGTHS = {\n         Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH,","@@ -571,7 +578,8 @@ async def _send_slack(token, chat_id, message):\n         url = \"https://slack.com/api/chat.postMessage\"\n         headers = {\"Authorization\": f\"Bearer {token}\", \"Content-Type\": \"application/json\"}\n         async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:\n-            async with session.post(url, headers=headers, json={\"channel\": chat_id, \"text\": message}) as resp:\n+            payload = {\"channel\": chat_id, \"text\": message, \"mrkdwn\": True}\n+            async with session.post(url, headers=headers, json=payload) as resp:\n                 data = await resp.json()\n                 if data.get(\"ok\"):\n                     return {\"success\": True, \"platform\": \"slack\", \"chat_id\": chat_id, \"message_id\": data.get(\"ts\")}\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}