{"repo":"NousResearch/hermes-agent","public_id":"5816","display_label":"Slack thread handling and approval button features","summary":"These members address Slack thread reply session matching issues and add approval button functionality for Slack and Telegram integrations. The work spans both a bug around thread replies being ignored and a related session key fix, alongside new gateway features.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5890,"merged_at":"2026-04-07T18:03:14+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-07T18:03:14+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":1953,"pr_number":5890,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-07T18:03:14+00:00"},{"issue_number":2950,"pr_number":5890,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-07T18:03:14+00:00"},{"issue_number":5816,"pr_number":5890,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-07T18:03:14+00:00"}],"members":[{"kind":"issue","number":5816,"title":"Bug: Slack thread replies without @mention ignored despite active session","url":null,"state":"closed"},{"kind":"pr","number":5833,"title":"fix/slack-thread-session-key-mismatch                                                                                                                                                                                 ","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5890","number":5890}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5890,"title":"feat(gateway): approval buttons for Slack & Telegram + Slack thread context","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/slack.py","gateway/platforms/telegram.py","tests/gateway/test_slack_approval_buttons.py","tests/gateway/test_telegram_approval_buttons.py"],"fix_hunks":[{"file":"gateway/platforms/slack.py","hunks":["@@ -84,6 +84,9 @@ def __init__(self, config: PlatformConfig):\n         self._seen_messages: Dict[str, float] = {}\n         self._SEEN_TTL = 300   # 5 minutes\n         self._SEEN_MAX = 2000  # prune threshold\n+        # Track pending approval message_ts → resolved flag to prevent\n+        # double-clicks on approval buttons.\n+        self._approval_resolved: Dict[str, bool] = {}\n \n     async def connect(self) -> bool:\n         \"\"\"Connect to Slack via Socket Mode.\"\"\"","@@ -176,6 +179,15 @@ async def handle_hermes_command(ack, command):\n                 await ack()\n                 await self._handle_slash_command(command)\n \n+            # Register Block Kit action handlers for approval buttons\n+            for _action_id in (\n+                \"hermes_approve_once\",\n+                \"hermes_approve_session\",\n+                \"hermes_approve_always\",\n+                \"hermes_deny\",\n+            ):\n+                self._app.action(_action_id)(self._handle_approval_action)\n+\n             # Start Socket Mode handler in background\n             self._handler = AsyncSocketModeHandler(self._app, app_token)\n             self._socket_mode_task = asyncio.create_task(self._handler.start_async())","@@ -791,6 +803,24 @@ async def _handle_slack_message(self, event: dict) -> None:\n             # Strip the bot mention from the text\n             text = text.replace(f\"<@{bot_uid}>\", \"\").strip()\n \n+            # When first mentioned in an existing thread, fetch thread context\n+            # so the agent understands the conversation it's joining.\n+            event_thread_ts = event.get(\"thread_ts\")\n+            is_thread_reply = event_thread_ts and event_thread_ts != ts\n+            if is_thread_reply and not self._has_active_session_for_thread(\n+                channel_id=channel_id,\n+                thread_ts=event_thread_ts,\n+                user_id=user_id,\n+            ):\n+                thread_context = await self._fetch_thread_context(\n+                    channel_id=channel_id,\n+                    thread_ts=event_thread_ts,\n+                    current_ts=ts,\n+                    team_id=team_id,\n+                )\n+                if thread_context:\n+                    text = thread_context + text\n+\n         # Determine message type\n         msg_type = MessageType.TEXT\n         if text.startswith(\"/\"):","@@ -912,6 +942,233 @@ async def _handle_slack_message(self, event: dict) -> None:\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+\n+    async def send_exec_approval(\n+        self, chat_id: str, command: str, session_key: str,\n+        description: str = \"dangerous command\",\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Send a Block Kit approval prompt with interactive buttons.\n+\n+        The buttons call ``resolve_gateway_approval()`` to unblock the waiting\n+        agent thread — same mechanism as the text ``/approve`` flow.\n+        \"\"\"\n+        if not self._app:\n+            return SendResult(success=False, error=\"Not connected\")\n+\n+        try:\n+            cmd_preview = command[:2900] + \"...\" if len(command) > 2900 else command\n+            thread_ts = self._resolve_thread_ts(None, metadata)\n+\n+            blocks = [\n+                {\n+                    \"type\": \"section\",\n+                    \"text\": {\n+                        \"type\": \"mrkdwn\",\n+                        \"text\": (\n+                            f\":warning: *Command Approval Required*\\n\"\n+                            f\"```{cmd_preview}```\\n\"\n+                            f\"Reason: {description}\"\n+                        ),\n+                    },\n+                },\n+                {\n+                    \"type\": \"actions\",\n+                    \"elements\": [\n+                        {\n+                            \"type\": \"button\",\n+                            \"text\": {\"type\": \"plain_text\", \"text\": \"Allow Once\"},\n+                            \"style\": \"primary\",\n+                            \"action_id\": \"hermes_approve_once\",\n+                            \"value\": session_key,\n+                        },\n+                        {\n+                            \"type\": \"button\",\n+                            \"text\": {\"type\": \"plain_text\", \"text\": \"Allow Session\"},\n+                            \"action_id\": \"hermes_approve_session\",\n+                            \"value\": session_key,\n+                        },\n+                        {\n+                            \"type\": \"button\",\n+                            \"text\": {\"type\": \"plain_text\", \"text\": \"Always Allow\"},\n+                            \"action_id\": \"hermes_approve_always\",\n+                            \"value\": session_key,\n+                        },\n+                        {\n+                            \"type\": \"button\",\n+                            \"text\": {\"type\": \"plain_text\", \"text\": \"Deny\"},\n+                            \"style\": \"danger\",\n+                            \"action_id\": \"hermes_deny\",\n+                            \"value\": session_key,\n+                        },\n+                    ],\n+                },\n+            ]\n+\n+            kwargs: Dict[str, Any] = {\n+                \"channel\": chat_id,\n+                \"text\": f\"⚠️ Command approval required: {cmd_preview[:100]}\",\n+                \"blocks\": blocks,\n+            }\n+            if thread_ts:\n+                kwargs[\"thread_ts\"] = thread_ts\n+\n+            result = await self._get_client(chat_id).chat_postMessage(**kwargs)\n+            msg_ts = result.get(\"ts\", \"\")\n+            if msg_ts:\n+                self._approval_resolved[msg_ts] = False\n+\n+            return SendResult(success=True, message_id=msg_ts, raw_response=result)\n+        except Exception as e:\n+            logger.error(\"[Slack] send_exec_approval failed: %s\", e, exc_info=True)\n+            return SendResult(success=False, error=str(e))\n+\n+    async def _handle_approval_action(self, ack, body, action) -> None:\n+        \"\"\"Handle an approval button click from Block Kit.\"\"\"\n+        await ack()\n+\n+        action_id = action.get(\"action_id\", \"\")\n+        session_key = action.get(\"value\", \"\")\n+        message = body.get(\"message\", {})\n+        msg_ts = message.get(\"ts\", \"\")\n+        channel_id = body.get(\"channel\", {}).get(\"id\", \"\")\n+        user_name = body.get(\"user\", {}).get(\"name\", \"unknown\")\n+\n+        # Map action_id to approval choice\n+        choice_map = {\n+            \"hermes_approve_once\": \"once\",\n+            \"hermes_approve_session\": \"session\",\n+            \"hermes_approve_always\": \"always\",\n+            \"hermes_deny\": \"deny\",\n+        }\n+        choice = choice_map.get(action_id, \"deny\")\n+\n+        # Prevent double-clicks\n+        if self._approval_resolved.get(msg_ts, False):\n+            return\n+        self._approval_resolved[msg_ts] = True\n+\n+        # Update the message to show the decision and remove buttons\n+        label_map = {\n+            \"once\": f\"✅ Approved once by {user_name}\",\n+            \"session\": f\"✅ Approved for session by {user_name}\",\n+            \"always\": f\"✅ Approved permanently by {user_name}\",\n+            \"deny\": f\"❌ Denied by {user_name}\",\n+        }\n+        decision_text = label_map.get(choice, f\"Resolved by {user_name}\")\n+\n+        # Get original text from the section block\n+        original_text = \"\"\n+        for block in message.get(\"blocks\", []):\n+            if block.get(\"type\") == \"section\":\n+                original_text = block.get(\"text\", {}).get(\"text\", \"\")\n+                break\n+\n+        updated_blocks = [\n+            {\n+                \"type\": \"section\",\n+                \"text\": {\n+                    \"type\": \"mrkdwn\",\n+                    \"text\": original_text or \"Command approval request\",\n+                },\n+            },\n+            {\n+                \"type\": \"context\",\n+                \"elements\": [\n+                    {\"type\": \"mrkdwn\", \"text\": decision_text},\n+                ],\n+            },\n+        ]\n+\n+        try:\n+            await self._get_client(channel_id).chat_update(\n+                channel=channel_id,\n+                ts=msg_ts,\n+                text=decision_text,\n+                blocks=updated_blocks,\n+            )\n+        except Exception as e:\n+            logger.warning(\"[Slack] Failed to update approval message: %s\", e)\n+\n+        # Resolve the approval — this unblocks the agent thread\n+        try:\n+            from tools.approval import resolve_gateway_approval\n+            count = resolve_gateway_approval(session_key, choice)\n+            logger.info(\n+                \"Slack button resolved %d approval(s) for session %s (choice=%s, user=%s)\",\n+                count, session_key, choice, user_name,\n+            )\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+\n+    # ----- Thread context fetching -----\n+\n+    async def _fetch_thread_context(\n+        self, channel_id: str, thread_ts: str, current_ts: str,\n+        team_id: str = \"\", limit: int = 30,\n+    ) -> str:\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+        \"\"\"\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+            messages = result.get(\"messages\", [])\n+            if not messages:\n+                return \"\"\n+\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+                if msg_ts == current_ts:\n+                    continue\n+                # Skip bot messages from ourselves\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+                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+\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+            )\n+        except Exception as e:\n+            logger.warning(\"[Slack] Failed to fetch thread context: %s\", e)\n+            return \"\"\n+\n     async def _handle_slash_command(self, command: dict) -> None:\n         \"\"\"Handle /hermes slash command.\"\"\"\n         text = command.get(\"text\", \"\").strip()","@@ -960,50 +1217,44 @@ def _has_active_session_for_thread(\n         user_id: str,\n     ) -> bool:\n         \"\"\"Check if there's an active session for a thread.\n-        \n+\n         Used to determine if thread replies without @mentions should be\n         processed (they should if there's an active session).\n-        \n-        Args:\n-            channel_id: The Slack channel ID\n-            thread_ts: The thread timestamp (parent message ts)\n-            user_id: The user ID of the sender\n-            \n-        Returns:\n-            True if there's an active session for this thread\n+\n+        Uses ``build_session_key()`` as the single source of truth for key\n+        construction — avoids the bug where manual key building didn't\n+        respect ``thread_sessions_per_user`` and ``group_sessions_per_user``\n+        settings correctly.\n         \"\"\"\n         session_store = getattr(self, \"_session_store\", None)\n         if not session_store:\n             return False\n-        \n+\n         try:\n-            # Build a SessionSource for this thread\n-            from gateway.session import SessionSource\n+            from gateway.session import SessionSource, build_session_key\n \n-            # Generate the session key using the same logic as SessionStore\n-            # This mirrors the logic in build_session_key for group sessions\n-            key_parts = [\"agent:main\", \"slack\", \"group\", channel_id, thread_ts]\n-            \n-            # Include user_id if group_sessions_per_user is enabled\n-            # We check the session store config if available\n-            group_sessions_per_user = getattr(\n-                session_store, \"config\", {}\n+            source = SessionSource(\n+                platform=Platform.SLACK,\n+                chat_id=channel_id,\n+                chat_type=\"group\",\n+                user_id=user_id,\n+                thread_id=thread_ts,\n             )\n-            if hasattr(group_sessions_per_user, \"group_sessions_per_user\"):\n-                group_sessions_per_user = group_sessions_per_user.group_sessions_per_user\n-            else:\n-                group_sessions_per_user = True  # Default\n-            \n-            if group_sessions_per_user and user_id:\n-                key_parts.append(str(user_id))\n-            \n-            session_key = \":\".join(key_parts)\n-            \n-            # Check if the session exists in the store\n+\n+            # Read session isolation settings from the store's config\n+            store_cfg = getattr(session_store, \"config\", None)\n+            gspu = getattr(store_cfg, \"group_sessions_per_user\", True) if store_cfg else True\n+            tspu = getattr(store_cfg, \"thread_sessions_per_user\", False) if store_cfg else False\n+\n+            session_key = build_session_key(\n+                source,\n+                group_sessions_per_user=gspu,\n+                thread_sessions_per_user=tspu,\n+            )\n+\n             session_store._ensure_loaded()\n             return session_key in session_store._entries\n         except Exception:\n-            # If anything goes wrong, default to False (require mention)\n             return False\n \n     async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = \"\") -> str:"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -153,6 +153,8 @@ def __init__(self, config: PlatformConfig):\n         self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get(\"dm_topics\", [])\n         # Interactive model picker state per chat\n         self._model_picker_state: Dict[str, dict] = {}\n+        # Approval button state: message_id → session_key\n+        self._approval_state: Dict[int, str] = {}\n \n     def _fallback_ips(self) -> list[str]:\n         \"\"\"Return validated fallback IPs from config (populated by _apply_env_overrides).\"\"\"","@@ -1010,6 +1012,70 @@ async def send_update_prompt(\n             logger.warning(\"[%s] send_update_prompt failed: %s\", self.name, e)\n             return SendResult(success=False, error=str(e))\n \n+    async def send_exec_approval(\n+        self, chat_id: str, command: str, session_key: str,\n+        description: str = \"dangerous command\",\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Send an inline-keyboard approval prompt with interactive buttons.\n+\n+        The buttons call ``resolve_gateway_approval()`` to unblock the waiting\n+        agent thread — same mechanism as the text ``/approve`` flow.\n+        \"\"\"\n+        if not self._bot:\n+            return SendResult(success=False, error=\"Not connected\")\n+\n+        try:\n+            cmd_preview = command[:3800] + \"...\" if len(command) > 3800 else command\n+            text = (\n+                f\"⚠️ *Command Approval Required*\\n\\n\"\n+                f\"`{cmd_preview}`\\n\\n\"\n+                f\"Reason: {description}\"\n+            )\n+\n+            # Resolve thread context for thread replies\n+            thread_id = None\n+            if metadata:\n+                thread_id = metadata.get(\"thread_id\") or metadata.get(\"message_thread_id\")\n+\n+            # We'll use the message_id as part of callback_data to look up session_key\n+            # Send a placeholder first, then update — or use a counter.\n+            # Simpler: use a monotonic counter to generate short IDs.\n+            import itertools\n+            if not hasattr(self, \"_approval_counter\"):\n+                self._approval_counter = itertools.count(1)\n+            approval_id = next(self._approval_counter)\n+\n+            keyboard = InlineKeyboardMarkup([\n+                [\n+                    InlineKeyboardButton(\"✅ Allow Once\", callback_data=f\"ea:once:{approval_id}\"),\n+                    InlineKeyboardButton(\"✅ Session\", callback_data=f\"ea:session:{approval_id}\"),\n+                ],\n+                [\n+                    InlineKeyboardButton(\"✅ Always\", callback_data=f\"ea:always:{approval_id}\"),\n+                    InlineKeyboardButton(\"❌ Deny\", callback_data=f\"ea:deny:{approval_id}\"),\n+                ],\n+            ])\n+\n+            kwargs: Dict[str, Any] = {\n+                \"chat_id\": int(chat_id),\n+                \"text\": text,\n+                \"parse_mode\": ParseMode.MARKDOWN,\n+                \"reply_markup\": keyboard,\n+            }\n+            if thread_id:\n+                kwargs[\"message_thread_id\"] = int(thread_id)\n+\n+            msg = await self._bot.send_message(**kwargs)\n+\n+            # Store session_key keyed by approval_id for the callback handler\n+            self._approval_state[approval_id] = session_key\n+\n+            return SendResult(success=True, message_id=str(msg.message_id))\n+        except Exception as e:\n+            logger.warning(\"[%s] send_exec_approval failed: %s\", self.name, e)\n+            return SendResult(success=False, error=str(e))\n+\n     async def send_model_picker(\n         self,\n         chat_id: str,","@@ -1321,6 +1387,56 @@ async def _handle_callback_query(\n                 await self._handle_model_picker_callback(query, data, chat_id)\n             return\n \n+        # --- Exec approval callbacks (ea:choice:id) ---\n+        if data.startswith(\"ea:\"):\n+            parts = data.split(\":\", 2)\n+            if len(parts) == 3:\n+                choice = parts[1]  # once, session, always, deny\n+                try:\n+                    approval_id = int(parts[2])\n+                except (ValueError, IndexError):\n+                    await query.answer(text=\"Invalid approval data.\")\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.\")\n+                    return\n+\n+                # Map choice to human-readable label\n+                label_map = {\n+                    \"once\": \"✅ Approved once\",\n+                    \"session\": \"✅ Approved for session\",\n+                    \"always\": \"✅ Approved permanently\",\n+                    \"deny\": \"❌ Denied\",\n+                }\n+                user_display = getattr(query.from_user, \"first_name\", \"User\")\n+                label = label_map.get(choice, \"Resolved\")\n+\n+                await query.answer(text=label)\n+\n+                # Edit message to show decision, remove buttons\n+                try:\n+                    await query.edit_message_text(\n+                        text=f\"{label} by {user_display}\",\n+                        parse_mode=ParseMode.MARKDOWN,\n+                        reply_markup=None,\n+                    )\n+                except Exception:\n+                    pass  # non-fatal if edit fails\n+\n+                # Resolve the approval — unblocks the agent thread\n+                try:\n+                    from tools.approval import resolve_gateway_approval\n+                    count = resolve_gateway_approval(session_key, choice)\n+                    logger.info(\n+                        \"Telegram button resolved %d approval(s) for session %s (choice=%s, user=%s)\",\n+                        count, session_key, choice, user_display,\n+                    )\n+                except Exception as exc:\n+                    logger.error(\"Failed to resolve gateway approval from Telegram button: %s\", exc)\n+            return\n+\n         # --- Update prompt callbacks ---\n         if not data.startswith(\"update_prompt:\"):\n             return"]},{"file":"tests/gateway/test_slack_approval_buttons.py","hunks":["@@ -0,0 +1,373 @@\n+\"\"\"Tests for Slack Block Kit approval buttons and thread context fetching.\"\"\"\n+\n+import asyncio\n+import os\n+import sys\n+from pathlib import Path\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+# ---------------------------------------------------------------------------\n+# Ensure the repo root is importable\n+# ---------------------------------------------------------------------------\n+_repo = str(Path(__file__).resolve().parents[2])\n+if _repo not in sys.path:\n+    sys.path.insert(0, _repo)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Minimal Slack SDK mock so SlackAdapter can be imported\n+# ---------------------------------------------------------------------------\n+def _ensure_slack_mock():\n+    \"\"\"Wire up the minimal mocks required to import SlackAdapter.\"\"\"\n+    if \"slack_bolt\" in sys.modules:\n+        return\n+    slack_bolt = MagicMock()\n+    slack_bolt.async_app.AsyncApp = MagicMock\n+    sys.modules[\"slack_bolt\"] = slack_bolt\n+    sys.modules[\"slack_bolt.async_app\"] = slack_bolt.async_app\n+    handler_mod = MagicMock()\n+    handler_mod.AsyncSocketModeHandler = MagicMock\n+    sys.modules[\"slack_bolt.adapter\"] = MagicMock()\n+    sys.modules[\"slack_bolt.adapter.socket_mode\"] = MagicMock()\n+    sys.modules[\"slack_bolt.adapter.socket_mode.async_handler\"] = handler_mod\n+    sdk_mod = MagicMock()\n+    sdk_mod.web = MagicMock()\n+    sdk_mod.web.async_client = MagicMock()\n+    sdk_mod.web.async_client.AsyncWebClient = MagicMock\n+    sys.modules[\"slack_sdk\"] = sdk_mod\n+    sys.modules[\"slack_sdk.web\"] = sdk_mod.web\n+    sys.modules[\"slack_sdk.web.async_client\"] = sdk_mod.web.async_client\n+\n+\n+_ensure_slack_mock()\n+\n+from gateway.platforms.slack import SlackAdapter\n+from gateway.config import Platform, PlatformConfig\n+\n+\n+def _make_adapter():\n+    \"\"\"Create a SlackAdapter instance with mocked internals.\"\"\"\n+    config = PlatformConfig(enabled=True, token=\"[redacted:slack-token]\")\n+    adapter = SlackAdapter(config)\n+    adapter._app = MagicMock()\n+    adapter._bot_user_id = \"U_BOT\"\n+    adapter._team_clients = {\"T1\": AsyncMock()}\n+    adapter._team_bot_user_ids = {\"T1\": \"U_BOT\"}\n+    adapter._channel_team = {\"C1\": \"T1\"}\n+    return adapter\n+\n+\n+# ===========================================================================\n+# send_exec_approval — Block Kit buttons\n+# ===========================================================================\n+\n+class TestSlackExecApproval:\n+    \"\"\"Test the send_exec_approval method sends Block Kit buttons.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_sends_blocks_with_buttons(self):\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.chat_postMessage = AsyncMock(return_value={\"ts\": \"1234.5678\"})\n+\n+        result = await adapter.send_exec_approval(\n+            chat_id=\"C1\",\n+            command=\"rm -rf /important\",\n+            session_key=\"agent:main:slack:group:C1:1111\",\n+            description=\"dangerous deletion\",\n+        )\n+\n+        assert result.success is True\n+        assert result.message_id == \"1234.5678\"\n+\n+        # Verify chat_postMessage was called with blocks\n+        mock_client.chat_postMessage.assert_called_once()\n+        kwargs = mock_client.chat_postMessage.call_args[1]\n+        assert \"blocks\" in kwargs\n+        blocks = kwargs[\"blocks\"]\n+        assert len(blocks) == 2\n+        assert blocks[0][\"type\"] == \"section\"\n+        assert \"rm -rf /important\" in blocks[0][\"text\"][\"text\"]\n+        assert \"dangerous deletion\" in blocks[0][\"text\"][\"text\"]\n+        assert blocks[1][\"type\"] == \"actions\"\n+        elements = blocks[1][\"elements\"]\n+        assert len(elements) == 4\n+        action_ids = [e[\"action_id\"] for e in elements]\n+        assert \"hermes_approve_once\" in action_ids\n+        assert \"hermes_approve_session\" in action_ids\n+        assert \"hermes_approve_always\" in action_ids\n+        assert \"hermes_deny\" in action_ids\n+        # Each button carries the session key as value\n+        for e in elements:\n+            assert e[\"value\"] == \"agent:main:slack:group:C1:1111\"\n+\n+    @pytest.mark.asyncio\n+    async def test_sends_in_thread(self):\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.chat_postMessage = AsyncMock(return_value={\"ts\": \"1234.5678\"})\n+\n+        await adapter.send_exec_approval(\n+            chat_id=\"C1\",\n+            command=\"echo test\",\n+            session_key=\"test-session\",\n+            metadata={\"thread_id\": \"9999.0000\"},\n+        )\n+\n+        kwargs = mock_client.chat_postMessage.call_args[1]\n+        assert kwargs.get(\"thread_ts\") == \"9999.0000\"\n+\n+    @pytest.mark.asyncio\n+    async def test_not_connected(self):\n+        adapter = _make_adapter()\n+        adapter._app = None\n+        result = await adapter.send_exec_approval(\n+            chat_id=\"C1\", command=\"ls\", session_key=\"s\"\n+        )\n+        assert result.success is False\n+\n+    @pytest.mark.asyncio\n+    async def test_truncates_long_command(self):\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.chat_postMessage = AsyncMock(return_value={\"ts\": \"1.2\"})\n+\n+        long_cmd = \"x\" * 5000\n+        await adapter.send_exec_approval(\n+            chat_id=\"C1\", command=long_cmd, session_key=\"s\"\n+        )\n+\n+        kwargs = mock_client.chat_postMessage.call_args[1]\n+        section_text = kwargs[\"blocks\"][0][\"text\"][\"text\"]\n+        assert \"...\" in section_text\n+        assert len(section_text) < 5000\n+\n+\n+# ===========================================================================\n+# _handle_approval_action — button click handler\n+# ===========================================================================\n+\n+class TestSlackApprovalAction:\n+    \"\"\"Test the approval button click handler.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_resolves_approval(self):\n+        adapter = _make_adapter()\n+        adapter._approval_resolved[\"1234.5678\"] = False\n+\n+        ack = AsyncMock()\n+        body = {\n+            \"message\": {\n+                \"ts\": \"1234.5678\",\n+                \"blocks\": [\n+                    {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"original text\"}},\n+                    {\"type\": \"actions\", \"elements\": []},\n+                ],\n+            },\n+            \"channel\": {\"id\": \"C1\"},\n+            \"user\": {\"name\": \"norbert\"},\n+        }\n+        action = {\n+            \"action_id\": \"hermes_approve_once\",\n+            \"value\": \"agent:main:slack:group:C1:1111\",\n+        }\n+\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.chat_update = AsyncMock()\n+\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._handle_approval_action(ack, body, action)\n+\n+        ack.assert_called_once()\n+        mock_resolve.assert_called_once_with(\"agent:main:slack:group:C1:1111\", \"once\")\n+\n+        # Message should be updated with decision\n+        mock_client.chat_update.assert_called_once()\n+        update_kwargs = mock_client.chat_update.call_args[1]\n+        assert \"Approved once by norbert\" in update_kwargs[\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_prevents_double_click(self):\n+        adapter = _make_adapter()\n+        adapter._approval_resolved[\"1234.5678\"] = True  # Already resolved\n+\n+        ack = AsyncMock()\n+        body = {\n+            \"message\": {\"ts\": \"1234.5678\", \"blocks\": []},\n+            \"channel\": {\"id\": \"C1\"},\n+            \"user\": {\"name\": \"norbert\"},\n+        }\n+        action = {\n+            \"action_id\": \"hermes_approve_once\",\n+            \"value\": \"some-session\",\n+        }\n+\n+        with patch(\"tools.approval.resolve_gateway_approval\") as mock_resolve:\n+            await adapter._handle_approval_action(ack, body, action)\n+\n+        # Should have acked but NOT resolved\n+        ack.assert_called_once()\n+        mock_resolve.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_deny_action(self):\n+        adapter = _make_adapter()\n+        adapter._approval_resolved[\"1.2\"] = False\n+\n+        ack = AsyncMock()\n+        body = {\n+            \"message\": {\"ts\": \"1.2\", \"blocks\": [\n+                {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"cmd\"}},\n+            ]},\n+            \"channel\": {\"id\": \"C1\"},\n+            \"user\": {\"name\": \"alice\"},\n+        }\n+        action = {\"action_id\": \"hermes_deny\", \"value\": \"session-key\"}\n+\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.chat_update = AsyncMock()\n+\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._handle_approval_action(ack, body, action)\n+\n+        mock_resolve.assert_called_once_with(\"session-key\", \"deny\")\n+        update_kwargs = mock_client.chat_update.call_args[1]\n+        assert \"Denied by alice\" in update_kwargs[\"text\"]\n+\n+\n+# ===========================================================================\n+# _fetch_thread_context\n+# ===========================================================================\n+\n+class TestSlackThreadContext:\n+    \"\"\"Test thread context fetching.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_fetches_and_formats_context(self):\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.conversations_replies = AsyncMock(return_value={\n+            \"messages\": [\n+                {\"ts\": \"1000.0\", \"user\": \"U1\", \"text\": \"This is the parent message\"},\n+                {\"ts\": \"1000.1\", \"user\": \"U2\", \"text\": \"I think we should refactor\"},\n+                {\"ts\": \"1000.2\", \"user\": \"U1\", \"text\": \"Good idea, <@U_BOT> what do you think?\"},\n+            ]\n+        })\n+\n+        # Mock user name resolution\n+        adapter._user_name_cache = {\"U1\": \"Alice\", \"U2\": \"Bob\"}\n+\n+        context = await adapter._fetch_thread_context(\n+            channel_id=\"C1\",\n+            thread_ts=\"1000.0\",\n+            current_ts=\"1000.2\",  # The message that triggered the fetch\n+            team_id=\"T1\",\n+        )\n+\n+        assert \"[Thread context\" in context\n+        assert \"[thread parent] Alice: This is the parent message\" in context\n+        assert \"Bob: I think we should refactor\" in context\n+        # Current message should be excluded\n+        assert \"what do you think\" not in context\n+        # Bot mention should be stripped from context\n+        assert \"<@U_BOT>\" not in context\n+\n+    @pytest.mark.asyncio\n+    async def test_skips_bot_messages(self):\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.conversations_replies = AsyncMock(return_value={\n+            \"messages\": [\n+                {\"ts\": \"1000.0\", \"user\": \"U1\", \"text\": \"Parent\"},\n+                {\"ts\": \"1000.1\", \"bot_id\": \"B1\", \"text\": \"Bot reply (should be skipped)\"},\n+                {\"ts\": \"1000.2\", \"user\": \"U1\", \"text\": \"Current\"},\n+            ]\n+        })\n+        adapter._user_name_cache = {\"U1\": \"Alice\"}\n+\n+        context = await adapter._fetch_thread_context(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", current_ts=\"1000.2\", team_id=\"T1\"\n+        )\n+\n+        assert \"Bot reply\" not in context\n+        assert \"Alice: Parent\" in context\n+\n+    @pytest.mark.asyncio\n+    async def test_empty_thread(self):\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.conversations_replies = AsyncMock(return_value={\"messages\": []})\n+\n+        context = await adapter._fetch_thread_context(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", current_ts=\"1000.1\", team_id=\"T1\"\n+        )\n+        assert context == \"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_api_failure_returns_empty(self):\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.conversations_replies = AsyncMock(side_effect=Exception(\"API error\"))\n+\n+        context = await adapter._fetch_thread_context(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", current_ts=\"1000.1\", team_id=\"T1\"\n+        )\n+        assert context == \"\"\n+\n+\n+# ===========================================================================\n+# _has_active_session_for_thread — session key fix (#5833)\n+# ===========================================================================\n+\n+class TestSessionKeyFix:\n+    \"\"\"Test that _has_active_session_for_thread uses build_session_key.\"\"\"\n+\n+    def test_uses_build_session_key(self):\n+        \"\"\"Verify the fix uses build_session_key instead of manual key construction.\"\"\"\n+        adapter = _make_adapter()\n+\n+        # Mock session store with a known entry\n+        mock_store = MagicMock()\n+        mock_store._entries = {\n+            \"agent:main:slack:group:C1:1000.0\": MagicMock()\n+        }\n+        mock_store._ensure_loaded = MagicMock()\n+        mock_store.config = MagicMock()\n+        mock_store.config.group_sessions_per_user = False  # threads don't include user_id\n+        mock_store.config.thread_sessions_per_user = False\n+        adapter._session_store = mock_store\n+\n+        # With the fix, build_session_key should be called which respects\n+        # group_sessions_per_user=False (no user_id appended)\n+        result = adapter._has_active_session_for_thread(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", user_id=\"U123\"\n+        )\n+\n+        # Should find the session because build_session_key with\n+        # group_sessions_per_user=False doesn't append user_id\n+        assert result is True\n+\n+    def test_no_session_returns_false(self):\n+        adapter = _make_adapter()\n+        mock_store = MagicMock()\n+        mock_store._entries = {}\n+        mock_store._ensure_loaded = MagicMock()\n+        mock_store.config = MagicMock()\n+        mock_store.config.group_sessions_per_user = True\n+        mock_store.config.thread_sessions_per_user = False\n+        adapter._session_store = mock_store\n+\n+        result = adapter._has_active_session_for_thread(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", user_id=\"U123\"\n+        )\n+        assert result is False\n+\n+    def test_no_session_store(self):\n+        adapter = _make_adapter()\n+        # No _session_store attribute\n+        result = adapter._has_active_session_for_thread(\n+            channel_id=\"C1\", thread_ts=\"1000.0\", user_id=\"U123\"\n+        )\n+        assert result is False"]},{"file":"tests/gateway/test_telegram_approval_buttons.py","hunks":["@@ -0,0 +1,284 @@\n+\"\"\"Tests for Telegram inline keyboard approval buttons.\"\"\"\n+\n+import asyncio\n+import os\n+import sys\n+from pathlib import Path\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+# ---------------------------------------------------------------------------\n+# Ensure the repo root is importable\n+# ---------------------------------------------------------------------------\n+_repo = str(Path(__file__).resolve().parents[2])\n+if _repo not in sys.path:\n+    sys.path.insert(0, _repo)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Minimal Telegram mock so TelegramAdapter can be imported\n+# ---------------------------------------------------------------------------\n+def _ensure_telegram_mock():\n+    \"\"\"Wire up the minimal mocks required to import TelegramAdapter.\"\"\"\n+    if \"telegram\" in sys.modules and hasattr(sys.modules[\"telegram\"], \"__file__\"):\n+        return\n+\n+    mod = MagicMock()\n+    mod.ext.ContextTypes.DEFAULT_TYPE = type(None)\n+    mod.constants.ParseMode.MARKDOWN = \"Markdown\"\n+    mod.constants.ParseMode.MARKDOWN_V2 = \"MarkdownV2\"\n+    mod.constants.ParseMode.HTML = \"HTML\"\n+    mod.constants.ChatType.PRIVATE = \"private\"\n+    mod.constants.ChatType.GROUP = \"group\"\n+    mod.constants.ChatType.SUPERGROUP = \"supergroup\"\n+    mod.constants.ChatType.CHANNEL = \"channel\"\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\", \"telegram.error\"):\n+        sys.modules.setdefault(name, mod)\n+\n+\n+_ensure_telegram_mock()\n+\n+from gateway.platforms.telegram import TelegramAdapter\n+from gateway.config import Platform, PlatformConfig\n+\n+\n+def _make_adapter():\n+    \"\"\"Create a TelegramAdapter with mocked internals.\"\"\"\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    adapter = TelegramAdapter(config)\n+    adapter._bot = AsyncMock()\n+    adapter._app = MagicMock()\n+    return adapter\n+\n+\n+# ===========================================================================\n+# send_exec_approval — inline keyboard buttons\n+# ===========================================================================\n+\n+class TestTelegramExecApproval:\n+    \"\"\"Test the send_exec_approval method sends InlineKeyboard buttons.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_sends_inline_keyboard(self):\n+        adapter = _make_adapter()\n+        mock_msg = MagicMock()\n+        mock_msg.message_id = 42\n+        adapter._bot.send_message = AsyncMock(return_value=mock_msg)\n+\n+        result = await adapter.send_exec_approval(\n+            chat_id=\"12345\",\n+            command=\"rm -rf /important\",\n+            session_key=\"agent:main:telegram:group:12345:99\",\n+            description=\"dangerous deletion\",\n+        )\n+\n+        assert result.success is True\n+        assert result.message_id == \"42\"\n+\n+        adapter._bot.send_message.assert_called_once()\n+        kwargs = adapter._bot.send_message.call_args[1]\n+        assert kwargs[\"chat_id\"] == 12345\n+        assert \"rm -rf /important\" in kwargs[\"text\"]\n+        assert \"dangerous deletion\" in kwargs[\"text\"]\n+        assert kwargs[\"reply_markup\"] is not None  # InlineKeyboardMarkup\n+\n+    @pytest.mark.asyncio\n+    async def test_stores_approval_state(self):\n+        adapter = _make_adapter()\n+        mock_msg = MagicMock()\n+        mock_msg.message_id = 42\n+        adapter._bot.send_message = AsyncMock(return_value=mock_msg)\n+\n+        await adapter.send_exec_approval(\n+            chat_id=\"12345\",\n+            command=\"echo test\",\n+            session_key=\"my-session-key\",\n+        )\n+\n+        # The approval_id should map to the session_key\n+        assert len(adapter._approval_state) == 1\n+        approval_id = list(adapter._approval_state.keys())[0]\n+        assert adapter._approval_state[approval_id] == \"my-session-key\"\n+\n+    @pytest.mark.asyncio\n+    async def test_sends_in_thread(self):\n+        adapter = _make_adapter()\n+        mock_msg = MagicMock()\n+        mock_msg.message_id = 42\n+        adapter._bot.send_message = AsyncMock(return_value=mock_msg)\n+\n+        await adapter.send_exec_approval(\n+            chat_id=\"12345\",\n+            command=\"ls\",\n+            session_key=\"s\",\n+            metadata={\"thread_id\": \"999\"},\n+        )\n+\n+        kwargs = adapter._bot.send_message.call_args[1]\n+        assert kwargs.get(\"message_thread_id\") == 999\n+\n+    @pytest.mark.asyncio\n+    async def test_not_connected(self):\n+        adapter = _make_adapter()\n+        adapter._bot = None\n+        result = await adapter.send_exec_approval(\n+            chat_id=\"12345\", command=\"ls\", session_key=\"s\"\n+        )\n+        assert result.success is False\n+\n+    @pytest.mark.asyncio\n+    async def test_truncates_long_command(self):\n+        adapter = _make_adapter()\n+        mock_msg = MagicMock()\n+        mock_msg.message_id = 1\n+        adapter._bot.send_message = AsyncMock(return_value=mock_msg)\n+\n+        long_cmd = \"x\" * 5000\n+        await adapter.send_exec_approval(\n+            chat_id=\"12345\", command=long_cmd, session_key=\"s\"\n+        )\n+\n+        kwargs = adapter._bot.send_message.call_args[1]\n+        assert \"...\" in kwargs[\"text\"]\n+        assert len(kwargs[\"text\"]) < 5000\n+\n+\n+# ===========================================================================\n+# _handle_callback_query — approval button clicks\n+# ===========================================================================\n+\n+class TestTelegramApprovalCallback:\n+    \"\"\"Test the approval callback handling in _handle_callback_query.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_resolves_approval_on_click(self):\n+        adapter = _make_adapter()\n+        # Set up approval state\n+        adapter._approval_state[1] = \"agent:main:telegram:group:12345:99\"\n+\n+        # Mock callback query\n+        query = AsyncMock()\n+        query.data = \"ea:once:1\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.from_user = MagicMock()\n+        query.from_user.first_name = \"Norbert\"\n+        query.answer = AsyncMock()\n+        query.edit_message_text = AsyncMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._handle_callback_query(update, context)\n+\n+        mock_resolve.assert_called_once_with(\"agent:main:telegram:group:12345:99\", \"once\")\n+        query.answer.assert_called_once()\n+        query.edit_message_text.assert_called_once()\n+\n+        # State should be cleaned up\n+        assert 1 not in adapter._approval_state\n+\n+    @pytest.mark.asyncio\n+    async def test_deny_button(self):\n+        adapter = _make_adapter()\n+        adapter._approval_state[2] = \"some-session\"\n+\n+        query = AsyncMock()\n+        query.data = \"ea:deny:2\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.from_user = MagicMock()\n+        query.from_user.first_name = \"Alice\"\n+        query.answer = AsyncMock()\n+        query.edit_message_text = AsyncMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._handle_callback_query(update, context)\n+\n+        mock_resolve.assert_called_once_with(\"some-session\", \"deny\")\n+        edit_kwargs = query.edit_message_text.call_args[1]\n+        assert \"Denied\" in edit_kwargs[\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_already_resolved(self):\n+        adapter = _make_adapter()\n+        # No state for approval_id 99 — already resolved\n+\n+        query = AsyncMock()\n+        query.data = \"ea:once:99\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.from_user = MagicMock()\n+        query.from_user.first_name = \"Bob\"\n+        query.answer = AsyncMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        with patch(\"tools.approval.resolve_gateway_approval\") as mock_resolve:\n+            await adapter._handle_callback_query(update, context)\n+\n+        # Should NOT resolve — already handled\n+        mock_resolve.assert_not_called()\n+        # Should still ack with \"already resolved\" message\n+        query.answer.assert_called_once()\n+        assert \"already been resolved\" in query.answer.call_args[1][\"text\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_model_picker_callback_not_affected(self):\n+        \"\"\"Ensure model picker callbacks still route correctly.\"\"\"\n+        adapter = _make_adapter()\n+\n+        query = AsyncMock()\n+        query.data = \"mp:some_provider\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.from_user = MagicMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        # Model picker callback should be handled (not crash)\n+        # We just verify it doesn't try to resolve an approval\n+        with patch(\"tools.approval.resolve_gateway_approval\") as mock_resolve:\n+            with patch.object(adapter, \"_handle_model_picker_callback\", new_callable=AsyncMock):\n+                await adapter._handle_callback_query(update, context)\n+\n+        mock_resolve.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_update_prompt_callback_not_affected(self):\n+        \"\"\"Ensure update prompt callbacks still work.\"\"\"\n+        adapter = _make_adapter()\n+\n+        query = AsyncMock()\n+        query.data = \"update_prompt:y\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.from_user = MagicMock()\n+        query.from_user.id = 123\n+        query.answer = AsyncMock()\n+        query.edit_message_text = AsyncMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        with patch(\"tools.approval.resolve_gateway_approval\") as mock_resolve:\n+            with patch(\"hermes_constants.get_hermes_home\", return_value=Path(\"/tmp/test\")):\n+                try:\n+                    await adapter._handle_callback_query(update, context)\n+                except Exception:\n+                    pass  # May fail on file write, that's fine\n+\n+        # Should NOT have triggered approval resolution\n+        mock_resolve.assert_not_called()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}