{"repo":"NousResearch/hermes-agent","public_id":"12573","display_label":"Clarify tool wiring for gateway/Telegram/Feishu","summary":"These issues and pull requests concern adding and fixing the wiring of a clarify tool—including callback handling and interactive buttons—across gateway sessions, Telegram, and Feishu messaging platforms. Several members address a bug where the clarify tool was exposed but its callback was never connected in gateway/Telegram mode.","subsystem":"gateway","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":5,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":24199,"merged_at":"2026-05-12T23:33:34+00:00"},"resolution":{"state":"partially_resolved","by":"pr-24199"},"freshness":{"state":"verified","last_verified_at":"2026-05-12T23:33:34+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.26399999999999996},"fix_edges":[{"issue_number":12573,"pr_number":24199,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-12T23:33:34+00:00"},{"issue_number":21032,"pr_number":24199,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-12T23:33:34+00:00"},{"issue_number":24191,"pr_number":24199,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-12T23:33:34+00:00"}],"members":[{"kind":"issue","number":12573,"title":"Bug: gateway sessions expose clarify tool but never wire clarify_callback","url":null,"state":"closed"},{"kind":"issue","number":18301,"title":"feat: wire up clarify tool for Telegram via inline keyboard buttons","url":null,"state":"closed"},{"kind":"issue","number":21028,"title":"[BUG] Clarify tool returns 'not available in this execution context' in gateway/Telegram mode","url":null,"state":"closed"},{"kind":"issue","number":21032,"title":"Clarify tool breaks in gateway/Telegram mode — missing callback wiring","url":null,"state":"closed"},{"kind":"issue","number":24191,"title":"feat(gateway): wire clarify tool end-to-end with inline keyboard buttons on Telegram","url":null,"state":"closed"},{"kind":"pr","number":12576,"title":"fix(gateway): wire clarify callback for messaging sessions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"29d7c244c5d55230e838c049afb13d307168679c","number":0},{"kind":"commit","value":"d7f655f370e2bea177dfedf19f3b261d13c986e2","number":0},{"kind":"pr","value":"#24199","number":24199},{"kind":"pr","value":"#12573","number":12573}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21517,"title":"fix(gateway,telegram): wire clarify_callback through gateway agent and Telegram adapter","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":23740,"title":"feat(gateway): bridge clarify tool to messaging platforms","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#29719","number":29719},{"kind":"pr","value":"#24199","number":24199}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":24199,"title":"feat(gateway): wire clarify tool with inline keyboard buttons on Telegram","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/platforms/telegram.py","gateway/run.py","hermes_cli/config.py","tests/gateway/test_telegram_clarify_buttons.py","tests/tools/test_clarify_gateway.py","tools/clarify_gateway.py","website/docs/user-guide/messaging/telegram.md"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1743,6 +1743,55 @@ async def send_slash_confirm(\n         \"\"\"\n         return SendResult(success=False, error=\"Not supported\")\n \n+    async def send_clarify(\n+        self,\n+        chat_id: str,\n+        question: str,\n+        choices: Optional[list],\n+        clarify_id: str,\n+        session_key: str,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Send a clarify prompt to the user.\n+\n+        Two render modes:\n+\n+          * **Multiple choice** (``choices`` is a non-empty list) — adapters\n+            that override this should render inline buttons (one per choice\n+            plus a final \"Other\" / free-text option).  Button callbacks\n+            MUST resolve via\n+            ``tools.clarify_gateway.resolve_gateway_clarify(clarify_id, response)``\n+            with the chosen string.  Picking the \"Other\" button calls\n+            ``mark_awaiting_text(clarify_id)`` so the next message in the\n+            session is captured as the response.\n+\n+          * **Open-ended** (``choices`` is None or empty) — render the\n+            question as a plain text message; the next user message in the\n+            session is captured by the gateway's text-intercept and\n+            resolves the clarify automatically (see\n+            ``GatewayRunner._maybe_intercept_clarify_text``).\n+\n+        The default implementation falls back to a numbered text list,\n+        which works on every platform — the user replies with a number\n+        (\"2\") or with the literal choice text, and the gateway intercepts\n+        and resolves.  Adapters with native button UIs (Telegram, Discord)\n+        SHOULD override this for a richer UX.\n+        \"\"\"\n+        if choices:\n+            lines = [f\"❓ {question}\", \"\"]\n+            for i, choice in enumerate(choices, start=1):\n+                lines.append(f\"  {i}. {choice}\")\n+            lines.append(\"\")\n+            lines.append(\"Reply with the number, the option text, or your own answer.\")\n+            text = \"\\n\".join(lines)\n+        else:\n+            text = f\"❓ {question}\"\n+        return await self.send(\n+            chat_id=chat_id,\n+            content=text,\n+            metadata=metadata,\n+        )\n+\n     async def send_private_notice(\n         self,\n         chat_id: str,","@@ -2831,6 +2880,58 @@ async def handle_message(self, event: MessageEvent) -> None:\n                     logger.error(\"[%s] Command '/%s' dispatch failed: %s\", self.name, cmd, e, exc_info=True)\n                 return\n \n+            # Clarify text-capture bypass: if the agent is blocked on a\n+            # clarify_tool call awaiting a free-form text response (open-\n+            # ended clarify, or user picked \"Other\"), the next non-command\n+            # message in this session MUST reach the runner so the\n+            # clarify-intercept can resolve it and unblock the agent.\n+            #\n+            # Without this bypass: the message gets queued in\n+            # _pending_messages AND triggers an interrupt, killing the\n+            # agent run mid-clarify and discarding the user's answer.\n+            # Same shape as the /approve deadlock fix (PR #4926) — both\n+            # cases are \"agent thread blocked on Event.wait, message must\n+            # reach the resolver before being treated as a new turn.\"\n+            if not cmd:\n+                try:\n+                    from tools import clarify_gateway as _clarify_mod\n+                    _has_text_clarify = (\n+                        _clarify_mod.get_pending_for_session(session_key) is not None\n+                    )\n+                except Exception:\n+                    _has_text_clarify = False\n+\n+                if _has_text_clarify:\n+                    logger.debug(\n+                        \"[%s] Routing message to clarify text-intercept for %s\",\n+                        self.name, session_key,\n+                    )\n+                    try:\n+                        _thread_meta = _thread_metadata_for_source(\n+                            event.source, _reply_anchor_for_event(event)\n+                        )\n+                        response = await self._message_handler(event)\n+                        _text, _eph_ttl = self._unwrap_ephemeral(response)\n+                        if _text:\n+                            _r = await self._send_with_retry(\n+                                chat_id=event.source.chat_id,\n+                                content=_text,\n+                                reply_to=_reply_anchor_for_event(event),\n+                                metadata=_thread_meta,\n+                            )\n+                            if _eph_ttl > 0 and _r.success and _r.message_id:\n+                                self._schedule_ephemeral_delete(\n+                                    chat_id=event.source.chat_id,\n+                                    message_id=_r.message_id,\n+                                    ttl_seconds=_eph_ttl,\n+                                )\n+                    except Exception as e:\n+                        logger.error(\n+                            \"[%s] Clarify text-intercept dispatch failed: %s\",\n+                            self.name, e, exc_info=True,\n+                        )\n+                    return\n+\n             if self._busy_session_handler is not None:\n                 try:\n                     if await self._busy_session_handler(event, session_key):"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -427,6 +427,9 @@ def __init__(self, config: PlatformConfig):\n         # Slash-confirm button state: confirm_id → session_key (for /reload-mcp\n         # and any other slash-confirm prompts; see GatewayRunner._request_slash_confirm).\n         self._slash_confirm_state: Dict[str, str] = {}\n+        # Clarify button state: clarify_id → session_key (for the clarify tool's\n+        # multiple-choice prompts; see GatewayRunner clarify_callback wiring).\n+        self._clarify_state: Dict[str, str] = {}\n         # Notification mode for message sends.\n         # \"important\" — only final responses, approvals, and slash confirmations\n         #               trigger notifications; tool progress, streaming, status","@@ -2215,6 +2218,80 @@ async def send_slash_confirm(\n             logger.warning(\"[%s] send_slash_confirm failed: %s\", self.name, e)\n             return SendResult(success=False, error=str(e))\n \n+    async def send_clarify(\n+        self,\n+        chat_id: str,\n+        question: str,\n+        choices: Optional[list],\n+        clarify_id: str,\n+        session_key: str,\n+        metadata: Optional[Dict[str, Any]] = None,\n+    ) -> SendResult:\n+        \"\"\"Render a clarify prompt with one inline button per choice.\n+\n+        Multi-choice mode (``choices`` non-empty): renders one button per\n+        option plus a final \"✏️ Other (type answer)\" button.  Picking the\n+        \"Other\" button flips the entry into text-capture mode so the next\n+        message becomes the response.\n+\n+        Open-ended mode (``choices`` empty): renders the question as plain\n+        text — no buttons.  The next message in the session is captured by\n+        the gateway's text-intercept and resolves the clarify.\n+        \"\"\"\n+        if not self._bot:\n+            return SendResult(success=False, error=\"Not connected\")\n+\n+        try:\n+            text = f\"❓ {_html.escape(question)}\"\n+            thread_id = self._metadata_thread_id(metadata)\n+\n+            kwargs: Dict[str, Any] = {\n+                \"chat_id\": int(chat_id),\n+                \"text\": text,\n+                \"parse_mode\": ParseMode.HTML,\n+                **self._link_preview_kwargs(),\n+            }\n+\n+            if choices:\n+                # Telegram caps callback_data at 64 bytes; keep \"cl:<id>:<idx>\"\n+                # short.  Button label is also capped (~64 chars in practice).\n+                rows = []\n+                for idx, choice in enumerate(choices):\n+                    label = str(choice)\n+                    if len(label) > 60:\n+                        label = label[:57] + \"...\"\n+                    rows.append([\n+                        InlineKeyboardButton(\n+                            f\"{idx + 1}. {label}\",\n+                            callback_data=f\"cl:{clarify_id}:{idx}\",\n+                        )\n+                    ])\n+                rows.append([\n+                    InlineKeyboardButton(\n+                        \"✏️ Other (type answer)\",\n+                        callback_data=f\"cl:{clarify_id}:other\",\n+                    )\n+                ])\n+                kwargs[\"reply_markup\"] = InlineKeyboardMarkup(rows)\n+\n+            reply_to_id = self._reply_to_message_id_for_send(None, metadata)\n+            kwargs[\"reply_to_message_id\"] = reply_to_id\n+            kwargs.update(\n+                self._thread_kwargs_for_send(\n+                    chat_id,\n+                    thread_id,\n+                    metadata,\n+                    reply_to_message_id=reply_to_id,\n+                )\n+            )\n+\n+            msg = await self._send_message_with_thread_fallback(**kwargs)\n+            self._clarify_state[clarify_id] = session_key\n+            return SendResult(success=True, message_id=str(msg.message_id))\n+        except Exception as e:\n+            logger.warning(\"[%s] send_clarify 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,","@@ -2700,6 +2777,111 @@ async def _handle_callback_query(\n                     logger.error(\"[%s] slash-confirm callback failed: %s\", self.name, exc, exc_info=True)\n             return\n \n+        # --- Clarify callbacks (cl:clarify_id:idx | cl:clarify_id:other) ---\n+        if data.startswith(\"cl:\"):\n+            parts = data.split(\":\", 2)\n+            if len(parts) == 3:\n+                clarify_id = parts[1]\n+                choice_token = [redacted:secret-assignment]\n+\n+                caller_id = str(getattr(query.from_user, \"id\", \"\"))\n+                if not self._is_callback_user_authorized(\n+                    caller_id,\n+                    chat_id=query_chat_id,\n+                    chat_type=str(query_chat_type) if query_chat_type is not None else None,\n+                    thread_id=str(query_thread_id) if query_thread_id is not None else None,\n+                    user_name=query_user_name,\n+                ):\n+                    await query.answer(text=\"⛔ You are not authorized to answer this prompt.\")\n+                    return\n+\n+                session_key = self._clarify_state.get(clarify_id)\n+                if not session_key:\n+                    await query.answer(text=\"This prompt has already been resolved.\")\n+                    return\n+\n+                user_display = getattr(query.from_user, \"first_name\", \"User\")\n+\n+                if choice_token == \"other\":\n+                    # Flip into text-capture mode and tell the user to type\n+                    # their answer.  The gateway's text-intercept will pick\n+                    # up the next message in this session and resolve the\n+                    # clarify.  Do NOT pop _clarify_state yet — we still\n+                    # need it if the user is slow to respond and the entry\n+                    # is cleared by something else.\n+                    try:\n+                        from tools.clarify_gateway import mark_awaiting_text\n+                        mark_awaiting_text(clarify_id)\n+                    except Exception as exc:\n+                        logger.warning(\"[%s] mark_awaiting_text failed: %s\", self.name, exc)\n+\n+                    await query.answer(text=\"✏️ Type your answer in the chat.\")\n+                    try:\n+                        await query.edit_message_text(\n+                            text=f\"❓ {query.message.text or ''}\\n\\n<i>Awaiting typed response from {_html.escape(user_display)}…</i>\",\n+                            parse_mode=ParseMode.HTML,\n+                            reply_markup=None,\n+                        )\n+                    except Exception:\n+                        pass\n+                    return\n+\n+                # Numeric choice → resolve immediately with the chosen text\n+                try:\n+                    idx = int(choice_token)\n+                except (ValueError, TypeError):\n+                    await query.answer(text=\"Invalid choice.\")\n+                    return\n+\n+                # Look up the choice text from the entry registered in the\n+                # clarify primitive.  Fall back to the index if the entry\n+                # has been cleaned up (race with timeout / session reset).\n+                resolved_text: Optional[str] = None\n+                try:\n+                    from tools.clarify_gateway import _entries as _clarify_entries  # type: ignore\n+                    entry = _clarify_entries.get(clarify_id)\n+                    if entry and entry.choices and 0 <= idx < len(entry.choices):\n+                        resolved_text = entry.choices[idx]\n+                except Exception:\n+                    resolved_text = None\n+\n+                if resolved_text is None:\n+                    # Race: entry vanished. Echo the index as a number so\n+                    # the agent at least sees an intentional response\n+                    # rather than nothing.\n+                    resolved_text = f\"choice {idx + 1}\"\n+\n+                # Pop state and resolve\n+                self._clarify_state.pop(clarify_id, None)\n+                try:\n+                    from tools.clarify_gateway import resolve_gateway_clarify\n+                    resolved = resolve_gateway_clarify(clarify_id, resolved_text)\n+                except Exception as exc:\n+                    logger.error(\"[%s] resolve_gateway_clarify failed: %s\", self.name, exc)\n+                    resolved = False\n+\n+                await query.answer(text=f\"✓ {resolved_text[:60]}\")\n+                try:\n+                    await query.edit_message_text(\n+                        text=f\"❓ {_html.escape(query.message.text or '')}\\n\\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}\",\n+                        parse_mode=ParseMode.HTML,\n+                        reply_markup=None,\n+                    )\n+                except Exception:\n+                    pass\n+\n+                if resolved:\n+                    logger.info(\n+                        \"Telegram clarify button resolved (id=%s, choice=%r, user=%s)\",\n+                        clarify_id, resolved_text, user_display,\n+                    )\n+                else:\n+                    logger.warning(\n+                        \"Telegram clarify button: resolve_gateway_clarify returned False (id=%s)\",\n+                        clarify_id,\n+                    )\n+            return\n+\n         # --- Update prompt callbacks ---\n         if not data.startswith(\"update_prompt:\"):\n             return"]},{"file":"gateway/run.py","hunks":["@@ -5828,6 +5828,37 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                     )\n                 _update_prompts.pop(_quick_key, None)\n \n+        # Intercept messages that are responses to a pending clarify\n+        # request that is awaiting free-form text (either an open-ended\n+        # clarify with no choices, or one where the user picked the\n+        # \"Other\" button).  The first non-empty user message in the\n+        # session resolves the clarify and unblocks the agent thread —\n+        # we do NOT route it to the agent as a new turn.\n+        try:\n+            from tools import clarify_gateway as _clarify_mod\n+            _pending_clarify = _clarify_mod.get_pending_for_session(_quick_key)\n+        except Exception:\n+            _pending_clarify = None\n+        if _pending_clarify is not None:\n+            _raw_clarify_reply = (event.text or \"\").strip()\n+            # Skip slash commands — the user clearly wanted to issue a\n+            # command, not answer the clarify.  Leave the clarify pending\n+            # so the user can retry; if it times out, the agent unblocks\n+            # with an empty response.\n+            if _raw_clarify_reply and not _raw_clarify_reply.startswith(\"/\"):\n+                _resolved = _clarify_mod.resolve_gateway_clarify(\n+                    _pending_clarify.clarify_id, _raw_clarify_reply,\n+                )\n+                if _resolved:\n+                    logger.info(\n+                        \"Gateway intercepted clarify text response (session=%s, id=%s)\",\n+                        _quick_key, _pending_clarify.clarify_id,\n+                    )\n+                    # Acknowledge with empty string so adapters that emit\n+                    # the agent's response don't double-post.  The agent\n+                    # itself will produce the next user-facing message.\n+                    return \"\"\n+\n         # Intercept messages that are responses to a pending /reload-mcp\n         # (or future) slash-confirm prompt.  Recognized confirm replies are\n         # /approve, /always, /cancel (plus short aliases).  Anything else","@@ -14957,6 +14988,76 @@ def _bg_review_send(message: str) -> None:\n                     if _pdc is not None:\n                         _pdc[session_key] = _release_bg_review_messages\n \n+            # ------------------------------------------------------------------\n+            # Clarify callback: present a clarify prompt and block on a response.\n+            #\n+            # Runs on the agent's worker thread (see clarify_tool's synchronous\n+            # callback contract).  Bridges sync→async by scheduling the\n+            # adapter's send_clarify on the gateway event loop, then blocks on\n+            # the clarify primitive's threading.Event with a configurable\n+            # timeout.  Returns the user's response string, or a sentinel\n+            # explaining that no response arrived (so the agent can adapt\n+            # rather than hang forever).\n+            # ------------------------------------------------------------------\n+            def _clarify_callback_sync(question: str, choices) -> str:\n+                from tools import clarify_gateway as _clarify_mod\n+                import uuid as _uuid\n+\n+                if not _status_adapter:\n+                    return \"\"\n+\n+                clarify_id = _uuid.uuid4().hex[:10]\n+                _clarify_mod.register(\n+                    clarify_id=clarify_id,\n+                    session_key=session_key or \"\",\n+                    question=question,\n+                    choices=list(choices) if choices else None,\n+                )\n+\n+                # Pause typing — like approval, we don't want a \"thinking...\"\n+                # status to obscure the prompt or block the user from typing\n+                # an \"Other\" response on platforms that disable input while\n+                # typing is active (Slack Assistant API).\n+                try:\n+                    _status_adapter.pause_typing_for_chat(_status_chat_id)\n+                except Exception:\n+                    pass\n+\n+                send_ok = False\n+                try:\n+                    fut = asyncio.run_coroutine_threadsafe(\n+                        _status_adapter.send_clarify(\n+                            chat_id=_status_chat_id,\n+                            question=question,\n+                            choices=list(choices) if choices else None,\n+                            clarify_id=clarify_id,\n+                            session_key=session_key or \"\",\n+                            metadata=_status_thread_metadata,\n+                        ),\n+                        _loop_for_step,\n+                    )\n+                    result = fut.result(timeout=15)\n+                    send_ok = bool(getattr(result, \"success\", False))\n+                except Exception as exc:\n+                    logger.warning(\"Clarify send failed: %s\", exc)\n+                    send_ok = False\n+\n+                if not send_ok:\n+                    # Couldn't deliver the prompt — clean up and return\n+                    # sentinel so the agent can fall back to a sensible\n+                    # default rather than hanging.\n+                    _clarify_mod.clear_session(session_key or \"\")\n+                    return \"[clarify prompt could not be delivered]\"\n+\n+                timeout = _clarify_mod.get_clarify_timeout()\n+                response = _clarify_mod.wait_for_response(clarify_id, timeout=float(timeout))\n+                if response is None or response == \"\":\n+                    # Timeout or session-boundary cancellation\n+                    return f\"[user did not respond within {int(timeout / 60)}m]\"\n+                return response\n+\n+            agent.clarify_callback = _clarify_callback_sync\n+\n             # Store agent reference for interrupt support\n             agent_holder[0] = agent\n             # Capture the full tool definitions for transcript logging","@@ -15228,6 +15329,14 @@ def _approval_notify_sync(approval_data: dict) -> None:\n                 result = agent.run_conversation(_run_message, conversation_history=agent_history, task_id=session_id)\n             finally:\n                 unregister_gateway_notify(_approval_session_key)\n+                # Cancel any pending clarify entries so blocked agent\n+                # threads don't hang past the end of the run (interrupt,\n+                # completion, gateway shutdown).  Idempotent.\n+                try:\n+                    from tools.clarify_gateway import clear_session as _clear_clarify_session\n+                    _clear_clarify_session(_approval_session_key)\n+                except Exception:\n+                    pass\n                 reset_current_session_key(_approval_session_token)\n             result_holder[0] = result\n "]},{"file":"hermes_cli/config.py","hunks":["@@ -477,6 +477,12 @@ def _ensure_hermes_home_managed(home: Path):\n         # threshold before escalating to a full timeout.  The warning fires\n         # once per run and does not interrupt the agent.  0 = disable warning.\n         \"gateway_timeout_warning\": 900,\n+        # Maximum time (seconds) the gateway will block an agent waiting for\n+        # a clarify-tool response from the user.  Hit this and the agent\n+        # unblocks with \"[user did not respond within Xm]\" so it can adapt\n+        # rather than pinning the running-agent guard forever.  CLI clarify\n+        # blocks indefinitely (input() is synchronous) and ignores this.\n+        \"clarify_timeout\": 600,\n         # Periodic \"still working\" notification interval (seconds).\n         # Sends a status message every N seconds so the user knows the\n         # agent hasn't died during long tasks.  0 = disable notifications."]},{"file":"tests/gateway/test_telegram_clarify_buttons.py","hunks":["@@ -0,0 +1,451 @@\n+\"\"\"Tests for Telegram inline keyboard clarify buttons.\n+\n+Mirrors test_telegram_approval_buttons.py for the new ``send_clarify`` and\n+``cl:`` callback dispatch added in feat/clarify-gateway-buttons.\n+\"\"\"\n+\n+import asyncio\n+import os\n+import sys\n+from pathlib import Path\n+from types import SimpleNamespace\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 (mirrors\n+# test_telegram_approval_buttons.py)\n+# ---------------------------------------------------------------------------\n+def _ensure_telegram_mock():\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+    mod.error.NetworkError = type(\"NetworkError\", (OSError,), {})\n+    mod.error.TimedOut = type(\"TimedOut\", (OSError,), {})\n+    mod.error.BadRequest = type(\"BadRequest\", (Exception,), {})\n+\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n+        sys.modules.setdefault(name, mod)\n+    sys.modules.setdefault(\"telegram.error\", mod.error)\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(extra=None):\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\", extra=extra or {})\n+    adapter = TelegramAdapter(config)\n+    adapter._bot = AsyncMock()\n+    adapter._app = MagicMock()\n+    return adapter\n+\n+\n+def _clear_clarify_state():\n+    from tools import clarify_gateway as cm\n+    with cm._lock:\n+        cm._entries.clear()\n+        cm._session_index.clear()\n+        cm._notify_cbs.clear()\n+\n+\n+# ===========================================================================\n+# send_clarify — render\n+# ===========================================================================\n+\n+class TestTelegramSendClarify:\n+    \"\"\"Verify the rendered prompt has buttons or none, and stores state.\"\"\"\n+\n+    def setup_method(self):\n+        _clear_clarify_state()\n+\n+    @pytest.mark.asyncio\n+    async def test_multi_choice_renders_buttons_and_other(self):\n+        adapter = _make_adapter()\n+        mock_msg = MagicMock()\n+        mock_msg.message_id = 100\n+        adapter._bot.send_message = AsyncMock(return_value=mock_msg)\n+\n+        result = await adapter.send_clarify(\n+            chat_id=\"12345\",\n+            question=\"Which option?\",\n+            choices=[\"alpha\", \"beta\", \"gamma\"],\n+            clarify_id=\"cid1\",\n+            session_key=\"sk1\",\n+        )\n+\n+        assert result.success is True\n+        assert result.message_id == \"100\"\n+\n+        kwargs = adapter._bot.send_message.call_args[1]\n+        assert kwargs[\"chat_id\"] == 12345\n+        assert \"Which option?\" in kwargs[\"text\"]\n+        # InlineKeyboardMarkup with N+1 buttons (3 choices + Other)\n+        markup = kwargs[\"reply_markup\"]\n+        assert markup is not None\n+        # Mocked InlineKeyboardMarkup — just verify it was constructed\n+        # with rows.  We check state instead of poking the mock structure.\n+        assert \"cid1\" in adapter._clarify_state\n+        assert adapter._clarify_state[\"cid1\"] == \"sk1\"\n+\n+    @pytest.mark.asyncio\n+    async def test_open_ended_no_keyboard(self):\n+        adapter = _make_adapter()\n+        mock_msg = MagicMock()\n+        mock_msg.message_id = 101\n+        adapter._bot.send_message = AsyncMock(return_value=mock_msg)\n+\n+        result = await adapter.send_clarify(\n+            chat_id=\"12345\",\n+            question=\"What is your name?\",\n+            choices=None,\n+            clarify_id=\"cid2\",\n+            session_key=\"sk2\",\n+        )\n+\n+        assert result.success is True\n+        kwargs = adapter._bot.send_message.call_args[1]\n+        # No reply_markup means no buttons — open-ended path\n+        assert \"reply_markup\" not in kwargs\n+        assert \"What is your name?\" in kwargs[\"text\"]\n+        assert adapter._clarify_state[\"cid2\"] == \"sk2\"\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_clarify(\n+            chat_id=\"12345\",\n+            question=\"?\",\n+            choices=[\"a\"],\n+            clarify_id=\"cid3\",\n+            session_key=\"sk3\",\n+        )\n+        assert result.success is False\n+\n+    @pytest.mark.asyncio\n+    async def test_truncates_long_choice_label(self):\n+        adapter = _make_adapter()\n+        mock_msg = MagicMock()\n+        mock_msg.message_id = 102\n+        adapter._bot.send_message = AsyncMock(return_value=mock_msg)\n+\n+        long_choice = \"x\" * 200  # > 60 char cap\n+        result = await adapter.send_clarify(\n+            chat_id=\"12345\",\n+            question=\"?\",\n+            choices=[long_choice],\n+            clarify_id=\"cid4\",\n+            session_key=\"sk4\",\n+        )\n+        assert result.success is True\n+        # The truncation logic replaces with \"...\" past 57 chars; we don't\n+        # inspect the mock's button labels directly (auto-MagicMock), but\n+        # we can verify the call didn't raise on absurdly long input.\n+\n+    @pytest.mark.asyncio\n+    async def test_html_escapes_question(self):\n+        adapter = _make_adapter()\n+        mock_msg = MagicMock()\n+        mock_msg.message_id = 103\n+        adapter._bot.send_message = AsyncMock(return_value=mock_msg)\n+\n+        await adapter.send_clarify(\n+            chat_id=\"12345\",\n+            question=\"<script>alert(1)</script>\",\n+            choices=[\"x\"],\n+            clarify_id=\"cid5\",\n+            session_key=\"sk5\",\n+        )\n+        kwargs = adapter._bot.send_message.call_args[1]\n+        # Must NOT contain raw <script> — html.escape should have neutralized\n+        assert \"<script>\" not in kwargs[\"text\"]\n+        assert \"&lt;script&gt;\" in kwargs[\"text\"]\n+\n+\n+# ===========================================================================\n+# Callback dispatch — _handle_callback_query routing for cl:* prefixes\n+# ===========================================================================\n+\n+class TestTelegramClarifyCallback:\n+    \"\"\"Verify clicking a button resolves the clarify primitive.\"\"\"\n+\n+    def setup_method(self):\n+        _clear_clarify_state()\n+\n+    @pytest.mark.asyncio\n+    async def test_numeric_choice_resolves_with_choice_text(self):\n+        from tools import clarify_gateway as cm\n+\n+        adapter = _make_adapter()\n+        # Pre-register a clarify entry so the callback can look up the choice text\n+        cm.register(\"cidA\", \"sk-cb\", \"Pick\", [\"red\", \"green\", \"blue\"])\n+        adapter._clarify_state[\"cidA\"] = \"sk-cb\"\n+\n+        query = AsyncMock()\n+        query.data = \"cl:cidA:1\"  # green\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.message.text = \"Pick\"\n+        query.from_user = MagicMock()\n+        query.from_user.id = \"777\"\n+        query.from_user.first_name = \"Tester\"\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.dict(os.environ, {\"TELEGRAM_ALLOWED_USERS\": \"*\"}, clear=False):\n+            await adapter._handle_callback_query(update, context)\n+\n+        # State popped\n+        assert \"cidA\" not in adapter._clarify_state\n+        # Wait shouldn't be needed — resolve_gateway_clarify is sync.\n+        # The entry's response should be set.\n+        # We test by reading the entry's response directly.\n+        with cm._lock:\n+            entry = cm._entries.get(\"cidA\")\n+        # Entry might be popped by wait_for_response, but here we never\n+        # called wait — so it's still in _entries with response set.\n+        assert entry is not None\n+        assert entry.response == \"green\"\n+        assert entry.event.is_set()\n+        query.answer.assert_called_once()\n+        query.edit_message_text.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_other_button_flips_to_text_mode(self):\n+        from tools import clarify_gateway as cm\n+\n+        adapter = _make_adapter()\n+        cm.register(\"cidB\", \"sk-cb-other\", \"Pick\", [\"x\", \"y\"])\n+        adapter._clarify_state[\"cidB\"] = \"sk-cb-other\"\n+\n+        query = AsyncMock()\n+        query.data = \"cl:cidB:other\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.message.text = \"Pick\"\n+        query.from_user = MagicMock()\n+        query.from_user.id = \"777\"\n+        query.from_user.first_name = \"Tester\"\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.dict(os.environ, {\"TELEGRAM_ALLOWED_USERS\": \"*\"}, clear=False):\n+            await adapter._handle_callback_query(update, context)\n+\n+        # Entry should now be in text-capture mode\n+        pending = cm.get_pending_for_session(\"sk-cb-other\")\n+        assert pending is not None\n+        assert pending.clarify_id == \"cidB\"\n+        assert pending.awaiting_text is True\n+        # State NOT popped — the user still needs to type their answer\n+        assert \"cidB\" in adapter._clarify_state\n+        # Entry NOT yet resolved\n+        with cm._lock:\n+            entry = cm._entries.get(\"cidB\")\n+        assert entry is not None\n+        assert not entry.event.is_set()\n+\n+    @pytest.mark.asyncio\n+    async def test_already_resolved(self):\n+        adapter = _make_adapter()\n+        # No state for cidGone\n+\n+        query = AsyncMock()\n+        query.data = \"cl:cidGone:0\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.from_user = MagicMock()\n+        query.from_user.id = \"777\"\n+        query.from_user.first_name = \"Tester\"\n+        query.answer = AsyncMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        with patch.dict(os.environ, {\"TELEGRAM_ALLOWED_USERS\": \"*\"}, clear=False):\n+            await adapter._handle_callback_query(update, context)\n+\n+        query.answer.assert_called_once()\n+        # Should NOT resolve anything\n+        assert \"already\" in query.answer.call_args[1][\"text\"].lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_unauthorized_user_rejected(self):\n+        from tools import clarify_gateway as cm\n+\n+        adapter = _make_adapter()\n+        cm.register(\"cidC\", \"sk-auth\", \"Pick\", [\"a\", \"b\"])\n+        adapter._clarify_state[\"cidC\"] = \"sk-auth\"\n+\n+        # Hook up a runner that says NOT authorized\n+        class _DenyRunner:\n+            async def _handle_message(self, event):\n+                return None\n+            def _is_user_authorized(self, source):\n+                return False\n+\n+        adapter._message_handler = _DenyRunner()._handle_message\n+\n+        query = AsyncMock()\n+        query.data = \"cl:cidC:0\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.message.chat.type = \"private\"\n+        query.message.text = \"Pick\"\n+        query.from_user = MagicMock()\n+        query.from_user.id = \"999\"\n+        query.from_user.first_name = \"Mallory\"\n+        query.answer = AsyncMock()\n+        query.edit_message_text = AsyncMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        await adapter._handle_callback_query(update, context)\n+\n+        # Must not resolve, must answer with not-authorized message\n+        with cm._lock:\n+            entry = cm._entries.get(\"cidC\")\n+        assert entry is not None\n+        assert not entry.event.is_set()\n+        query.answer.assert_called_once()\n+        assert \"not authorized\" in query.answer.call_args[1][\"text\"].lower()\n+        # State preserved\n+        assert adapter._clarify_state[\"cidC\"] == \"sk-auth\"\n+\n+    @pytest.mark.asyncio\n+    async def test_invalid_choice_token(self):\n+        from tools import clarify_gateway as cm\n+\n+        adapter = _make_adapter()\n+        cm.register(\"cidD\", \"sk-inv\", \"Q?\", [\"a\"])\n+        adapter._clarify_state[\"cidD\"] = \"sk-inv\"\n+\n+        query = AsyncMock()\n+        query.data = \"cl:cidD:not-a-number\"\n+        query.message = MagicMock()\n+        query.message.chat_id = 12345\n+        query.message.text = \"Q?\"\n+        query.from_user = MagicMock()\n+        query.from_user.id = \"777\"\n+        query.from_user.first_name = \"Tester\"\n+        query.answer = AsyncMock()\n+\n+        update = MagicMock()\n+        update.callback_query = query\n+        context = MagicMock()\n+\n+        with patch.dict(os.environ, {\"TELEGRAM_ALLOWED_USERS\": \"*\"}, clear=False):\n+            await adapter._handle_callback_query(update, context)\n+\n+        with cm._lock:\n+            entry = cm._entries.get(\"cidD\")\n+        assert entry is not None\n+        assert not entry.event.is_set()\n+        query.answer.assert_called_once()\n+        assert \"invalid\" in query.answer.call_args[1][\"text\"].lower()\n+\n+\n+# ===========================================================================\n+# Base adapter fallback render — text numbered list\n+# ===========================================================================\n+\n+class TestBaseAdapterClarifyFallback:\n+    \"\"\"Adapters without button overrides should render numbered text.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_numbered_text_fallback(self):\n+        from gateway.platforms.base import BasePlatformAdapter, SendResult\n+\n+        # Subclass just enough to instantiate\n+        class _Stub(BasePlatformAdapter):\n+            name = \"stub\"\n+\n+            def __init__(self):\n+                # Skip base __init__ — we're not exercising it\n+                self.sent: list = []\n+\n+            async def connect(self): pass\n+            async def disconnect(self): pass\n+            async def send(self, chat_id, content, **kw):\n+                self.sent.append({\"chat_id\": chat_id, \"content\": content})\n+                return SendResult(success=True, message_id=\"1\")\n+            async def edit(self, *a, **k): return SendResult(success=False)\n+            async def get_history(self, *a, **k): return []\n+            async def get_chat_info(self, *a, **k): return {}\n+\n+        adapter = _Stub()\n+\n+        result = await adapter.send_clarify(\n+            chat_id=\"c\",\n+            question=\"Pick a fruit\",\n+            choices=[\"apple\", \"banana\"],\n+            clarify_id=\"x\",\n+            session_key=\"s\",\n+        )\n+        assert result.success is True\n+        assert len(adapter.sent) == 1\n+        text = adapter.sent[0][\"content\"]\n+        assert \"Pick a fruit\" in text\n+        assert \"1.\" in text and \"apple\" in text\n+        assert \"2.\" in text and \"banana\" in text\n+\n+    @pytest.mark.asyncio\n+    async def test_open_ended_fallback_renders_question_only(self):\n+        from gateway.platforms.base import BasePlatformAdapter, SendResult\n+\n+        class _Stub(BasePlatformAdapter):\n+            name = \"stub\"\n+            def __init__(self):\n+                self.sent: list = []\n+            async def connect(self): pass\n+            async def disconnect(self): pass\n+            async def send(self, chat_id, content, **kw):\n+                self.sent.append(content)\n+                return SendResult(success=True, message_id=\"1\")\n+            async def edit(self, *a, **k): return SendResult(success=False)\n+            async def get_history(self, *a, **k): return []\n+            async def get_chat_info(self, *a, **k): return {}\n+\n+        adapter = _Stub()\n+        await adapter.send_clarify(\n+            chat_id=\"c\",\n+            question=\"Free form?\",\n+            choices=None,\n+            clarify_id=\"x\",\n+            session_key=\"s\",\n+        )\n+        assert \"Free form?\" in adapter.sent[0]\n+        # No numbered list — choices were empty\n+        assert \"1.\" not in adapter.sent[0]"]},{"file":"tests/tools/test_clarify_gateway.py","hunks":["@@ -0,0 +1,207 @@\n+\"\"\"Tests for the gateway-side clarify primitive (tools/clarify_gateway.py).\n+\n+The clarify tool needs to ask the user a question and block the agent\n+thread until they respond.  These tests cover the module-level state\n+machine: register, wait, resolve via button, resolve via text-fallback,\n+\"Other\"-button text-capture flip, timeout, session boundary cleanup.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import threading\n+import time\n+from concurrent.futures import ThreadPoolExecutor\n+\n+import pytest\n+\n+\n+def _clear_clarify_state():\n+    \"\"\"Reset module-level state between tests.\"\"\"\n+    from tools import clarify_gateway as cm\n+    with cm._lock:\n+        cm._entries.clear()\n+        cm._session_index.clear()\n+        cm._notify_cbs.clear()\n+\n+\n+class TestClarifyPrimitive:\n+    \"\"\"Core register/wait/resolve mechanics.\"\"\"\n+\n+    def setup_method(self):\n+        _clear_clarify_state()\n+\n+    def test_button_choice_resolves_wait(self):\n+        \"\"\"resolve_gateway_clarify unblocks wait_for_response with the chosen string.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id1\", \"sk1\", \"Pick one\", [\"A\", \"B\", \"C\"])\n+\n+        def resolver():\n+            time.sleep(0.05)\n+            cm.resolve_gateway_clarify(\"id1\", \"B\")\n+\n+        threading.Thread(target=resolver).start()\n+        result = cm.wait_for_response(\"id1\", timeout=2.0)\n+        assert result == \"B\"\n+\n+    def test_open_ended_auto_awaits_text(self):\n+        \"\"\"Clarify with no choices is in text-capture mode immediately.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        entry = cm.register(\"id2\", \"sk2\", \"Free form?\", None)\n+        assert entry.awaiting_text is True\n+\n+        # get_pending_for_session returns the entry so the gateway\n+        # text-intercept can find it.\n+        pending = cm.get_pending_for_session(\"sk2\")\n+        assert pending is not None\n+        assert pending.clarify_id == \"id2\"\n+\n+    def test_button_choice_does_not_auto_await(self):\n+        \"\"\"Multi-choice clarify should NOT be in text-capture mode initially.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        entry = cm.register(\"id3\", \"sk3\", \"Pick\", [\"X\", \"Y\"])\n+        assert entry.awaiting_text is False\n+        assert cm.get_pending_for_session(\"sk3\") is None\n+\n+    def test_other_button_flips_to_text_mode(self):\n+        \"\"\"mark_awaiting_text makes get_pending_for_session find the entry.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id4\", \"sk4\", \"Pick\", [\"X\", \"Y\"])\n+        assert cm.get_pending_for_session(\"sk4\") is None\n+\n+        flipped = cm.mark_awaiting_text(\"id4\")\n+        assert flipped is True\n+\n+        pending = cm.get_pending_for_session(\"sk4\")\n+        assert pending is not None\n+        assert pending.clarify_id == \"id4\"\n+\n+    def test_mark_awaiting_text_unknown_id(self):\n+        \"\"\"mark_awaiting_text on a non-existent id returns False.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        assert cm.mark_awaiting_text(\"nope\") is False\n+\n+    def test_timeout_returns_none(self):\n+        \"\"\"wait_for_response returns None when no resolve fires within the timeout.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id5\", \"sk5\", \"Q?\", [\"A\"])\n+        result = cm.wait_for_response(\"id5\", timeout=0.2)\n+        assert result is None\n+\n+    def test_resolve_unknown_id_returns_false(self):\n+        \"\"\"resolve_gateway_clarify is idempotent on unknown ids.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        assert cm.resolve_gateway_clarify(\"nope\", \"anything\") is False\n+\n+    def test_resolve_after_wait_completes_is_noop(self):\n+        \"\"\"A late resolve on a finished entry doesn't blow up.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id6\", \"sk6\", \"Q?\", [\"A\"])\n+        # Time out, entry gets cleaned up\n+        cm.wait_for_response(\"id6\", timeout=0.1)\n+        # Late button click — should not raise\n+        result = cm.resolve_gateway_clarify(\"id6\", \"A\")\n+        assert result is False\n+\n+    def test_clear_session_cancels_pending_entries(self):\n+        \"\"\"clear_session unblocks blocked threads with empty response.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id7\", \"sk7\", \"Q?\", [\"A\"])\n+\n+        def waiter():\n+            return cm.wait_for_response(\"id7\", timeout=10.0)\n+\n+        with ThreadPoolExecutor(1) as pool:\n+            fut = pool.submit(waiter)\n+            time.sleep(0.05)\n+            cancelled = cm.clear_session(\"sk7\")\n+            assert cancelled == 1\n+            result = fut.result(timeout=2.0)\n+            # clear_session sets response=\"\" then the wait returns it\n+            assert result == \"\"\n+\n+    def test_has_pending(self):\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id8\", \"sk8\", \"Q?\", [\"A\"])\n+        assert cm.has_pending(\"sk8\") is True\n+        assert cm.has_pending(\"nonexistent\") is False\n+\n+    def test_notify_register_unregister_clears_pending(self):\n+        \"\"\"unregister_notify cancels any pending clarify so threads unwind.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id9\", \"sk9\", \"Q?\", [\"A\"])\n+\n+        def waiter():\n+            return cm.wait_for_response(\"id9\", timeout=10.0)\n+\n+        with ThreadPoolExecutor(1) as pool:\n+            fut = pool.submit(waiter)\n+            time.sleep(0.05)\n+\n+            cm.register_notify(\"sk9\", lambda entry: None)\n+            cm.unregister_notify(\"sk9\")\n+\n+            # unregister_notify calls clear_session; thread unwinds\n+            result = fut.result(timeout=2.0)\n+            assert result == \"\"\n+\n+    def test_session_index_isolation(self):\n+        \"\"\"Entries from different sessions don't leak across get_pending lookups.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"idA\", \"alpha\", \"Q?\", None)  # auto-await text\n+        cm.register(\"idB\", \"beta\", \"Q?\", None)   # auto-await text\n+\n+        a = cm.get_pending_for_session(\"alpha\")\n+        b = cm.get_pending_for_session(\"beta\")\n+        assert a is not None and a.clarify_id == \"idA\"\n+        assert b is not None and b.clarify_id == \"idB\"\n+\n+    def test_clarify_timeout_config_default(self):\n+        \"\"\"get_clarify_timeout returns 600 by default.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        timeout = cm.get_clarify_timeout()\n+        # Default 600s OR whatever is in the user's loaded config.\n+        # Floor check: must be a positive int, not crashed.\n+        assert isinstance(timeout, int)\n+        assert timeout > 0\n+\n+\n+class TestGatewayTextIntercept:\n+    \"\"\"The gateway's _handle_message intercepts text replies to pending clarifies.\"\"\"\n+\n+    def setup_method(self):\n+        _clear_clarify_state()\n+\n+    def test_get_pending_for_session_returns_oldest_text_awaiting(self):\n+        \"\"\"When two clarifies are pending, get_pending_for_session returns the\n+        first that is awaiting_text (the older one if both).\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        # Older multi-choice (not awaiting text)\n+        cm.register(\"first\", \"sk\", \"Q1?\", [\"A\"])\n+        # Newer open-ended (awaiting text)\n+        cm.register(\"second\", \"sk\", \"Q2?\", None)\n+\n+        pending = cm.get_pending_for_session(\"sk\")\n+        # The newer one is awaiting text; the older isn't.\n+        assert pending is not None\n+        assert pending.clarify_id == \"second\"\n+\n+        # Now flip the first to text mode too.  Both are awaiting text,\n+        # FIFO returns the older one.\n+        cm.mark_awaiting_text(\"first\")\n+        pending2 = cm.get_pending_for_session(\"sk\")\n+        assert pending2 is not None\n+        assert pending2.clarify_id == \"first\""]},{"file":"tools/clarify_gateway.py","hunks":["@@ -0,0 +1,278 @@\n+\"\"\"Gateway-side clarify primitive (blocking event-based queue).\n+\n+The ``clarify`` tool needs to ask the user a question and block the agent\n+thread until they respond.  In CLI mode this is trivial — ``input()`` is\n+synchronous.  In gateway mode the agent runs on a worker thread while the\n+event loop handles the user's reply, so we need a thread-safe primitive\n+that:\n+\n+  * stores a pending clarify request (with a generated ``clarify_id``),\n+  * blocks the agent thread on an ``Event``,\n+  * resolves the wait when the gateway's button-callback or text-intercept\n+    fires ``resolve_gateway_clarify(clarify_id, response)``,\n+  * supports timeouts so a user who never responds does NOT hang the agent\n+    thread forever (which would also pin the gateway's running-agent guard).\n+\n+State is module-level (same shape as ``tools.approval``) so platform\n+adapters can call ``resolve_gateway_clarify`` without holding a back-\n+reference to the ``GatewayRunner`` instance.\n+\n+Two delivery paths from the adapter:\n+\n+  1. **Button UI** — adapters override ``send_clarify`` to render inline\n+     buttons (e.g. Telegram ``InlineKeyboardMarkup``).  The button\n+     callback resolves with the chosen string.  A final \"Other (type\n+     answer)\" button enters text-capture mode for free-form responses.\n+\n+  2. **Text fallback** — adapters without rich UI render a numbered list.\n+     The user replies with a number (\"2\") or with free text; the gateway's\n+     ``_handle_message`` intercepts the reply and resolves directly.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import logging\n+import threading\n+import time\n+from dataclasses import dataclass, field\n+from typing import Callable, Dict, List, Optional\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+# =========================================================================\n+# Module-level state\n+# =========================================================================\n+\n+@dataclass\n+class _ClarifyEntry:\n+    \"\"\"One pending clarify request inside a gateway session.\"\"\"\n+    clarify_id: str\n+    session_key: str\n+    question: str\n+    choices: Optional[List[str]]\n+    event: threading.Event = field(default_factory=threading.Event)\n+    response: Optional[str] = None\n+    awaiting_text: bool = False  # set when user picked \"Other\" or clarify is open-ended\n+\n+    def signature(self) -> Dict[str, object]:\n+        return {\n+            \"clarify_id\": self.clarify_id,\n+            \"session_key\": self.session_key,\n+            \"question\": self.question,\n+            \"choices\": list(self.choices) if self.choices else None,\n+        }\n+\n+\n+_lock = threading.RLock()\n+# clarify_id → _ClarifyEntry  (primary lookup for button callbacks)\n+_entries: Dict[str, _ClarifyEntry] = {}\n+# session_key → list[clarify_id]  (FIFO; for text-fallback intercept and session cleanup)\n+_session_index: Dict[str, List[str]] = {}\n+\n+\n+# =========================================================================\n+# Public API — agent-thread side\n+# =========================================================================\n+\n+def register(\n+    clarify_id: str,\n+    session_key: str,\n+    question: str,\n+    choices: Optional[List[str]],\n+) -> _ClarifyEntry:\n+    \"\"\"Register a pending clarify request and return the entry.\n+\n+    The caller (gateway clarify_callback) will then send the prompt to the\n+    user and block on ``wait_for_response(clarify_id, timeout)``.\n+    \"\"\"\n+    entry = _ClarifyEntry(\n+        clarify_id=clarify_id,\n+        session_key=session_key,\n+        question=question,\n+        choices=list(choices) if choices else None,\n+        # Open-ended (no choices) → next message IS the response, no buttons needed.\n+        awaiting_text=not bool(choices),\n+    )\n+    with _lock:\n+        _entries[clarify_id] = entry\n+        _session_index.setdefault(session_key, []).append(clarify_id)\n+    return entry\n+\n+\n+def wait_for_response(clarify_id: str, timeout: float) -> Optional[str]:\n+    \"\"\"Block on the entry's event until resolved or timeout fires.\n+\n+    Polls in 1-second slices so the agent's inactivity heartbeat keeps\n+    firing — without this, ``Event.wait(timeout=600)`` blocks the thread\n+    for 10 minutes with zero activity touches and the gateway's inactivity\n+    watchdog kills the agent while the user is still typing.\n+\n+    Returns the resolved response string, or ``None`` on timeout.\n+    \"\"\"\n+    with _lock:\n+        entry = _entries.get(clarify_id)\n+    if entry is None:\n+        return None\n+\n+    try:\n+        from tools.environments.base import touch_activity_if_due\n+    except Exception:  # pragma: no cover - optional\n+        touch_activity_if_due = None\n+\n+    deadline = time.monotonic() + max(timeout, 0.0)\n+    activity_state = {\"last_touch\": time.monotonic(), \"start\": time.monotonic()}\n+    while True:\n+        remaining = deadline - time.monotonic()\n+        if remaining <= 0:\n+            break\n+        if entry.event.wait(timeout=min(1.0, remaining)):\n+            break\n+        if touch_activity_if_due is not None:\n+            touch_activity_if_due(activity_state, \"waiting for user clarify response\")\n+\n+    with _lock:\n+        # Remove from indices regardless of resolution outcome.\n+        _entries.pop(clarify_id, None)\n+        ids = _session_index.get(entry.session_key)\n+        if ids and clarify_id in ids:\n+            ids.remove(clarify_id)\n+            if not ids:\n+                _session_index.pop(entry.session_key, None)\n+\n+    return entry.response\n+\n+\n+# =========================================================================\n+# Public API — gateway / adapter side\n+# =========================================================================\n+\n+def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:\n+    \"\"\"Unblock the agent thread waiting on ``clarify_id``.\n+\n+    Returns True if an entry was found and resolved, False otherwise\n+    (already resolved, expired, or never existed).\n+    \"\"\"\n+    with _lock:\n+        entry = _entries.get(clarify_id)\n+        if entry is None:\n+            return False\n+    entry.response = str(response) if response is not None else \"\"\n+    entry.event.set()\n+    return True\n+\n+\n+def get_pending_for_session(session_key: str) -> Optional[_ClarifyEntry]:\n+    \"\"\"Return the OLDEST pending clarify entry for a session, or None.\n+\n+    Used by the text-fallback intercept in ``_handle_message`` — when a\n+    clarify is awaiting a free-form text response, the next user message\n+    in that session is captured as the answer.\n+    \"\"\"\n+    with _lock:\n+        ids = _session_index.get(session_key) or []\n+        for cid in ids:\n+            entry = _entries.get(cid)\n+            if entry is None:\n+                continue\n+            if entry.awaiting_text:\n+                return entry\n+        return None\n+\n+\n+def mark_awaiting_text(clarify_id: str) -> bool:\n+    \"\"\"Flip an entry into text-capture mode (user picked the 'Other' button).\n+\n+    Returns True if the entry exists and was flipped, False otherwise.\n+    \"\"\"\n+    with _lock:\n+        entry = _entries.get(clarify_id)\n+        if entry is None:\n+            return False\n+        entry.awaiting_text = True\n+        return True\n+\n+\n+def has_pending(session_key: str) -> bool:\n+    \"\"\"Return True when this session has at least one pending clarify entry.\"\"\"\n+    with _lock:\n+        ids = _session_index.get(session_key) or []\n+        return any(_entries.get(cid) is not None for cid in ids)\n+\n+\n+def clear_session(session_key: str) -> int:\n+    \"\"\"Resolve and drop every pending clarify for a session.\n+\n+    Used by session-boundary cleanup (e.g. ``/new``, gateway shutdown,\n+    cached-agent eviction) so blocked agent threads don't hang past the\n+    end of their session.  Returns the number of entries cancelled.\n+    \"\"\"\n+    with _lock:\n+        ids = list(_session_index.pop(session_key, []) or [])\n+        entries = [_entries.pop(cid, None) for cid in ids]\n+    cancelled = 0\n+    for entry in entries:\n+        if entry is None:\n+            continue\n+        # Empty string sentinel — agent code can distinguish from a real\n+        # response by inspecting the wait_for_response return value\n+        # alongside its own timeout deadline.  Most callers just treat any\n+        # falsy result as \"user did not respond\".\n+        entry.response = \"\"\n+        entry.event.set()\n+        cancelled += 1\n+    return cancelled\n+\n+\n+# =========================================================================\n+# Config\n+# =========================================================================\n+\n+def get_clarify_timeout() -> int:\n+    \"\"\"Read the clarify response timeout (seconds) from config.\n+\n+    Defaults to 600 (10 minutes) — long enough for the user to type a\n+    thoughtful response, short enough that an abandoned prompt eventually\n+    unblocks the agent thread instead of pinning the running-agent guard\n+    forever.\n+\n+    Reads ``agent.clarify_timeout`` from config.yaml.\n+    \"\"\"\n+    try:\n+        from hermes_cli.config import load_config\n+        cfg = load_config() or {}\n+        agent_cfg = cfg.get(\"agent\", {}) or {}\n+        return int(agent_cfg.get(\"clarify_timeout\", 600))\n+    except Exception:\n+        return 600\n+\n+\n+# =========================================================================\n+# Per-session notify hook (gateway → adapter bridge)\n+# =========================================================================\n+# Mirrors tools.approval's _gateway_notify_cbs: the gateway registers a\n+# per-session callback that sends the clarify prompt to the user.  The\n+# callback bridges sync→async (runs on the agent thread; schedules the\n+# adapter ``send_clarify`` call on the event loop).\n+\n+_notify_cbs: Dict[str, Callable[[_ClarifyEntry], None]] = {}\n+\n+\n+def register_notify(session_key: str, cb: Callable[[_ClarifyEntry], None]) -> None:\n+    \"\"\"Register a per-session notify callback used by ``clarify_callback``.\"\"\"\n+    with _lock:\n+        _notify_cbs[session_key] = cb\n+\n+\n+def unregister_notify(session_key: str) -> None:\n+    \"\"\"Drop the per-session notify callback and cancel any pending clarify entries.\"\"\"\n+    with _lock:\n+        _notify_cbs.pop(session_key, None)\n+    # Cancel any pending entries so blocked threads unwind when the run\n+    # ends (interrupt, completion, gateway shutdown).\n+    clear_session(session_key)\n+\n+\n+def get_notify(session_key: str) -> Optional[Callable[[_ClarifyEntry], None]]:\n+    with _lock:\n+        return _notify_cbs.get(session_key)"]},{"file":"website/docs/user-guide/messaging/telegram.md","hunks":["@@ -907,6 +907,19 @@ When the agent tries to run a potentially dangerous command, it asks you for app\n \n Reply \"yes\"/\"y\" to approve or \"no\"/\"n\" to deny.\n \n+## Interactive Prompts (clarify)\n+\n+When the agent calls the `clarify` tool — to ask which approach you prefer, get post-task feedback, or check before a non-trivial decision — Telegram renders the question with **inline keyboard buttons**:\n+\n+> ❓ Which framework should I use for the dashboard?\n+>\n+> [1. Next.js] [2. Remix] [3. Astro]\n+> [✏️ Other (type answer)]\n+\n+Tap a button to answer, or tap **Other** to type a free-form response (the next message you send becomes the answer). Open-ended `clarify` calls (no preset choices) skip the buttons and just capture your next message.\n+\n+Configure the response timeout via `agent.clarify_timeout` in `~/.hermes/config.yaml` (default `600` seconds). If you don't respond within the timeout, the agent unblocks with a sentinel message and adapts rather than hanging.\n+\n ## Security\n \n :::warning\n"]}]},{"kind":"pr","number":29719,"title":"feat(feishu): wire clarify tool with interactive card buttons","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#11981","number":11981},{"kind":"pr","value":"#11977","number":11977},{"kind":"pr","value":"#26332","number":26332},{"kind":"pr","value":"#23740","number":23740},{"kind":"pr","value":"#12573","number":12573},{"kind":"pr","value":"#21032","number":21032},{"kind":"pr","value":"#24199","number":24199}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}