# NousResearch/hermes-agent · Clarify prompt typed-reply interception across platforms

## Open now

- Nothing open on GitHub.

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.

## TL;DR

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

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-25778`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.14`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #25567: Bug: clarify multiple-choice text fallback never intercepts user reply on Mattermost
- #26009: \[Bug\] clarify tool hangs indefinitely with 'Still working...' on Discord (choice-based clarifies not intercepted)
- #38475: Gateway clarify prompt ignores typed freeform replies while awaiting response
- #47566: clarify: typed replies to button prompts fall through instead of resolving the clarify — intended?

## Prior & competing PRs

- **#25778: fix(gateway): enable text-intercept for multi-choice clarify fallback (#25587)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/platforms/base.py`, `tests/tools/test_clarify_gateway.py`
  - fix hunks:
  - `gateway/platforms/base.py`

    ```diff
    @@ -1774,8 +1774,12 @@ async def send_clarify(
             The default implementation falls back to a numbered text list,
             which works on every platform — the user replies with a number
             ("2") or with the literal choice text, and the gateway intercepts
    -        and resolves.  Adapters with native button UIs (Telegram, Discord)
    -        SHOULD override this for a richer UX.
    +        and resolves.  For the text fallback path, the default calls
    +        ``mark_awaiting_text()`` so that the gateway text-intercept
    +        (:meth:`GatewayRunner._maybe_intercept_clarify_text`) catches the
    +        user's reply instead of timing out.
    +        Adapters with native button UIs (Telegram, Discord) SHOULD
    +        override this for a richer UX.
             """
             if choices:
                 lines = [f"❓ {question}", ""]
    ```

    ```diff
    @@ -1784,6 +1788,10 @@ async def send_clarify(
                 lines.append("")
                 lines.append("Reply with the number, the option text, or your own answer.")
                 text = "\n".join(lines)
    +            # Text fallback: enable text-capture so the gateway intercept
    +            # picks up the user's typed reply (e.g. "2" or choice text).
    +            from tools.clarify_gateway import mark_awaiting_text
    +            mark_awaiting_text(clarify_id)
             else:
                 text = f"❓ {question}"
             return await self.send(
    ```
  - `tests/tools/test_clarify_gateway.py`

    ```diff
    @@ -205,3 +205,23 @@ def test_get_pending_for_session_returns_oldest_text_awaiting(self):
             pending2 = cm.get_pending_for_session("sk")
             assert pending2 is not None
             assert pending2.clarify_id == "first"
    +    def test_text_fallback_enables_awaiting_text_for_multi_choice(self):
    +        """When base send_clarify renders choices as text, mark_awaiting_text
    +        is called so the gateway text-intercept can capture the reply."""
    +        from tools import clarify_gateway as cm
    +
    +        entry = cm.register("id-tf", "sk-tf", "Pick one", ["A", "B", "C"])
    +        # Initially, multi-choice does NOT await text (button path)
    +        assert entry.awaiting_text is False
    +
    +        # After the base send_clarify text fallback calls mark_awaiting_text:
    +        flipped = cm.mark_awaiting_text("id-tf")
    +        assert flipped is True
    +
    +        # Now get_pending_for_session should find it
    +        pending = cm.get_pending_for_session("sk-tf")
    +        assert pending is not None
    +        assert pending.clarify_id == "id-tf"
    +        
    +        # Clean up
    +        cm.clear_session("sk-tf")
    ```
