# NousResearch/hermes-agent · Slack adapter require_mention config handling

## Open now

- Nothing open on GitHub.

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.

## TL;DR

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

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

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

## Closed / duplicate issues

- #6218: Slack adapter ignores require_mention: false config for channel messages

## Prior & competing PRs

- **#6809: feat(slack): consolidated Slack adapter improvements — 7 community PRs salvaged**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `gateway/config.py`

    ```diff
    @@ -532,6 +532,8 @@ def load_gateway_config() -> GatewayConfig:
                         bridged["reply_prefix"] = platform_cfg["reply_prefix"]
                     if "require_mention" in platform_cfg:
                         bridged["require_mention"] = platform_cfg["require_mention"]
    +                if "free_response_channels" in platform_cfg:
    +                    bridged["free_response_channels"] = platform_cfg["free_response_channels"]
                     if "mention_patterns" in platform_cfg:
                         bridged["mention_patterns"] = platform_cfg["mention_patterns"]
                     if not bridged:
    ```

    ```diff
    @@ -546,6 +548,19 @@ def load_gateway_config() -> GatewayConfig:
                         plat_data["extra"] = extra
                     extra.update(bridged)
     
    +            # Slack settings → env vars (env vars take precedence)
    +            slack_cfg = yaml_cfg.get("slack", {})
    +            if isinstance(slack_cfg, dict):
    +                if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
    +                    os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
    +                if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
    +                    os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
    +                frc = slack_cfg.get("free_response_channels")
    +                if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"):
    +                    if isinstance(frc, list):
    +                        frc = ",".join(str(v) for v in frc)
    +                    os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
    +
                 # Discord settings → env vars (env vars take precedence)
                 discord_cfg = yaml_cfg.get("discord", {})
                 if isinstance(discord_cfg, dict):
    ```
  - `gateway/platforms/slack.py`

    ```diff
    @@ -14,6 +14,7 @@
     import os
     import re
     import time
    +from dataclasses import dataclass, field
     from typing import Dict, Optional, Any, Tuple
     
     try:
    ```

    ```diff
    @@ -45,6 +46,14 @@
     logger = logging.getLogger(__name__)
     
     
    +@dataclass
    +class _ThreadContextCache:
    +    """Cache entry for fetched thread context."""
    +    content: str
    +    fetched_at: float = field(default_factory=time.monotonic)
    +    message_count: int = 0
    +
    +
     def check_slack_requirements() -> bool:
         """Check if Slack dependencies are available."""
         return SLACK_AVAILABLE
    ```

    ```diff
    @@ -101,6 +110,9 @@ def __init__(self, config: PlatformConfig):
             # session + memory scoping.
             self._assistant_threads: Dict[Tuple[str, str], Dict[str, str]] = {}
             self._ASSISTANT_THREADS_MAX = 5000
    +        # Cache for _fetch_thread_context results: cache_key → _ThreadContextCache
    +        self._thread_context_cache: Dict[str, _ThreadContextCache] = {}
    +        self._THREAD_CACHE_TTL = 60.0
     
         async def connect(self) -> bool:
             """Connect to Slack via Socket Mode."""
    ```

    ```diff
    @@ -281,6 +293,7 @@ async def send(
                     kwargs = {
                         "channel": chat_id,
                         "text": chunk,
    +                    "mrkdwn": True,
                     }
                     if thread_ts:
                         kwargs["thread_ts"] = thread_ts
    ```

    ```diff
    @@ -323,9 +336,7 @@ async def edit_message(
             if not self._app:
                 return SendResult(success=False, error="Not connected")
             try:
    -            # Convert standard markdown → Slack mrkdwn
                 formatted = self.format_message(content)
    -
                 await self._get_client(chat_id).chat_update(
                     channel=chat_id,
                     ts=message_id,
    ```

    ```diff
    @@ -457,13 +468,36 @@ def _ph(value: str) -> str:
             text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text)
     
             # 3) Convert markdown links [text](url) → <url|text>
    +        def _convert_markdown_link(m):
    +            label = m.group(1)
    +            url = m.group(2).strip()
    +            if url.startswith('<') and url.endswith('>'):
    +                url = url[1:-1].strip()
    +            return _ph(f'<{url}|{label}>')
    +
    +        text = re.sub(
    +            r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)',
    +            _convert_markdown_link,
    +            text,
    +        )
    +
    +        # 4) Protect existing Slack entities/manual links so escaping and later
    +        #    formatting passes don't break them.
             text = re.sub(
    -            r'\[([^\]]+)\]\(([^)]+)\)',
    -            lambda m: _ph(f'<{m.group(2)}|{m.group(1)}>'),
    +            r'(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)',
    +            lambda m: _ph(m.group(1)),
                 text,
             )
     
    -        # 4) Convert headers (## Title) → *Title* (bold)
    +        # 5) Protect blockquote markers before escaping
    +        text = re.sub(r'^(>+\s)', lambda m: _ph(m.group(0)), text, flags=re.MULTILINE)
    +
    +        # 6) Escape Slack control characters in remaining plain text.
    +        # Unescape first so already-escaped input doesn't get double-escaped.
    +        text = text.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>')
    +        text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
    +
    +        # 7) Convert headers (## Title) → *Title* (bold)
             def _convert_header(m):
                 inner = m.group(1).strip()
                 # Strip redundant bold markers inside a header
    ```

    ```diff
    @@ -474,34 +508,39 @@ def _convert_header(m):
                 r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE
             )
     
    -        # 5) Convert bold: **text** → *text* (Slack bold)
    +        # 8) Convert bold+italic: ***text*** → *_text_* (Slack bold wrapping italic)
    +        text = re.sub(
    +            r'\*\*\*(.+?)\*\*\*',
    +            lambda m: _ph(f'*_{m.group(1)}_*'),
    +            text,
    +        )
    +
    +        # 9) Convert bold: **text** → *text* (Slack bold)
             text = re.sub(
                 r'\*\*(.+?)\*\*',
                 lambda m: _ph(f'*{m.group(1)}*'),
                 text,
             )
     
    -        # 6) Convert italic: _text_ stays as _text_ (already Slack italic)
    -        #    Single *text* → _text_ (Slack italic)
    +        # 10) Convert italic: _text_ stays as _text_ (already Slack italic)
    +        #     Single *text* → _text_ (Slack italic)
             text = re.sub(
                 r'(?<!\*)\*([^*\n]+)\*(?!\*)',
                 lambda m: _ph(f'_{m.group(1)}_'),
                 text,
             )
     
    -        # 7) Convert strikethrough: ~~text~~ → ~text~
    +        # 11) Convert strikethrough: ~~text~~ → ~text~
             text = re.sub(
                 r'~~(.+?)~~',
                 lambda m: _ph(f'~{m.group(1)}~'),
                 text,
             )
     
    -        # 8) Convert blockquotes: > text → > text (same syntax, just ensure
    -        #    no extra escaping happens to the > character)
    -        # Slack uses the same > prefix, so this is a no-op for content.
    +        # 12) Blockquotes: > prefix is already protected by step 5 above.
     
    -        # 9) Restore placeholders in reverse order
    -        for key in reversed(list(placeholders.keys())):
    +        # 13) Restore placeholders in reverse order
    +        for key in reversed(placeholders):
                 text = text.replace(key, placeholders[key])
     
             return text
    ```

    ```diff
    @@ -914,9 +953,26 @@ async def _handle_slack_message(self, event: dict) -> None:
                         if v > cutoff
                     }
     
    -        # Ignore bot messages (including our own)
    +        # Bot message filtering (SLACK_ALLOW_BOTS / config allow_bots):
    +        #   "none"     — ignore all bot messages (default, backward-compatible)
    +        #   "mentions" — accept bot messages only when they @mention us
    +        #   "all"      — accept all bot messages (except our own)
             if event.get("bot_id") or event.get("subtype") == "bot_message":
    -            return
    +            allow_bots = self.config.extra.get("allow_bots", "")
    +            if not allow_bots:
    +                allow_bots = os.getenv("SLACK_ALLOW_BOTS", "none")
    +            allow_bots = str(allow_bots).lower().strip()
    +            if allow_bots == "none":
    +                return
    +            elif allow_bots == "mentions":
    +                text_check = event.get("text", "")
    +                if self._bot_user_id and f"<@{self._bot_user_id}>" not in text_check:
    +                    return
    +            # "all" falls through to process the message
    +            # Always ignore our own messages to prevent echo loops
    +            msg_user = event.get("user", "")
    +            if msg_user and self._bot_user_id and msg_user == self._bot_user_id:
    +                return
     
             # Ignore message edits and deletions
             subtype = event.get("subtype")
    ```

    ```diff
    @@ -948,7 +1004,7 @@ async def _handle_slack_message(self, event: dict) -> None:
             channel_type = event.get("channel_type", "")
             if not channel_type and channel_id.startswith("D"):
                 channel_type = "im"
    -        is_dm = channel_type == "im"
    +        is_dm = channel_type in ("im", "mpim")  # Both 1:1 and group DMs
     
             # Build thread_ts for session keying.
             # In channels: fall back to ts so each top-level @mention starts a
    ```

    ```diff
    @@ -961,6 +1017,8 @@ async def _handle_slack_message(self, event: dict) -> None:
                 thread_ts = event.get("thread_ts") or ts  # ts fallback for channels
     
             # In channels, respond if:
    +        #   0. Channel is in free_response_channels, OR require_mention is
    +        #      disabled — always process regardless of mention.
             #   1. The bot is @mentioned in this message, OR
             #   2. The message is a reply in a thread the bot started/participated in, OR
             #   3. The message is in a thread where the bot was previously @mentioned, OR
    ```

    ```diff
    @@ -970,24 +1028,29 @@ async def _handle_slack_message(self, event: dict) -> None:
             event_thread_ts = event.get("thread_ts")
             is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)
     
    -        if not is_dm and bot_uid and not is_mentioned:
    -            reply_to_bot_thread = (
    -                is_thread_reply and event_thread_ts in self._bot_message_ts
    -            )
    -            in_mentioned_thread = (
    -                event_thread_ts is not None
    -                and event_thread_ts in self._mentioned_threads
    -            )
    -            has_session = (
    -                is_thread_reply
    -                and self._has_active_session_for_thread(
    -                    channel_id=channel_id,
    -                    thread_ts=event_thread_ts,
    -                    user_id=user_id,
    +        if not is_dm and bot_uid:
    +            if channel_id in self._slack_free_response_channels():
    +                pass  # Free-response channel — always process
    +            elif not self._slack_require_mention():
    +                pass  # Mention requirement disabled globally for Slack
    +            elif not is_mentioned:
    +                reply_to_bot_thread = (
    +                    is_thread_reply and event_thread_ts in self._bot_message_ts
                     )
    -            )
    -            if not reply_to_bot_thread and not in_mentioned_thread and not has_session:
    -                return
    +                in_mentioned_thread = (
    +                    event_thread_ts is not None
    +                    and event_thread_ts in self._mentioned_threads
    +                )
    +                has_session = (
    +                    is_thread_reply
    +                    and self._has_active_session_for_thread(
    +                        channel_id=channel_id,
    +                        thread_ts=event_thread_ts,
    +                        user_id=user_id,
    +                    )
    +                )
    +                if not reply_to_bot_thread and not in_mentioned_thread and not has_session:
    +                    return
     
             if is_mentioned:
                 # Strip the bot mention from the text
    ```

    ```diff
    @@ -1128,14 +1191,19 @@ async def _handle_slack_message(self, event: dict) -> None:
                 reply_to_message_id=thread_ts if thread_ts != ts else None,
             )
     
    -        # Add 👀 reaction to acknowledge receipt
    -        await self._add_reaction(channel_id, ts, "eyes")
    +        # Only react when bot is directly addressed (DM or @mention).
    +        # In listen-all channels (require_mention=false), reacting to every
    +        # casual message would be noisy.
    +        _should_react = is_dm or is_mentioned
    +
    +        if _should_react:
    +            await self._add_reaction(channel_id, ts, "eyes")
     
             await self.handle_message(msg_event)
     
    -        # Replace 👀 with ✅ when done
    -        await self._remove_reaction(channel_id, ts, "eyes")
    -        await self._add_reaction(channel_id, ts, "white_check_mark")
    +        if _should_react:
    +            await self._remove_reaction(channel_id, ts, "eyes")
    +            await self._add_reaction(channel_id, ts, "white_check_mark")
     
         # ----- Approval button support (Block Kit) -----
     
    ```

    ```diff
    @@ -1229,6 +1297,20 @@ async def _handle_approval_action(self, ack, body, action) -> None:
             msg_ts = message.get("ts", "")
             channel_id = body.get("channel", {}).get("id", "")
             user_name = body.get("user", {}).get("name", "unknown")
    +        user_id = body.get("user", {}).get("id", "")
    +
    +        # Only authorized users may click approval buttons.  Button clicks
    +        # bypass the normal message auth flow in gateway/run.py, so we must
    +        # check here as well.
    +        allowed_csv = os.getenv("SLACK_ALLOWED_USERS", "").strip()
    +        if allowed_csv:
    +            allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
    +            if "*" not in allowed_ids and user_id not in allowed_ids:
    +                logger.warning(
    +                    "[Slack] Unauthorized approval click by %s (%s) — ignoring",
    +                    user_name, user_id,
    +                )
    +                return
     
             # Map action_id to approval choice
             choice_map = {
    ```

    ```diff
    @@ -1239,10 +1321,9 @@ async def _handle_approval_action(self, ack, body, action) -> None:
             }
             choice = choice_map.get(action_id, "deny")
     
    -        # Prevent double-clicks
    -        if self._approval_resolved.get(msg_ts, False):
    +        # Prevent double-clicks — atomic pop; first caller gets False, others get True (default)
    +        if self._approval_resolved.pop(msg_ts, True):
                 return
    -        self._approval_resolved[msg_ts] = True
     
             # Update the message to show the decision and remove buttons
             label_map = {
    ```

    ```diff
    @@ -1297,8 +1378,7 @@ async def _handle_approval_action(self, ack, body, action) -> None:
             except Exception as exc:
                 logger.error("Failed to resolve gateway approval from Slack button: %s", exc)
     
    -        # Clean up stale approval state
    -        self._approval_resolved.pop(msg_ts, None)
    +        # (approval state already consumed by atomic pop above)
     
         # ----- Thread context fetching -----
     
    ```

    ```diff
    @@ -1309,57 +1389,104 @@ async def _fetch_thread_context(
             """Fetch recent thread messages to provide context when the bot is
             mentioned mid-thread for the first time.
     
    -        Returns a formatted string with thread history, or empty string on
    -        failure or if the thread is empty (just the parent message).
    +        This method is only called when there is NO active session for the
    +        thread (guarded at the call site by _has_active_session_for_thread).
    +        That guard ensures thread messages are prepended only on the very
    +        first turn — after that the session history already holds them, so
    +        there is no duplication across subsequent turns.
    +
    +        Results are cached for _THREAD_CACHE_TTL seconds per thread to avoid
    +        hammering conversations.replies (Tier 3, ~50 req/min).
    +
    +        Returns a formatted string with prior thread history, or empty string
    +        on failure or if the thread has no prior messages.
             """
    +        cache_key = f"{channel_id}:{thread_ts}"
    +        now = time.monotonic()
    +        cached = self._thread_context_cache.get(cache_key)
    +        if cached and (now - cached.fetched_at) < self._THREAD_CACHE_TTL:
    +            return cached.content
    +
             try:
                 client = self._get_client(channel_id)
    -            result = await client.conversations_replies(
    -                channel=channel_id,
    -                ts=thread_ts,
    -                limit=limit + 1,  # +1 because it includes the current message
    -                inclusive=True,
    -            )
    +
    +            # Retry with exponential backoff for Tier-3 rate limits (429).
    +            result = None
    +            for attempt in range(3):
    +                try:
    +                    result = await client.conversations_replies(
    +                        channel=channel_id,
    +                        ts=thread_ts,
    +                        limit=limit + 1,  # +1 because it includes the current message
    +                        inclusive=True,
    +                    )
    +                    break
    +                except Exception as exc:
    +                    # Check for rate-limit error from slack_sdk
    +                    err_str = str(exc).lower()
    +                    is_rate_limit = (
    +                        "ratelimited" in err_str
    +                        or "429" in err_str
    +                        or "rate_limited" in err_str
    +                    )
    +                    if is_rate_limit and attempt < 2:
    +                        retry_after = 1.0 * (2 ** attempt)  # 1s, 2s
    +                        logger.warning(
    +                            "[Slack] conversations.replies rate limited; retrying in %.1fs (attempt %d/3)",
    +                            retry_after, attempt + 1,
    +                        )
    +                        await asyncio.sleep(retry_after)
    +                        continue
    +                    raise
    +
    +            if result is None:
    +                return ""
    +
                 messages = result.get("messages", [])
                 if not messages:
                     return ""
     
    +            bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
                 context_parts = []
                 for msg in messages:
                     msg_ts = msg.get("ts", "")
    -                # Skip the current message (the one that triggered this fetch)
    +                # Exclude the current triggering message — it will be delivered
    +                # as the user message itself, so including it here would duplicate it.
                     if msg_ts == current_ts:
                         continue
    -                # Skip bot messages from ourselves
    +                # Exclude our own bot messages to avoid circular context.
                     if msg.get("bot_id") or msg.get("subtype") == "bot_message":
                         continue
     
    -                msg_user = msg.get("user", "unknown")
                     msg_text = msg.get("text", "").strip()
                     if not msg_text:
                         continue
     
                     # Strip bot mentions from context messages
    -                bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
                     if bot_uid:
                         msg_text = msg_text.replace(f"<@{bot_uid}>", "").strip()
     
    -                # Mark the thread parent
    +                msg_user = msg.get("user", "unknown")
                     is_parent = msg_ts == thread_ts
                     prefix = "[thread parent] " if is_parent else ""
    -
    -                # Resolve user name (cached)
                     name = await self._resolve_user_name(msg_user, chat_id=channel_id)
                     context_parts.append(f"{prefix}{name}: {msg_text}")
     
    -            if not context_parts:
    -                return ""
    +            content = ""
    +            if context_parts:
    +                content = (
    +                    "[Thread context — prior messages in this thread (not yet in conversation history):]\n"
    +                    + "\n".join(context_parts)
    +                    + "\n[End of thread context]\n\n"
    +                )
     
    -            return (
    -                "[Thread context — previous messages in this thread:]\n"
    -                + "\n".join(context_parts)
    -                + "\n[End of thread context]\n\n"
    +            self._thread_context_cache[cache_key] = _ThreadContextCache(
    +                content=content,
    +                fetched_at=now,
    +                message_count=len(context_parts),
                 )
    +            return content
    +
             except Exception as e:
                 logger.warning("[Slack] Failed to fetch thread context: %s", e)
                 return ""
    ```

    ```diff
    @@ -1515,3 +1642,30 @@ async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes
                             continue
                         raise
             raise last_exc
    +
    +    # ── Channel mention gating ─────────────────────────────────────────────
    +
    +    def _slack_require_mention(self) -> bool:
    +        """Return whether channel messages require an explicit bot mention.
    +
    +        Uses explicit-false parsing (like Discord/Matrix) rather than
    +        truthy parsing, since the safe default is True (gating on).
    +        Unrecognised or empty values keep gating enabled.
    +        """
    +        configured = self.config.extra.get("require_mention")
    +        if configured is not None:
    +            if isinstance(configured, str):
    +                return configured.lower() not in ("false", "0", "no", "off")
    +            return bool(configured)
    +        return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
    +
    +    def _slack_free_response_channels(self) -> set:
    +        """Return channel IDs where no @mention is required."""
    +        raw = self.config.extra.get("free_response_channels")
    +        if raw is None:
    +            raw = os.getenv("SLACK_FREE_RESPONSE_CHANNELS", "")
    +        if isinstance(raw, list):
    +            return {str(part).strip() for part in raw if str(part).strip()}
    +        if isinstance(raw, str) and raw.strip():
    +            return {part.strip() for part in raw.split(",") if part.strip()}
    +        return set()
    ```
  - `gateway/platforms/telegram.py`

    ```diff
    @@ -1398,6 +1398,15 @@ async def _handle_callback_query(
                         await query.answer(text="Invalid approval data.")
                         return
     
    +                # Only authorized users may click approval buttons.
    +                caller_id = str(getattr(query.from_user, "id", ""))
    +                allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip()
    +                if allowed_csv:
    +                    allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
    +                    if "*" not in allowed_ids and caller_id not in allowed_ids:
    +                        await query.answer(text="⛔ You are not authorized to approve commands.")
    +                        return
    +
                     session_key = self._approval_state.pop(approval_id, None)
                     if not session_key:
                         await query.answer(text="This approval has already been resolved.")
    ```
  - `hermes_state.py`

    ```diff
    @@ -944,7 +944,8 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
                     try:
                         msg["tool_calls"] = json.loads(msg["tool_calls"])
                     except (json.JSONDecodeError, TypeError):
    -                    pass
    +                    logger.warning("Failed to deserialize tool_calls in get_messages, falling back to []")
    +                    msg["tool_calls"] = []
                 result.append(msg)
             return result
     
    ```

    ```diff
    @@ -972,7 +973,8 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
                     try:
                         msg["tool_calls"] = json.loads(row["tool_calls"])
                     except (json.JSONDecodeError, TypeError):
    -                    pass
    +                    logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []")
    +                    msg["tool_calls"] = []
                 # Restore reasoning fields on assistant messages so providers
                 # that replay reasoning (OpenRouter, OpenAI, Nous) receive
                 # coherent multi-turn reasoning context.
    ```

    ```diff
    @@ -983,12 +985,14 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
                         try:
                             msg["reasoning_details"] = json.loads(row["reasoning_details"])
                         except (json.JSONDecodeError, TypeError):
    -                        pass
    +                        logger.warning("Failed to deserialize reasoning_details, falling back to None")
    +                        msg["reasoning_details"] = None
                     if row["codex_reasoning_items"]:
                         try:
                             msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
                         except (json.JSONDecodeError, TypeError):
    -                        pass
    +                        logger.warning("Failed to deserialize codex_reasoning_items, falling back to None")
    +                        msg["codex_reasoning_items"] = None
                 messages.append(msg)
             return messages
     
    ```
  - `tests/gateway/test_slack.py`

    ```diff
    @@ -619,6 +619,18 @@ def test_link_conversion(self, adapter):
             result = adapter.format_message("[click here](https://example.com)")
             assert result == "<https://example.com|click here>"
     
    +    def test_link_conversion_strips_markdown_angle_brackets(self, adapter):
    +        result = adapter.format_message("[click here](<https://example.com>)")
    +        assert result == "<https://example.com|click here>"
    +
    +    def test_escapes_control_characters(self, adapter):
    +        result = adapter.format_message("AT&T < 5 > 3")
    +        assert result == "AT&amp;T &lt; 5 &gt; 3"
    +
    +    def test_preserves_existing_slack_entities(self, adapter):
    +        text = "Hey <@U123>, see <https://example.com|example> and <!here>"
    +        assert adapter.format_message(text) == text
    +
         def test_strikethrough(self, adapter):
             assert adapter.format_message("~~deleted~~") == "~deleted~"
     
    ```

    ```diff
    @@ -643,6 +655,325 @@ def test_empty_string(self, adapter):
         def test_none_passthrough(self, adapter):
             assert adapter.format_message(None) is None
     
    +    def test_blockquote_preserved(self, adapter):
    +        """Single-line blockquote > marker is preserved."""
    +        assert adapter.format_message("> quoted text") == "> quoted text"
    +
    +    def test_multiline_blockquote(self, adapter):
    +        """Multi-line blockquote preserves > on each line."""
    +        text = "> line one\n> line two"
    +        assert adapter.format_message(text) == "> line one\n> line two"
    +
    +    def test_blockquote_with_formatting(self, adapter):
    +        """Blockquote containing bold text."""
    +        assert adapter.format_message("> **bold quote**") == "> *bold quote*"
    +
    +    def test_nested_blockquote(self, adapter):
    +        """Multiple > characters for nested quotes."""
    +        assert adapter.format_message(">> deeply quoted") == ">> deeply quoted"
    +
    +    def test_blockquote_mixed_with_plain(self, adapter):
    +        """Blockquote lines interleaved with plain text."""
    +        text = "normal\n> quoted\nnormal again"
    +        result = adapter.format_message(text)
    +        assert "> quoted" in result
    +        assert "normal" in result
    +
    +    def test_non_prefix_gt_still_escaped(self, adapter):
    +        """Greater-than in mid-line is still escaped."""
    +        assert adapter.format_message("5 > 3") == "5 &gt; 3"
    +
    +    def test_blockquote_with_code(self, adapter):
    +        """Blockquote containing inline code."""
    +        result = adapter.format_message("> use `fmt.Println`")
    +        assert result.startswith(">")
    +        assert "`fmt.Println`" in result
    +
    +    def test_bold_italic_combined(self, adapter):
    +        """Triple-star ***text*** converts to Slack bold+italic *_text_*."""
    +        assert adapter.format_message("***hello***") == "*_hello_*"
    +
    +    def test_bold_italic_with_surrounding_text(self, adapter):
    +        """Bold+italic in a sentence."""
    +        result = adapter.format_message("This is ***important*** stuff")
    +        assert "*_important_*" in result
    +
    +    def test_bold_italic_does_not_break_plain_bold(self, adapter):
    +        """**bold** still works after adding ***bold italic*** support."""
    +        assert adapter.format_message("**bold**") == "*bold*"
    +
    +    def test_bold_italic_does_not_break_plain_italic(self, adapter):
    +        """*italic* still works after adding ***bold italic*** support."""
    +        assert adapter.format_message("*italic*") == "_italic_"
    +
    +    def test_bold_italic_mixed_with_bold(self, adapter):
    +        """Both ***bold italic*** and **bold** in the same message."""
    +        result = adapter.format_message("***important*** and **bold**")
    +        assert "*_important_*" in result
    +        assert "*bold*" in result
    +
    +    def test_pre_escaped_ampersand_not_double_escaped(self, adapter):
    +        """Already-escaped &amp; must not become &amp;amp;."""
    +        assert adapter.format_message("&amp;") == "&amp;"
    +
    +    def test_pre_escaped_lt_not_double_escaped(self, adapter):
    +        """Already-escaped &lt; must not become &amp;lt;."""
    +        assert adapter.format_message("&lt;") == "&lt;"
    +
    +    def test_pre_escaped_gt_not_double_escaped(self, adapter):
    +        """Already-escaped &gt; in plain text must not become &amp;gt;."""
    +        assert adapter.format_message("5 &gt; 3") == "5 &gt; 3"
    +
    +    def test_mixed_raw_and_escaped_entities(self, adapter):
    +        """Raw & and pre-escaped &amp; coexist correctly."""
    +        result = adapter.format_message("AT&T and &amp; entity")
    +        assert result == "AT&amp;T and &amp; entity"
    +
    +    def test_link_with_parentheses_in_url(self, adapter):
    +        """Wikipedia-style URL with balanced parens is not truncated."""
    +        result = adapter.format_message("[Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
    +        assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
    +
    +    def test_link_with_multiple_paren_pairs(self, adapter):
    +        """URL with multiple balanced paren pairs."""
    +        result = adapter.format_message("[text](https://example.com/a_(b)_c_(d))")
    +        assert result == "<https://example.com/a_(b)_c_(d)|text>"
    +
    +    def test_link_without_parens_still_works(self, adapter):
    +        """Normal URL without parens is unaffected by regex change."""
    +        result = adapter.format_message("[click](https://example.com/path?q=1)")
    +        assert result == "<https://example.com/path?q=1|click>"
    +
    +    def test_link_with_angle_brackets_and_parens(self, adapter):
    +        """Angle-bracket URL with parens (CommonMark syntax)."""
    +        result = adapter.format_message("[Foo](<https://en.wikipedia.org/wiki/Foo_(bar)>)")
    +        assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
    +
    +    def test_escaping_is_idempotent(self, adapter):
    +        """Formatting already-formatted text produces the same result."""
    +        original = "AT&T < 5 > 3"
    +        once = adapter.format_message(original)
    +        twice = adapter.format_message(once)
    +        assert once == twice
    +
    +    # --- Entity preservation (spec-compliance) ---
    +
    +    def test_channel_mention_preserved(self, adapter):
    +        """<!channel> special mention passes through unchanged."""
    +        assert adapter.format_message("Attention <!channel>") == "Attention <!channel>"
    +
    +    def test_everyone_mention_preserved(self, adapter):
    +        """<!everyone> special mention passes through unchanged."""
    +        assert adapter.format_message("Hey <!everyone>") == "Hey <!everyone>"
    +
    +    def test_subteam_mention_preserved(self, adapter):
    +        """<!subteam^ID> user group mention passes through unchanged."""
    +        assert adapter.format_message("Paging <!subteam^S12345>") == "Paging <!subteam^S12345>"
    +
    +    def test_date_formatting_preserved(self, adapter):
    +        """<!date^...> formatting token passes through unchanged."""
    +        text = "Posted <!date^1392734382^{date_pretty}|Feb 18, 2014>"
    +        assert adapter.format_message(text) == text
    +
    +    def test_channel_link_preserved(self, adapter):
    +        """<#CHANNEL_ID> channel link passes through unchanged."""
    +        assert adapter.format_message("Join <#C12345>") == "Join <#C12345>"
    +
    +    # --- Additional edge cases ---
    +
    +    def test_message_only_code_block(self, adapter):
    +        """Entire message is a fenced code block — no conversion."""
    +        code = "```python\nx = 1\n```"
    +        assert adapter.format_message(code) == code
    +
    +    def test_multiline_mixed_formatting(self, adapter):
    +        """Multi-line message with headers, bold, links, code, and blockquotes."""
    +        text = "## Title\n**bold** and [link](https://x.com)\n> quote\n`code`"
    +        result = adapter.format_message(text)
    +        assert result.startswith("*Title*")
    +        assert "*bold*" in result
    +        assert "<https://x.com|link>" in result
    +        assert "> quote" in result
    +        assert "`code`" in result
    +
    +    def test_markdown_unordered_list_with_asterisk(self, adapter):
    +        """Asterisk list items must not trigger italic conversion."""
    +        text = "* item one\n* item two"
    +        result = adapter.format_message(text)
    +        assert "item one" in result
    +        assert "item two" in result
    +
    +    def test_nested_bold_in_link(self, adapter):
    +        """Bold inside link label — label is stashed before bold pass."""
    +        result = adapter.format_message("[**bold**](https://example.com)")
    +        assert "https://example.com" in result
    +        assert "bold" in result
    +
    +    def test_url_with_query_string_and_ampersand(self, adapter):
    +        """Ampersand in URL query string must not be escaped."""
    +        result = adapter.format_message("[link](https://x.com?a=1&b=2)")
    +        assert result == "<https://x.com?a=1&b=2|link>"
    +
    +    def test_emoji_shortcodes_passthrough(self, adapter):
    +        """Emoji shortcodes like :smile: pass through unchanged."""
    +        assert adapter.format_message(":smile: hello :wave:") == ":smile: hello :wave:"
    +
    +
    +# ---------------------------------------------------------------------------
    +# TestEditMessage
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestEditMessage:
    +    """Verify that edit_message() applies mrkdwn formatting before sending."""
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_formats_bold(self, adapter):
    +        """edit_message converts **bold** to Slack *bold*."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +        await adapter.edit_message("C123", "1234.5678", "**hello world**")
    +        kwargs = adapter._app.client.chat_update.call_args.kwargs
    +        assert kwargs["text"] == "*hello world*"
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_formats_links(self, adapter):
    +        """edit_message converts markdown links to Slack format."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +        await adapter.edit_message("C123", "1234.5678", "[click](https://example.com)")
    +        kwargs = adapter._app.client.chat_update.call_args.kwargs
    +        assert kwargs["text"] == "<https://example.com|click>"
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_preserves_blockquotes(self, adapter):
    +        """edit_message preserves blockquote > markers."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +        await adapter.edit_message("C123", "1234.5678", "> quoted text")
    +        kwargs = adapter._app.client.chat_update.call_args.kwargs
    +        assert kwargs["text"] == "> quoted text"
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_escapes_control_chars(self, adapter):
    +        """edit_message escapes & < > in plain text."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +        await adapter.edit_message("C123", "1234.5678", "AT&T < 5 > 3")
    +        kwargs = adapter._app.client.chat_update.call_args.kwargs
    +        assert kwargs["text"] == "AT&amp;T &lt; 5 &gt; 3"
    +
    +
    +# ---------------------------------------------------------------------------
    +# TestEditMessageStreamingPipeline
    +# ---------------------------------------------------------------------------
    +
    +
    +class TestEditMessageStreamingPipeline:
    +    """E2E: verify that sequential streaming edits all go through format_message.
    +
    +    Simulates the GatewayStreamConsumer pattern where edit_message is called
    +    repeatedly with progressively longer accumulated text.  Every call must
    +    produce properly formatted mrkdwn in the chat_update payload.
    +    """
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_formats_streaming_updates(self, adapter):
    +        """Simulates streaming: multiple edits, each should be formatted."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +
    +        # First streaming update — bold
    +        result1 = await adapter.edit_message("C123", "ts1", "**Processing**...")
    +        assert result1.success is True
    +        kwargs1 = adapter._app.client.chat_update.call_args.kwargs
    +        assert kwargs1["text"] == "*Processing*..."
    +
    +        # Second streaming update — bold + link
    +        result2 = await adapter.edit_message(
    +            "C123", "ts1", "**Done!** See [results](https://example.com)"
    +        )
    +        assert result2.success is True
    +        kwargs2 = adapter._app.client.chat_update.call_args.kwargs
    +        assert kwargs2["text"] == "*Done!* See <https://example.com|results>"
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_formats_code_and_bold(self, adapter):
    +        """Streaming update with code block and bold — code must be preserved."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +
    +        content = "**Result:**\n```python\nprint('hello')\n```"
    +        result = await adapter.edit_message("C123", "ts1", content)
    +        assert result.success is True
    +        kwargs = adapter._app.client.chat_update.call_args.kwargs
    +        assert kwargs["text"].startswith("*Result:*")
    +        assert "```python\nprint('hello')\n```" in kwargs["text"]
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_formats_blockquote_in_stream(self, adapter):
    +        """Streaming update with blockquote — '>' marker must survive."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +
    +        content = "> **Important:** do this\nnormal line"
    +        result = await adapter.edit_message("C123", "ts1", content)
    +        assert result.success is True
    +        kwargs = adapter._app.client.chat_update.call_args.kwargs
    +        assert kwargs["text"].startswith("> *Important:*")
    +        assert "normal line" in kwargs["text"]
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_formats_progressive_accumulation(self, adapter):
    +        """Simulate real streaming: text grows with each edit, all formatted."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +
    +        updates = [
    +            ("**Step 1**", "*Step 1*"),
    +            ("**Step 1**\n**Step 2**", "*Step 1*\n*Step 2*"),
    +            (
    +                "**Step 1**\n**Step 2**\nSee [docs](https://docs.example.com)",
    +                "*Step 1*\n*Step 2*\nSee <https://docs.example.com|docs>",
    +            ),
    +        ]
    +
    +        for raw, expected in updates:
    +            result = await adapter.edit_message("C123", "ts1", raw)
    +            assert result.success is True
    +            kwargs = adapter._app.client.chat_update.call_args.kwargs
    +            assert kwargs["text"] == expected, f"Failed for input: {raw!r}"
    +
    +        # Total edit count should match number of updates
    +        assert adapter._app.client.chat_update.call_count == len(updates)
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_formats_bold_italic(self, adapter):
    +        """Bold+italic ***text*** is formatted as *_text_* in edited messages."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +        await adapter.edit_message("C123", "ts1", "***important*** update")
    +        kwargs = adapter._app.client.chat_update.call_args.kwargs
    +        assert "*_important_*" in kwargs["text"]
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_does_not_double_escape(self, adapter):
    +        """Pre-escaped entities in edited messages must not get double-escaped."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +        await adapter.edit_message("C123", "ts1", "5 &gt; 3 and &amp; entity")
    +        kwargs = adapter._app.client.chat_update.call_args.kwargs
    +        assert "&amp;gt;" not in kwargs["text"]
    +        assert "&amp;amp;" not in kwargs["text"]
    +        assert "&gt;" in kwargs["text"]
    +        assert "&amp;" in kwargs["text"]
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_formats_url_with_parens(self, adapter):
    +        """Wikipedia-style URL with parens survives edit pipeline."""
    +        adapter._app.client.chat_update = AsyncMock(return_value={"ok": True})
    +        await adapter.edit_message("C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
    +        kwargs = adapter._app.client.chat_update.call_args.kwargs
    +        assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
    +
    +    @pytest.mark.asyncio
    +    async def test_edit_message_not_connected(self, adapter):
    +        """edit_message returns failure when adapter is not connected."""
    +        adapter._app = None
    +        result = await adapter.edit_message("C123", "ts1", "**hello**")
    +        assert result.success is False
    +        assert "Not connected" in result.error
    +
     
     # ---------------------------------------------------------------------------
     # TestReactions
    ```

    ```diff
    @@ -1085,6 +1416,48 @@ async def test_short_message_single_send(self, adapter):
             await adapter.send("C123", "hello world")
             assert adapter._app.client.chat_postMessage.call_count == 1
     
    +    @pytest.mark.asyncio
    +    async def test_send_preserves_blockquote_formatting(self, adapter):
    +        """Blockquote '>' markers must survive format → chunk → send pipeline."""
    +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
    +        await adapter.send("C123", "> quoted text\nnormal text")
    +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
    +        sent_text = kwargs["text"]
    +        assert sent_text.startswith("> quoted text")
    +        assert "normal text" in sent_text
    +
    +    @pytest.mark.asyncio
    +    async def test_send_formats_bold_italic(self, adapter):
    +        """Bold+italic ***text*** is formatted as *_text_* in sent messages."""
    +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
    +        await adapter.send("C123", "***important*** update")
    +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
    +        assert "*_important_*" in kwargs["text"]
    +
    +    @pytest.mark.asyncio
    +    async def test_send_explicitly_enables_mrkdwn(self, adapter):
    +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
    +        await adapter.send("C123", "**hello**")
    +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
    +        assert kwargs.get("mrkdwn") is True
    +
    +    @pytest.mark.asyncio
    +    async def test_send_does_not_double_escape_entities(self, adapter):
    +        """Pre-escaped &amp; in sent messages must not become &amp;amp;."""
    +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
    +        await adapter.send("C123", "Use &amp; for ampersand")
    +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
    +        assert "&amp;amp;" not in kwargs["text"]
    +        assert "&amp;" in kwargs["text"]
    +
    +    @pytest.mark.asyncio
    +    async def test_send_formats_url_with_parens(self, adapter):
    +        """Wikipedia-style URL with parens survives send pipeline."""
    +        adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
    +        await adapter.send("C123", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))")
    +        kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
    +        assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
    +
     
     # ---------------------------------------------------------------------------
     # TestReplyBroadcast
    ```
  - `tests/gateway/test_slack_mention.py`

    ```diff
    @@ -0,0 +1,312 @@
    +"""
    +Tests for Slack mention gating (require_mention / free_response_channels).
    +
    +Follows the same pattern as test_whatsapp_group_gating.py.
    +"""
    +
    +import sys
    +from unittest.mock import MagicMock
    +
    +from gateway.config import Platform, PlatformConfig
    +
    +
    +# ---------------------------------------------------------------------------
    +# Mock slack-bolt if not installed (same as test_slack.py)
    +# ---------------------------------------------------------------------------
    +
    +def _ensure_slack_mock():
    +    if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
    +        return
    +
    +    slack_bolt = MagicMock()
    +    slack_bolt.async_app.AsyncApp = MagicMock
    +    slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
    +
    +    slack_sdk = MagicMock()
    +    slack_sdk.web.async_client.AsyncWebClient = MagicMock
    +
    +    for name, mod in [
    +        ("slack_bolt", slack_bolt),
    +        ("slack_bolt.async_app", slack_bolt.async_app),
    +        ("slack_bolt.adapter", slack_bolt.adapter),
    +        ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
    +        ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
    +        ("slack_sdk", slack_sdk),
    +        ("slack_sdk.web", slack_sdk.web),
    +        ("slack_sdk.web.async_client", slack_sdk.web.async_client),
    +    ]:
    +        sys.modules.setdefault(name, mod)
    +
    +
    +_ensure_slack_mock()
    +
    +import gateway.platforms.slack as _slack_mod
    +_slack_mod.SLACK_AVAILABLE = True
    +
    +from gateway.platforms.slack import SlackAdapter  # noqa: E402
    +
    +
    +# ---------------------------------------------------------------------------
    +# Helpers
    +# ---------------------------------------------------------------------------
    +
    +BOT_USER_ID = "U_BOT_123"
    +CHANNEL_ID = "C0AQWDLHY9M"
    +OTHER_CHANNEL_ID = "C9999999999"
    +
    +
    +def _make_adapter(require_mention=None, free_response_channels=None):
    +    extra = {}
    +    if require_mention is not None:
    +        extra["require_mention"] = require_mention
    +    if free_response_channels is not None:
    +        extra["free_response_channels"] = free_response_channels
    +
    +    adapter = object.__new__(SlackAdapter)
    +    adapter.platform = Platform.SLACK
    +    adapter.config = PlatformConfig(enabled=True, extra=extra)
    +    adapter._bot_user_id = BOT_USER_ID
    +    adapter._team_bot_user_ids = {}
    +    return adapter
    +
    +
    +# ---------------------------------------------------------------------------
    +# Tests: _slack_require_mention
    +# ---------------------------------------------------------------------------
    +
    +def test_require_mention_defaults_to_true(monkeypatch):
    +    monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
    +    adapter = _make_adapter()
    +    assert adapter._slack_require_mention() is True
    +
    +
    +def test_require_mention_false():
    +    adapter = _make_adapter(require_mention=False)
    +    assert adapter._slack_require_mention() is False
    +
    +
    +def test_require_mention_true():
    +    adapter = _make_adapter(require_mention=True)
    +    assert adapter._slack_require_mention() is True
    +
    +
    +def test_require_mention_string_true():
    +    adapter = _make_adapter(require_mention="true")
    +    assert adapter._slack_require_mention() is True
    +
    +
    +def test_require_mention_string_false():
    +    adapter = _make_adapter(require_mention="false")
    +    assert adapter._slack_require_mention() is False
    +
    +
    +def test_require_mention_string_no():
    +    adapter = _make_adapter(require_mention="no")
    +    assert adapter._slack_require_mention() is False
    +
    +
    +def test_require_mention_string_yes():
    +    adapter = _make_adapter(require_mention="yes")
    +    assert adapter._slack_require_mention() is True
    +
    +
    +def test_require_mention_empty_string_stays_true():
    +    """Empty/malformed strings keep gating ON (explicit-false parser)."""
    +    adapter = _make_adapter(require_mention="")
    +    assert adapter._slack_require_mention() is True
    +
    +
    +def test_require_mention_malformed_string_stays_true():
    +    """Unrecognised values keep gating ON (fail-closed)."""
    +    adapter = _make_adapter(require_mention="maybe")
    +    assert adapter._slack_require_mention() is True
    +
    +
    +def test_require_mention_env_var_fallback(monkeypatch):
    +    monkeypatch.setenv("SLACK_REQUIRE_MENTION", "false")
    +    adapter = _make_adapter()  # no config value -> falls back to env
    +    assert adapter._slack_require_mention() is False
    +
    +
    +def test_require_mention_env_var_default_true(monkeypatch):
    +    monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
    +    adapter = _make_adapter()
    +    assert adapter._slack_require_mention() is True
    +
    +
    +# ---------------------------------------------------------------------------
    +# Tests: _slack_free_response_channels
    +# ---------------------------------------------------------------------------
    +
    +def test_free_response_channels_default_empty(monkeypatch):
    +    monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
    +    adapter = _make_adapter()
    +    assert adapter._slack_free_response_channels() == set()
    +
    +
    +def test_free_response_channels_list():
    +    adapter = _make_adapter(free_response_channels=[CHANNEL_ID, OTHER_CHANNEL_ID])
    +    result = adapter._slack_free_response_channels()
    +    assert CHANNEL_ID in result
    +    assert OTHER_CHANNEL_ID in result
    +
    +
    +def test_free_response_channels_csv_string():
    +    adapter = _make_adapter(free_response_channels=f"{CHANNEL_ID}, {OTHER_CHANNEL_ID}")
    +    result = adapter._slack_free_response_channels()
    +    assert CHANNEL_ID in result
    +    assert OTHER_CHANNEL_ID in result
    +
    +
    +def test_free_response_channels_empty_string():
    +    adapter = _make_adapter(free_response_channels="")
    +    assert adapter._slack_free_response_channels() == set()
    +
    +
    +def test_free_response_channels_env_var_fallback(monkeypatch):
    +    monkeypatch.setenv("SLACK_FREE_RESPONSE_CHANNELS", f"{CHANNEL_ID},{OTHER_CHANNEL_ID}")
    +    adapter = _make_adapter()  # no config value → falls back to env
    +    result = adapter._slack_free_response_channels()
    +    assert CHANNEL_ID in result
    +    assert OTHER_CHANNEL_ID in result
    +
    +
    +# ---------------------------------------------------------------------------
    +# Tests: mention gating integration (simulating _handle_slack_message logic)
    +# ---------------------------------------------------------------------------
    +
    +def _would_process(adapter, *, is_dm=False, channel_id=CHANNEL_ID,
    +                   text="hello", mentioned=False, thread_reply=False,
    +                   active_session=False):
    +    """Simulate the mention gating logic from _handle_slack_message.
    +
    +    Returns True if the message would be processed, False if it would be
    +    skipped (returned early).
    +    """
    +    bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id)
    +    if mentioned:
    +        text = f"<@{bot_uid}> {text}"
    +    is_mentioned = bot_uid and f"<@{bot_uid}>" in text
    +
    +    if not is_dm:
    +        if channel_id in adapter._slack_free_response_channels():
    +            return True
    +        elif not adapter._slack_require_mention():
    +            return True
    +        elif not is_mentioned:
    +            if thread_reply and active_session:
    +                return True
    +            else:
    +                return False
    +    return True
    +
    +
    +def test_default_require_mention_channel_without_mention_ignored():
    +    adapter = _make_adapter()  # default: require_mention=True
    +    assert _would_process(adapter, text="hello everyone") is False
    +
    +
    +def test_require_mention_false_channel_without_mention_processed():
    +    adapter = _make_adapter(require_mention=False)
    +    assert _would_process(adapter, text="hello everyone") is True
    +
    +
    +def test_channel_in_free_response_processed_without_mention():
    +    adapter = _make_adapter(
    +        require_mention=True,
    +        free_response_channels=[CHANNEL_ID],
    +    )
    +    assert _would_process(adapter, channel_id=CHANNEL_ID, text="hello") is True
    +
    +
    +def test_other_channel_not_in_free_response_still_gated():
    +    adapter = _make_adapter(
    +        require_mention=True,
    +        free_response_channels=[CHANNEL_ID],
    +    )
    +    assert _would_process(adapter, channel_id=OTHER_CHANNEL_ID, text="hello") is False
    +
    +
    +def test_dm_always_processed_regardless_of_setting():
    +    adapter = _make_adapter(require_mention=True)
    +    assert _would_process(adapter, is_dm=True, text="hello") is True
    +
    +
    +def test_mentioned_message_always_processed():
    +    adapter = _make_adapter(require_mention=True)
    +    assert _would_process(adapter, mentioned=True, text="what's up") is True
    +
    +
    +def test_thread_reply_with_active_session_processed():
    +    adapter = _make_adapter(require_mention=True)
    +    assert _would_process(
    +        adapter, text="followup",
    +        thread_reply=True, active_session=True,
    +    ) is True
    +
    +
    +def test_thread_reply_without_active_session_ignored():
    +    adapter = _make_adapter(require_mention=True)
    +    assert _would_process(
    +        adapter, text="followup",
    +        thread_reply=True, active_session=False,
    +    ) is False
    +
    +
    +def test_bot_uid_none_processes_channel_message():
    +    """When bot_uid is None (before auth_test), channel messages pass through.
    +
    +    This preserves the old behavior: the gating block is skipped entirely
    +    when bot_uid is falsy, so messages are not silently dropped during
    +    startup or for new workspaces.
    +    """
    +    adapter = _make_adapter(require_mention=True)
    +    adapter._bot_user_id = None
    +    adapter._team_bot_user_ids = {}
    +
    +    # With bot_uid=None, the `if not is_dm and bot_uid:` condition is False,
    +    # so the gating block is skipped — message passes through.
    +    bot_uid = adapter._team_bot_user_ids.get("T1", adapter._bot_user_id)
    +    assert bot_uid is None
    +
    +    # Simulate: gating block not entered when bot_uid is falsy
    +    is_dm = False
    +    if not is_dm and bot_uid:
    +        result = False  # would enter gating
    +    else:
    +        result = True  # gating skipped, message processed
    +    assert result is True
    +
    +
    +# ---------------------------------------------------------------------------
    +# Tests: config bridging
    +# ---------------------------------------------------------------------------
    +
    +def test_config_bridges_slack_free_response_channels(monkeypatch, tmp_path):
    +    from gateway.config import load_gateway_config
    +
    +    hermes_home = tmp_path / ".hermes"
    +    hermes_home.mkdir()
    +    (hermes_home / "config.yaml").write_text(
    +        "slack:\n"
    +        "  require_mention: false\n"
    +        "  free_response_channels:\n"
    +        "    - C0AQWDLHY9M\n"
    +        "    - C9999999999\n",
    +        encoding="utf-8",
    +    )
    +
    +    monkeypatch.setenv("HERMES_HOME", str(hermes_home))
    +    monkeypatch.delenv("SLACK_REQUIRE_MENTION", raising=False)
    +    monkeypatch.delenv("SLACK_FREE_RESPONSE_CHANNELS", raising=False)
    +
    +    config = load_gateway_config()
    +
    +    assert config is not None
    +    slack_extra = config.platforms[Platform.SLACK].extra
    +    assert slack_extra.get("require_mention") is False
    +    assert slack_extra.get("free_response_channels") == ["C0AQWDLHY9M", "C9999999999"]
    +    # Verify env vars were set by config bridging
    +    import os as _os
    +    assert _os.environ["SLACK_REQUIRE_MENTION"] == "false"
    +    assert _os.environ["SLACK_FREE_RESPONSE_CHANNELS"] == "C0AQWDLHY9M,C9999999999"
    ```
  - `tests/tools/test_send_message_tool.py`

    ```diff
    @@ -32,6 +32,30 @@ def _install_telegram_mock(monkeypatch, bot):
         monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
     
     
    +def _ensure_slack_mock(monkeypatch):
    +    if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
    +        return
    +
    +    slack_bolt = MagicMock()
    +    slack_bolt.async_app.AsyncApp = MagicMock
    +    slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
    +
    +    slack_sdk = MagicMock()
    +    slack_sdk.web.async_client.AsyncWebClient = MagicMock
    +
    +    for name, mod in [
    +        ("slack_bolt", slack_bolt),
    +        ("slack_bolt.async_app", slack_bolt.async_app),
    +        ("slack_bolt.adapter", slack_bolt.adapter),
    +        ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
    +        ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
    +        ("slack_sdk", slack_sdk),
    +        ("slack_sdk.web", slack_sdk.web),
    +        ("slack_sdk.web.async_client", slack_sdk.web.async_client),
    +    ]:
    +        monkeypatch.setitem(sys.modules, name, mod)
    +
    +
     class TestSendMessageTool:
         def test_cron_duplicate_target_is_skipped_and_explained(self):
             home = SimpleNamespace(chat_id="-1001")
    ```

    ```diff
    @@ -426,7 +450,7 @@ def test_long_message_is_chunked(self):
                 result = asyncio.run(
                     _send_to_platform(
                         Platform.DISCORD,
    -                    SimpleNamespace(enabled=True, token="tok", extra={}),
    +                    SimpleNamespace(enabled=True, token="***", extra={}),
                         "ch", long_msg,
                     )
                 )
    ```

    ```diff
    @@ -435,8 +459,115 @@ def test_long_message_is_chunked(self):
             for call in send.await_args_list:
                 assert len(call.args[2]) <= 2020  # each chunk fits the limit
     
    +    def test_slack_messages_are_formatted_before_send(self, monkeypatch):
    +        _ensure_slack_mock(monkeypatch)
    +
    +        import gateway.platforms.slack as slack_mod
    +
    +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
    +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
    +
    +        with patch("tools.send_message_tool._send_slack", send):
    +            result = asyncio.run(
    +                _send_to_platform(
    +                    Platform.SLACK,
    +                    SimpleNamespace(enabled=True, token="***", extra={}),
    +                    "C123",
    +                    "**hello** from [Hermes](<https://example.com>)",
    +                )
    +            )
    +
    +        assert result["success"] is True
    +        send.assert_awaited_once_with(
    +            "***",
    +            "C123",
    +            "*hello* from <https://example.com|Hermes>",
    +        )
    +
    +    def test_slack_bold_italic_formatted_before_send(self, monkeypatch):
    +        """Bold+italic ***text*** survives tool-layer formatting."""
    +        _ensure_slack_mock(monkeypatch)
    +        import gateway.platforms.slack as slack_mod
    +
    +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
    +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
    +        with patch("tools.send_message_tool._send_slack", send):
    +            result = asyncio.run(
    +                _send_to_platform(
    +                    Platform.SLACK,
    +                    SimpleNamespace(enabled=True, token="***", extra={}),
    +                    "C123",
    +                    "***important*** update",
    +                )
    +            )
    +        assert result["success"] is True
    +        sent_text = send.await_args.args[2]
    +        assert "*_important_*" in sent_text
    +
    +    def test_slack_blockquote_formatted_before_send(self, monkeypatch):
    +        """Blockquote '>' markers must survive formatting (not escaped to '&gt;')."""
    +        _ensure_slack_mock(monkeypatch)
    +        import gateway.platforms.slack as slack_mod
    +
    +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
    +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
    +        with patch("tools.send_message_tool._send_slack", send):
    +            result = asyncio.run(
    +                _send_to_platform(
    +                    Platform.SLACK,
    +                    SimpleNamespace(enabled=True, token="***", extra={}),
    +                    "C123",
    +                    "> important quote\n\nnormal text & stuff",
    +                )
    +            )
    +        assert result["success"] is True
    +        sent_text = send.await_args.args[2]
    +        assert sent_text.startswith("> important quote")
    +        assert "&amp;" in sent_text  # & is escaped
    +        assert "&gt;" not in sent_text.split("\n")[0]  # > in blockquote is NOT escaped
    +
    +    def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch):
    +        """Pre-escaped HTML entities survive tool-layer formatting without double-escaping."""
    +        _ensure_slack_mock(monkeypatch)
    +        import gateway.platforms.slack as slack_mod
    +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
    +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
    +        with patch("tools.send_message_tool._send_slack", send):
    +            result = asyncio.run(
    +                _send_to_platform(
    +                    Platform.SLACK,
    +                    SimpleNamespace(enabled=True, token="***", extra={}),
    +                    "C123",
    +                    "AT&amp;T &lt;tag&gt; test",
    +                )
    +            )
    +        assert result["success"] is True
    +        sent_text = send.await_args.args[2]
    +        assert "&amp;amp;" not in sent_text
    +        assert "&amp;lt;" not in sent_text
    +        assert "AT&amp;T" in sent_text
    +
    +    def test_slack_url_with_parens_formatted_before_send(self, monkeypatch):
    +        """Wikipedia-style URL with parens survives tool-layer formatting."""
    +        _ensure_slack_mock(monkeypatch)
    +        import gateway.platforms.slack as slack_mod
    +        monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
    +        send = AsyncMock(return_value={"success": True, "message_id": "1"})
    +        with patch("tools.send_message_tool._send_slack", send):
    +            result = asyncio.run(
    +                _send_to_platform(
    +                    Platform.SLACK,
    +                    SimpleNamespace(enabled=True, token="***", extra={}),
    +                    "C123",
    +                    "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))",
    +                )
    +            )
    +        assert result["success"] is True
    +        sent_text = send.await_args.args[2]
    +        assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in sent_text
    +
         def test_telegram_media_attaches_to_last_chunk(self):
    -        """When chunked, media files are sent only with the last chunk."""
    +
             sent_calls = []
     
             async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
    ```
  - `tools/send_message_tool.py`

    ```diff
    @@ -322,6 +322,13 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
     
         media_files = media_files or []
     
    +    if platform == Platform.SLACK and message:
    +        try:
    +            slack_adapter = SlackAdapter.__new__(SlackAdapter)
    +            message = slack_adapter.format_message(message)
    +        except Exception:
    +            logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True)
    +
         # Platform message length limits (from adapter class attributes)
         _MAX_LENGTHS = {
             Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH,
    ```

    ```diff
    @@ -571,7 +578,8 @@ async def _send_slack(token, chat_id, message):
             url = "https://slack.com/api/chat.postMessage"
             headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
             async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
    -            async with session.post(url, headers=headers, json={"channel": chat_id, "text": message}) as resp:
    +            payload = {"channel": chat_id, "text": message, "mrkdwn": True}
    +            async with session.post(url, headers=headers, json=payload) as resp:
                     data = await resp.json()
                     if data.get("ok"):
                         return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")}
    ```
- **#6268: fix(slack): respect require_mention config for channel messages**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#6809`, `#5885`
- **#6303: fix(gateway): read require_mention from config.extra for Slack adapter**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#6809`, `#5885`

## Fix status

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