{"repo":"NousResearch/hermes-agent","public_id":"25567","display_label":"Clarify prompt typed-reply interception across platforms","summary":"These issues and fixes concern the clarify tool failing to intercept typed text replies when presenting multi-choice prompts, affecting Mattermost, Discord, Telegram, and the base gateway. Work spans bug reports, platform-specific rendering changes, and session state fixes.","subsystem":"gateway","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":25778},"resolution":{"state":"partially_resolved","by":"pr-25778"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T11:13:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14303030303030304},"fix_edges":[{"issue_number":25567,"pr_number":25778,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":38475,"pr_number":54160,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":25567,"title":"Bug: clarify multiple-choice text fallback never intercepts user reply on Mattermost","url":null,"state":"closed"},{"kind":"issue","number":26009,"title":"[Bug] clarify tool hangs indefinitely with 'Still working...' on Discord (choice-based clarifies not intercepted)","url":null,"state":"closed"},{"kind":"issue","number":38475,"title":"Gateway clarify prompt ignores typed freeform replies while awaiting response","url":null,"state":"closed"},{"kind":"issue","number":47566,"title":"clarify: typed replies to button prompts fall through instead of resolving the clarify — intended?","url":null,"state":"closed"},{"kind":"pr","number":25587,"title":"fix(gateway): enable text-intercept for multi-choice clarify fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#25778","number":25778}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":25778,"title":"fix(gateway): enable text-intercept for multi-choice clarify fallback (#25587)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","tests/tools/test_clarify_gateway.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1774,8 +1774,12 @@ async def send_clarify(\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+        and resolves.  For the text fallback path, the default calls\n+        ``mark_awaiting_text()`` so that the gateway text-intercept\n+        (:meth:`GatewayRunner._maybe_intercept_clarify_text`) catches the\n+        user's reply instead of timing out.\n+        Adapters with native button UIs (Telegram, Discord) SHOULD\n+        override this for a richer UX.\n         \"\"\"\n         if choices:\n             lines = [f\"❓ {question}\", \"\"]","@@ -1784,6 +1788,10 @@ async def send_clarify(\n             lines.append(\"\")\n             lines.append(\"Reply with the number, the option text, or your own answer.\")\n             text = \"\\n\".join(lines)\n+            # Text fallback: enable text-capture so the gateway intercept\n+            # picks up the user's typed reply (e.g. \"2\" or choice text).\n+            from tools.clarify_gateway import mark_awaiting_text\n+            mark_awaiting_text(clarify_id)\n         else:\n             text = f\"❓ {question}\"\n         return await self.send("]},{"file":"tests/tools/test_clarify_gateway.py","hunks":["@@ -205,3 +205,23 @@ def test_get_pending_for_session_returns_oldest_text_awaiting(self):\n         pending2 = cm.get_pending_for_session(\"sk\")\n         assert pending2 is not None\n         assert pending2.clarify_id == \"first\"\n+    def test_text_fallback_enables_awaiting_text_for_multi_choice(self):\n+        \"\"\"When base send_clarify renders choices as text, mark_awaiting_text\n+        is called so the gateway text-intercept can capture the reply.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        entry = cm.register(\"id-tf\", \"sk-tf\", \"Pick one\", [\"A\", \"B\", \"C\"])\n+        # Initially, multi-choice does NOT await text (button path)\n+        assert entry.awaiting_text is False\n+\n+        # After the base send_clarify text fallback calls mark_awaiting_text:\n+        flipped = cm.mark_awaiting_text(\"id-tf\")\n+        assert flipped is True\n+\n+        # Now get_pending_for_session should find it\n+        pending = cm.get_pending_for_session(\"sk-tf\")\n+        assert pending is not None\n+        assert pending.clarify_id == \"id-tf\"\n+        \n+        # Clean up\n+        cm.clear_session(\"sk-tf\")\n"]}]},{"kind":"pr","number":38484,"title":"fix: accept typed clarify choice replies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d7f655f370e2bea177dfedf19f3b261d13c986e2","number":0},{"kind":"pr","value":"#54160","number":54160},{"kind":"pr","value":"#38484","number":38484}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":38544,"title":"fix(gateway): intercept freeform text replies to multi-choice clarify prompts #38475","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54160","number":54160},{"kind":"pr","value":"#38475","number":38475},{"kind":"pr","value":"#54160","number":54160}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":41353,"title":"fix(discord): render clarify choices as Select menu, not truncated buttons","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":47603,"title":"fix(gateway): resolve button-prompt clarifies via typed replies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47566","number":47566}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":47613,"title":"fix(telegram): intercept typed replies to button clarify prompts (#47566)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54160,"title":"fix(gateway): accept typed replies to multi-choice clarify prompts (#38475)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/run.py","infographic/clarify-typed-replies/infographic.png","tests/gateway/test_clarify_active_session_bypass.py","tests/tools/test_clarify_gateway.py","tools/clarify_gateway.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -4410,11 +4410,11 @@ 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+            # Clarify reply bypass: if the agent is blocked on a\n+            # clarify_tool call, the next non-command message in this\n+            # session MUST reach the runner so typed numeric choices,\n+            # exact choices, and free-form \"Other\" answers can resolve the\n+            # clarify-intercept and unblock the agent.\n             #\n             # Without this bypass: the message gets queued in\n             # _pending_messages as a follow-up turn instead of reaching the","@@ -4427,7 +4427,10 @@ async def handle_message(self, event: MessageEvent) -> None:\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+                        _clarify_mod.get_pending_for_session(\n+                            session_key,\n+                            include_choice_prompts=True,\n+                        ) is not None\n                     )\n                 except Exception:\n                     _has_text_clarify = False"]},{"file":"gateway/run.py","hunks":["@@ -8037,26 +8037,28 @@ 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+        # Intercept messages that are responses to a pending clarify.\n+        # Open-ended prompts and \"Other\" responses are captured as free text;\n+        # direct replies to multi-choice prompts are accepted too (\"2\" maps\n+        # to the second option, arbitrary text becomes a custom answer). Slash\n+        # commands still bypass this path so /stop and friends keep working.\n+        _clarify_mod = None\n         try:\n             from tools import clarify_gateway as _clarify_mod\n-            _pending_clarify = _clarify_mod.get_pending_for_session(_quick_key)\n+            _pending_clarify = _clarify_mod.get_pending_for_session(\n+                _quick_key, include_choice_prompts=True,\n+            )\n         except Exception:\n             _pending_clarify = None\n-        if _pending_clarify is not None:\n+        if _pending_clarify is not None and _clarify_mod 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+                _resolved = _clarify_mod.resolve_text_response_for_session(\n+                    _quick_key, _raw_clarify_reply,\n                 )\n                 if _resolved:\n                     logger.info("]},{"file":"tests/gateway/test_clarify_active_session_bypass.py","hunks":["@@ -0,0 +1,87 @@\n+\"\"\"Regression tests for clarify replies while a gateway session is busy.\"\"\"\n+\n+import asyncio\n+from unittest.mock import AsyncMock\n+\n+import pytest\n+\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import (\n+    BasePlatformAdapter,\n+    MessageEvent,\n+    MessageType,\n+    SendResult,\n+)\n+from gateway.session import SessionSource, build_session_key\n+\n+\n+class _ClarifyBypassAdapter(BasePlatformAdapter):\n+    def __init__(self):\n+        super().__init__(PlatformConfig(enabled=True, token=\"test\"), Platform.TELEGRAM)\n+\n+    async def connect(self):\n+        return True\n+\n+    async def disconnect(self):\n+        pass\n+\n+    async def send(self, chat_id, content, reply_to=None, metadata=None):\n+        return SendResult(success=True, message_id=\"text\")\n+\n+    async def get_chat_info(self, chat_id):\n+        return {\"id\": chat_id, \"type\": \"private\"}\n+\n+\n+def _event(text=\"custom answer\"):\n+    return MessageEvent(\n+        text=text,\n+        message_type=MessageType.TEXT,\n+        source=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=\"12345\",\n+            chat_type=\"private\",\n+            user_id=\"user1\",\n+        ),\n+        message_id=\"msg1\",\n+    )\n+\n+\n+def _clear_clarify_state():\n+    from tools import clarify_gateway as cm\n+\n+    with cm._lock:\n+        cm._entries.clear()\n+        cm._session_index.clear()\n+        cm._notify_cbs.clear()\n+\n+\n[redacted:email]\n+async def test_active_session_routes_typed_choice_clarify_reply_to_runner_not_busy_queue():\n+    \"\"\"Typed text must resolve a pending choice clarify even while the agent is busy.\n+\n+    Telegram button clarifies keep the adapter session active while the agent\n+    thread blocks on ``wait_for_response``.  If the adapter only bypasses for\n+    entries already marked ``awaiting_text``, typed replies to the visible\n+    multi-choice prompt are handled as busy follow-ups and the clarify wait is\n+    never resolved.\n+    \"\"\"\n+    _clear_clarify_state()\n+    from tools import clarify_gateway as cm\n+\n+    adapter = _ClarifyBypassAdapter()\n+    adapter._message_handler = AsyncMock(return_value=\"\")\n+    adapter._busy_session_handler = AsyncMock(return_value=True)\n+    event = _event(\"None of those are valid options\")\n+    session_key = build_session_key(\n+        event.source,\n+        group_sessions_per_user=adapter.config.extra.get(\"group_sessions_per_user\", True),\n+        thread_sessions_per_user=adapter.config.extra.get(\"thread_sessions_per_user\", False),\n+    )\n+    adapter._active_sessions[session_key] = asyncio.Event()\n+    cm.register(\"clarify-1\", session_key, \"Pick one\", [\"A\", \"B\"])\n+\n+    await adapter.handle_message(event)\n+\n+    adapter._message_handler.assert_awaited_once_with(event)\n+    adapter._busy_session_handler.assert_not_awaited()\n+    assert adapter._pending_messages == {}"]},{"file":"tests/tools/test_clarify_gateway.py","hunks":["@@ -64,6 +64,32 @@ def test_button_choice_does_not_auto_await(self):\n         assert entry.awaiting_text is False\n         assert cm.get_pending_for_session(\"sk3\") is None\n \n+    def test_include_choice_prompts_returns_multi_choice_entry(self):\n+        \"\"\"Gateway typed replies must see active choice prompts too.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id3b\", \"sk3b\", \"Pick\", [\"X\", \"Y\"])\n+        pending = cm.get_pending_for_session(\"sk3b\", include_choice_prompts=True)\n+        assert pending is not None\n+        assert pending.clarify_id == \"id3b\"\n+\n+    def test_resolve_text_response_maps_numeric_choice(self):\n+        \"\"\"Typed numbers should resolve to the canonical choice string.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id3c\", \"sk3c\", \"Pick\", [\"X\", \"Y\"])\n+        assert cm.resolve_text_response_for_session(\"sk3c\", \"2\") is True\n+        assert cm.wait_for_response(\"id3c\", timeout=0.1) == \"Y\"\n+\n+    def test_resolve_text_response_accepts_custom_other_text(self):\n+        \"\"\"Arbitrary typed text should resolve as a custom Other answer.\"\"\"\n+        from tools import clarify_gateway as cm\n+\n+        cm.register(\"id3d\", \"sk3d\", \"Pick\", [\"X\", \"Y\"])\n+        custom = \"None of those are valid options\"\n+        assert cm.resolve_text_response_for_session(\"sk3d\", custom) is True\n+        assert cm.wait_for_response(\"id3d\", timeout=0.1) == custom\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"]},{"file":"tools/clarify_gateway.py","hunks":["@@ -162,12 +162,19 @@ def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:\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+def get_pending_for_session(\n+    session_key: str,\n+    *,\n+    include_choice_prompts: bool = False,\n+) -> Optional[_ClarifyEntry]:\n+    \"\"\"Return the oldest pending clarify entry for a session, or None.\n+\n+    By default this only returns entries awaiting free-form text (open-ended\n+    clarifies, or a multi-choice clarify after the user picked ``Other``).\n+    Gateways may pass ``include_choice_prompts=True`` when the user has typed\n+    directly in response to an active multi-choice prompt; in that case the\n+    oldest unresolved clarify is returned so the text can resolve it instead\n+    of being queued as an unrelated follow-up turn.\n     \"\"\"\n     with _lock:\n         ids = _session_index.get(session_key) or []","@@ -175,11 +182,38 @@ def get_pending_for_session(session_key: str) -> Optional[_ClarifyEntry]:\n             entry = _entries.get(cid)\n             if entry is None:\n                 continue\n-            if entry.awaiting_text:\n+            if include_choice_prompts or entry.awaiting_text:\n                 return entry\n         return None\n \n \n+def _coerce_text_response(entry: _ClarifyEntry, response: str) -> str:\n+    \"\"\"Map typed choice replies to canonical choice text, otherwise keep custom text.\"\"\"\n+    text = str(response).strip()\n+    if entry.choices:\n+        try:\n+            idx = int(text) - 1\n+        except ValueError:\n+            idx = -1\n+        if 0 <= idx < len(entry.choices):\n+            return entry.choices[idx]\n+        for choice in entry.choices:\n+            if text.casefold() == str(choice).strip().casefold():\n+                return str(choice).strip()\n+    return text\n+\n+\n+def resolve_text_response_for_session(session_key: str, response: str) -> bool:\n+    \"\"\"Resolve the oldest pending clarify in ``session_key`` from typed text.\"\"\"\n+    entry = get_pending_for_session(session_key, include_choice_prompts=True)\n+    if entry is None:\n+        return False\n+    return resolve_gateway_clarify(\n+        entry.clarify_id,\n+        _coerce_text_response(entry, response),\n+    )\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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}