- **#38484: fix: accept typed clarify choice replies**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `d7f655f370e2bea177dfedf19f3b261d13c986e2`, `#54160`, `#38484`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#41353: fix(discord): render clarify choices as Select menu, not truncated buttons**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#47603: fix(gateway): resolve button-prompt clarifies via typed replies**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#47566`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#54160: fix(gateway): accept typed replies to multi-choice clarify prompts (#38475)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `gateway/platforms/base.py`

    ```diff
    @@ -4410,11 +4410,11 @@ async def handle_message(self, event: MessageEvent) -> None:
                         logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
                     return
     
    -            # Clarify text-capture bypass: if the agent is blocked on a
    -            # clarify_tool call awaiting a free-form text response (open-
    -            # ended clarify, or user picked "Other"), the next non-command
    -            # message in this session MUST reach the runner so the
    -            # clarify-intercept can resolve it and unblock the agent.
    +            # Clarify reply bypass: if the agent is blocked on a
    +            # clarify_tool call, the next non-command message in this
    +            # session MUST reach the runner so typed numeric choices,
    +            # exact choices, and free-form "Other" answers can resolve the
    +            # clarify-intercept and unblock the agent.
                 #
                 # Without this bypass: the message gets queued in
                 # _pending_messages as a follow-up turn instead of reaching the
    ```

    ```diff
    @@ -4427,7 +4427,10 @@ async def handle_message(self, event: MessageEvent) -> None:
                     try:
                         from tools import clarify_gateway as _clarify_mod
                         _has_text_clarify = (
    -                        _clarify_mod.get_pending_for_session(session_key) is not None
    +                        _clarify_mod.get_pending_for_session(
    +                            session_key,
    +                            include_choice_prompts=True,
    +                        ) is not None
                         )
                     except Exception:
                         _has_text_clarify = False
    ```
  - `gateway/run.py`

    ```diff
    @@ -8037,26 +8037,28 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:
                         )
                     _update_prompts.pop(_quick_key, None)
     
    -        # Intercept messages that are responses to a pending clarify
    -        # request that is awaiting free-form text (either an open-ended
    -        # clarify with no choices, or one where the user picked the
    -        # "Other" button).  The first non-empty user message in the
    -        # session resolves the clarify and unblocks the agent thread —
    -        # we do NOT route it to the agent as a new turn.
    +        # Intercept messages that are responses to a pending clarify.
    +        # Open-ended prompts and "Other" responses are captured as free text;
    +        # direct replies to multi-choice prompts are accepted too ("2" maps
    +        # to the second option, arbitrary text becomes a custom answer). Slash
    +        # commands still bypass this path so /stop and friends keep working.
    +        _clarify_mod = None
             try:
                 from tools import clarify_gateway as _clarify_mod
    -            _pending_clarify = _clarify_mod.get_pending_for_session(_quick_key)
    +            _pending_clarify = _clarify_mod.get_pending_for_session(
    +                _quick_key, include_choice_prompts=True,
    +            )
             except Exception:
                 _pending_clarify = None
    -        if _pending_clarify is not None:
    +        if _pending_clarify is not None and _clarify_mod is not None:
                 _raw_clarify_reply = (event.text or "").strip()
                 # Skip slash commands — the user clearly wanted to issue a
                 # command, not answer the clarify.  Leave the clarify pending
                 # so the user can retry; if it times out, the agent unblocks
                 # with an empty response.
                 if _raw_clarify_reply and not _raw_clarify_reply.startswith("/"):
    -                _resolved = _clarify_mod.resolve_gateway_clarify(
    -                    _pending_clarify.clarify_id, _raw_clarify_reply,
    +                _resolved = _clarify_mod.resolve_text_response_for_session(
    +                    _quick_key, _raw_clarify_reply,
                     )
                     if _resolved:
                         logger.info(
    ```
  - `tests/gateway/test_clarify_active_session_bypass.py`

    ```diff
    @@ -0,0 +1,87 @@
    +"""Regression tests for clarify replies while a gateway session is busy."""
    +
    +import asyncio
    +from unittest.mock import AsyncMock
    +
    +import pytest
    +
    +from gateway.config import Platform, PlatformConfig
    +from gateway.platforms.base import (
    +    BasePlatformAdapter,
    +    MessageEvent,
    +    MessageType,
    +    SendResult,
    +)
    +from gateway.session import SessionSource, build_session_key
    +
    +
    +class _ClarifyBypassAdapter(BasePlatformAdapter):
    +    def __init__(self):
    +        super().__init__(PlatformConfig(enabled=True, token="test"), Platform.TELEGRAM)
    +
    +    async def connect(self):
    +        return True
    +
    +    async def disconnect(self):
    +        pass
    +
    +    async def send(self, chat_id, content, reply_to=None, metadata=None):
    +        return SendResult(success=True, message_id="text")
    +
    +    async def get_chat_info(self, chat_id):
    +        return {"id": chat_id, "type": "private"}
    +
    +
    +def _event(text="custom answer"):
    +    return MessageEvent(
    +        text=text,
    +        message_type=MessageType.TEXT,
    +        source=SessionSource(
    +            platform=Platform.TELEGRAM,
    +            chat_id="12345",
    +            chat_type="private",
    +            user_id="user1",
    +        ),
    +        message_id="msg1",
    +    )
    +
    +
    +def _clear_clarify_state():
    +    from tools import clarify_gateway as cm
    +
    +    with cm._lock:
    +        cm._entries.clear()
    +        cm._session_index.clear()
    +        cm._notify_cbs.clear()
    +
    +
    [redacted:email]
    +async def test_active_session_routes_typed_choice_clarify_reply_to_runner_not_busy_queue():
    +    """Typed text must resolve a pending choice clarify even while the agent is busy.
    +
    +    Telegram button clarifies keep the adapter session active while the agent
    +    thread blocks on ``wait_for_response``.  If the adapter only bypasses for
    +    entries already marked ``awaiting_text``, typed replies to the visible
    +    multi-choice prompt are handled as busy follow-ups and the clarify wait is
    +    never resolved.
    +    """
    +    _clear_clarify_state()
    +    from tools import clarify_gateway as cm
    +
    +    adapter = _ClarifyBypassAdapter()
    +    adapter._message_handler = AsyncMock(return_value="")
    +    adapter._busy_session_handler = AsyncMock(return_value=True)
    +    event = _event("None of those are valid options")
    +    session_key = build_session_key(
    +        event.source,
    +        group_sessions_per_user=adapter.config.extra.get("group_sessions_per_user", True),
    +        thread_sessions_per_user=adapter.config.extra.get("thread_sessions_per_user", False),
    +    )
    +    adapter._active_sessions[session_key] = asyncio.Event()
    +    cm.register("clarify-1", session_key, "Pick one", ["A", "B"])
    +
    +    await adapter.handle_message(event)
    +
    +    adapter._message_handler.assert_awaited_once_with(event)
    +    adapter._busy_session_handler.assert_not_awaited()
    +    assert adapter._pending_messages == {}
    ```
  - `tests/tools/test_clarify_gateway.py`

    ```diff
    @@ -64,6 +64,32 @@ def test_button_choice_does_not_auto_await(self):
             assert entry.awaiting_text is False
             assert cm.get_pending_for_session("sk3") is None
     
    +    def test_include_choice_prompts_returns_multi_choice_entry(self):
    +        """Gateway typed replies must see active choice prompts too."""
    +        from tools import clarify_gateway as cm
    +
    +        cm.register("id3b", "sk3b", "Pick", ["X", "Y"])
    +        pending = cm.get_pending_for_session("sk3b", include_choice_prompts=True)
    +        assert pending is not None
    +        assert pending.clarify_id == "id3b"
    +
    +    def test_resolve_text_response_maps_numeric_choice(self):
    +        """Typed numbers should resolve to the canonical choice string."""
    +        from tools import clarify_gateway as cm
    +
    +        cm.register("id3c", "sk3c", "Pick", ["X", "Y"])
    +        assert cm.resolve_text_response_for_session("sk3c", "2") is True
    +        assert cm.wait_for_response("id3c", timeout=0.1) == "Y"
    +
    +    def test_resolve_text_response_accepts_custom_other_text(self):
    +        """Arbitrary typed text should resolve as a custom Other answer."""
    +        from tools import clarify_gateway as cm
    +
    +        cm.register("id3d", "sk3d", "Pick", ["X", "Y"])
    +        custom = "None of those are valid options"
    +        assert cm.resolve_text_response_for_session("sk3d", custom) is True
    +        assert cm.wait_for_response("id3d", timeout=0.1) == custom
    +
         def test_other_button_flips_to_text_mode(self):
             """mark_awaiting_text makes get_pending_for_session find the entry."""
             from tools import clarify_gateway as cm
    ```
  - `tools/clarify_gateway.py`

    ```diff
    @@ -162,12 +162,19 @@ def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:
         return True
     
     
    -def get_pending_for_session(session_key: str) -> Optional[_ClarifyEntry]:
    -    """Return the OLDEST pending clarify entry for a session, or None.
    -
    -    Used by the text-fallback intercept in ``_handle_message`` — when a
    -    clarify is awaiting a free-form text response, the next user message
    -    in that session is captured as the answer.
    +def get_pending_for_session(
    +    session_key: str,
    +    *,
    +    include_choice_prompts: bool = False,
    +) -> Optional[_ClarifyEntry]:
    +    """Return the oldest pending clarify entry for a session, or None.
    +
    +    By default this only returns entries awaiting free-form text (open-ended
    +    clarifies, or a multi-choice clarify after the user picked ``Other``).
    +    Gateways may pass ``include_choice_prompts=True`` when the user has typed
    +    directly in response to an active multi-choice prompt; in that case the
    +    oldest unresolved clarify is returned so the text can resolve it instead
    +    of being queued as an unrelated follow-up turn.
         """
         with _lock:
             ids = _session_index.get(session_key) or []
    ```

    ```diff
    @@ -175,11 +182,38 @@ def get_pending_for_session(session_key: str) -> Optional[_ClarifyEntry]:
                 entry = _entries.get(cid)
                 if entry is None:
                     continue
    -            if entry.awaiting_text:
    +            if include_choice_prompts or entry.awaiting_text:
                     return entry
             return None
     
     
    +def _coerce_text_response(entry: _ClarifyEntry, response: str) -> str:
    +    """Map typed choice replies to canonical choice text, otherwise keep custom text."""
    +    text = str(response).strip()
    +    if entry.choices:
    +        try:
    +            idx = int(text) - 1
    +        except ValueError:
    +            idx = -1
    +        if 0 <= idx < len(entry.choices):
    +            return entry.choices[idx]
    +        for choice in entry.choices:
    +            if text.casefold() == str(choice).strip().casefold():
    +                return str(choice).strip()
    +    return text
    +
    +
    +def resolve_text_response_for_session(session_key: str, response: str) -> bool:
    +    """Resolve the oldest pending clarify in ``session_key`` from typed text."""
    +    entry = get_pending_for_session(session_key, include_choice_prompts=True)
    +    if entry is None:
    +        return False
    +    return resolve_gateway_clarify(
    +        entry.clarify_id,
    +        _coerce_text_response(entry, response),
    +    )
    +
    +
     def mark_awaiting_text(clarify_id: str) -> bool:
         """Flip an entry into text-capture mode (user picked the 'Other' button).
     
    ```
- **#25587: fix(gateway): enable text-intercept for multi-choice clarify fallback**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#25778`
- **#38544: fix(gateway): intercept freeform text replies to multi-choice clarify prompts #38475**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#54160`, `#38475`, `#54160`
- **#47613: fix(telegram): intercept typed replies to button clarify prompts (#47566)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`

## Fix status

- Merged to main in PR #25778 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-28`.
- Subsystem: `gateway`
