{"repo":"NousResearch/hermes-agent","public_id":"19111","display_label":"Render clarify choices as Discord buttons","summary":"These issues and pull requests all concern implementing and fixing the rendering of clarify multiple-choice questions as interactive Discord buttons, consistent with how permission prompts are displayed.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":25485},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-14T14:26:44+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":19111,"pr_number":25485,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":19111,"title":"Discord: clarify tool should render as buttons like permission prompts do","url":null,"state":"closed"},{"kind":"issue","number":28811,"title":"Render \"clarify\" multiple-choice questions as Discord buttons","url":null,"state":"closed"},{"kind":"pr","number":19249,"title":"fix(discord): render clarify choices as buttons","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1dca6a6960f87b07a7d270893ac35211c97913c8","number":0},{"kind":"pr","value":"#25485","number":25485}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":25485,"title":"feat(discord): render clarify choices as buttons","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","tests/gateway/conftest.py","tests/gateway/test_discord_clarify_buttons.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -3896,6 +3896,84 @@ async def send_slash_confirm(\n         except Exception as 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 Discord button per choice.\n+\n+        Multi-choice mode (``choices`` non-empty): renders a button per option\n+        plus a final \"✏️ Other (type answer)\" button. Picking \"Other\" flips\n+        the clarify entry into text-capture mode so the next user message in\n+        the session becomes the response. Numeric clicks resolve immediately\n+        via ``resolve_gateway_clarify(clarify_id, choice_text)``.\n+\n+        Open-ended mode (``choices`` empty/None): renders the question as\n+        plain embed text — no buttons. The gateway's text-intercept captures\n+        the next message in this session and resolves the clarify.\n+        \"\"\"\n+        if not self._client or not DISCORD_AVAILABLE:\n+            return SendResult(success=False, error=\"Not connected\")\n+\n+        try:\n+            target_id = chat_id\n+            if metadata and metadata.get(\"thread_id\"):\n+                target_id = metadata[\"thread_id\"]\n+\n+            channel = self._client.get_channel(int(target_id))\n+            if not channel:\n+                channel = await self._client.fetch_channel(int(target_id))\n+\n+            # Discord embed description limit is 4096; trim conservatively.\n+            max_desc = 4088\n+            body = str(question or \"\").strip()\n+            if len(body) > max_desc:\n+                body = body[: max_desc - 3] + \"...\"\n+\n+            embed = discord.Embed(\n+                title=\"❓ Hermes needs your input\",\n+                description=body,\n+                color=discord.Color.orange(),\n+            )\n+\n+            clean_choices = [\n+                str(c).strip() for c in (choices or []) if c is not None and str(c).strip()\n+            ]\n+            # Discord allows up to 5 buttons per row, 5 rows per view = 25.\n+            # We reserve one slot for the \"Other\" button, so cap at 24 choices.\n+            clean_choices = clean_choices[:24]\n+\n+            if clean_choices:\n+                embed.add_field(\n+                    name=\"Choices\",\n+                    value=\"Pick one below, or click ✏️ Other to type a custom answer.\",\n+                    inline=False,\n+                )\n+                view = ClarifyChoiceView(\n+                    choices=clean_choices,\n+                    clarify_id=clarify_id,\n+                    allowed_user_ids=self._allowed_user_ids,\n+                    allowed_role_ids=self._allowed_role_ids,\n+                )\n+            else:\n+                embed.add_field(\n+                    name=\"Reply\",\n+                    value=\"Reply in this channel with your answer.\",\n+                    inline=False,\n+                )\n+                view = None\n+\n+            msg = await channel.send(embed=embed, view=view) if view else await channel.send(embed=embed)\n+            return SendResult(success=True, message_id=str(msg.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_update_prompt(\n         self, chat_id: str, prompt: str, default: str = \"\",\n         session_key: str = \"\",","@@ -5125,3 +5203,188 @@ async def _on_cancel(self, interaction: discord.Interaction):\n         async def on_timeout(self):\n             self.resolved = True\n             self.clear_items()\n+\n+\n+    class ClarifyChoiceView(discord.ui.View):\n+        \"\"\"Interactive button view for the clarify tool's multiple-choice prompts.\n+\n+        Renders one button per choice (max 24) plus a final ``✏️ Other`` button.\n+        Picking a numeric choice resolves the gateway clarify entry immediately;\n+        picking ``Other`` flips the entry into text-capture mode so the next\n+        user message in the session becomes the response (the gateway's\n+        text-intercept handles the resolution).\n+\n+        Auth gating mirrors ``ExecApprovalView`` — only users/roles in the\n+        Discord adapter's allowlist may answer. Single-use: after the first\n+        valid click all buttons disable and the embed updates to show who\n+        answered and what they chose.\n+        \"\"\"\n+\n+        def __init__(\n+            self,\n+            choices: List[str],\n+            clarify_id: str,\n+            allowed_user_ids: set,\n+            allowed_role_ids: Optional[set] = None,\n+        ):\n+            super().__init__(timeout=300)  # 5-minute timeout\n+            self.choices = list(choices)[:24]\n+            self.clarify_id = clarify_id\n+            self.allowed_user_ids = allowed_user_ids\n+            self.allowed_role_ids = allowed_role_ids or set()\n+            self.resolved = False\n+\n+            for index, choice in enumerate(self.choices):\n+                # Discord button labels are capped at 80 chars.\n+                label_body = choice if len(choice) <= 75 else choice[:72] + \"...\"\n+                button = discord.ui.Button(\n+                    label=f\"{index + 1}. {label_body}\",\n+                    style=discord.ButtonStyle.primary,\n+                    custom_id=f\"clarify:{clarify_id}:{index}\",\n+                )\n+                button.callback = self._make_choice_callback(index, choice)\n+                self.add_item(button)\n+\n+            other_btn = discord.ui.Button(\n+                label=\"✏️ Other (type answer)\",\n+                style=discord.ButtonStyle.secondary,\n+                custom_id=f\"clarify:{clarify_id}:other\",\n+            )\n+            other_btn.callback = self._on_other\n+            self.add_item(other_btn)\n+\n+        def _check_auth(self, interaction: \"discord.Interaction\") -> bool:\n+            return _component_check_auth(\n+                interaction, self.allowed_user_ids, self.allowed_role_ids,\n+            )\n+\n+        def _make_choice_callback(self, index: int, choice: str):\n+            async def _callback(interaction: \"discord.Interaction\"):\n+                await self._resolve_choice(interaction, index, choice)\n+            return _callback\n+\n+        async def _resolve_choice(\n+            self,\n+            interaction: \"discord.Interaction\",\n+            index: int,\n+            choice: str,\n+        ) -> None:\n+            \"\"\"Resolve the clarify with a chosen option.\"\"\"\n+            if self.resolved:\n+                await interaction.response.send_message(\n+                    \"This prompt has already been answered~\", ephemeral=True,\n+                )\n+                return\n+            if not self._check_auth(interaction):\n+                await interaction.response.send_message(\n+                    \"You're not authorized to answer this prompt~\", ephemeral=True,\n+                )\n+                return\n+\n+            self.resolved = True\n+            for child in self.children:\n+                child.disabled = True\n+\n+            embed = interaction.message.embeds[0] if (\n+                interaction.message and interaction.message.embeds\n+            ) else None\n+            if embed:\n+                user = getattr(interaction, \"user\", None)\n+                display_name = getattr(user, \"display_name\", \"user\")\n+                embed.color = discord.Color.green()\n+                embed.set_footer(text=f\"Answered by {display_name}: {choice}\")\n+\n+            try:\n+                await interaction.response.edit_message(embed=embed, view=self)\n+            except Exception:\n+                logger.debug(\n+                    \"Discord clarify edit_message failed for %s\",\n+                    self.clarify_id,\n+                    exc_info=True,\n+                )\n+                try:\n+                    await interaction.response.defer()\n+                except Exception:\n+                    pass\n+\n+            # Resolve via the gateway clarify primitive — same mechanism as\n+            # Telegram. Look up the canonical choice text from the entry so\n+            # we round-trip the original value, not a button-label variant.\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(self.clarify_id)\n+                if entry and entry.choices and 0 <= index < len(entry.choices):\n+                    resolved_text = entry.choices[index]\n+            except Exception:\n+                resolved_text = None\n+            if resolved_text is None:\n+                resolved_text = choice\n+\n+            try:\n+                from tools.clarify_gateway import resolve_gateway_clarify\n+                resolved = resolve_gateway_clarify(self.clarify_id, resolved_text)\n+                logger.info(\n+                    \"Discord clarify button resolved (id=%s, choice=%r, user=%s, ok=%s)\",\n+                    self.clarify_id, resolved_text,\n+                    getattr(getattr(interaction, \"user\", None), \"display_name\", \"?\"),\n+                    resolved,\n+                )\n+            except Exception as exc:\n+                logger.error(\n+                    \"Discord clarify resolve_gateway_clarify failed (id=%s): %s\",\n+                    self.clarify_id, exc,\n+                )\n+\n+        async def _on_other(self, interaction: \"discord.Interaction\") -> None:\n+            \"\"\"Flip the clarify entry into text-capture mode.\"\"\"\n+            if self.resolved:\n+                await interaction.response.send_message(\n+                    \"This prompt has already been answered~\", ephemeral=True,\n+                )\n+                return\n+            if not self._check_auth(interaction):\n+                await interaction.response.send_message(\n+                    \"You're not authorized to answer this prompt~\", ephemeral=True,\n+                )\n+                return\n+\n+            # Don't pop the entry — the gateway's text-intercept needs it\n+            # until the user actually types. Just mark it as awaiting text\n+            # and disable the buttons so the user can't double-click.\n+            try:\n+                from tools.clarify_gateway import mark_awaiting_text\n+                mark_awaiting_text(self.clarify_id)\n+            except Exception as exc:\n+                logger.warning(\n+                    \"Discord clarify mark_awaiting_text failed (id=%s): %s\",\n+                    self.clarify_id, exc,\n+                )\n+\n+            self.resolved = True\n+            for child in self.children:\n+                child.disabled = True\n+\n+            embed = interaction.message.embeds[0] if (\n+                interaction.message and interaction.message.embeds\n+            ) else None\n+            if embed:\n+                user = getattr(interaction, \"user\", None)\n+                display_name = getattr(user, \"display_name\", \"user\")\n+                embed.color = discord.Color.blue()\n+                embed.set_footer(\n+                    text=f\"Awaiting typed response from {display_name}…\",\n+                )\n+\n+            try:\n+                await interaction.response.edit_message(embed=embed, view=self)\n+            except Exception:\n+                try:\n+                    await interaction.response.defer()\n+                except Exception:\n+                    pass\n+\n+        async def on_timeout(self):\n+            self.resolved = True\n+            for child in self.children:\n+                child.disabled = True"]},{"file":"tests/gateway/conftest.py","hunks":["@@ -119,6 +119,14 @@ def __init__(self, *, title=None, description=None, color=None, **_):\n             self.title = title\n             self.description = description\n             self.color = color\n+            self.fields = []\n+            self.footer = None\n+        def add_field(self, *, name=None, value=None, inline=False, **_):\n+            self.fields.append({\"name\": name, \"value\": value, \"inline\": inline})\n+            return self\n+        def set_footer(self, *, text=None, icon_url=None, **_):\n+            self.footer = {\"text\": text, \"icon_url\": icon_url}\n+            return self\n     discord_mod.Embed = _FakeEmbed\n \n     # ui.View / ui.Select / ui.Button: real classes (not MagicMock) so"]},{"file":"tests/gateway/test_discord_clarify_buttons.py","hunks":["@@ -0,0 +1,408 @@\n+\"\"\"Tests for Discord clarify button rendering and resolution.\n+\n+Mirrors test_telegram_clarify_buttons.py for the Discord ``send_clarify``\n+override and the ``ClarifyChoiceView`` callbacks. Discord uses ``discord.ui.View``\n+button callbacks (closures) rather than a string-prefixed callback_query\n+dispatcher like Telegram — the auth + resolution path is the same:\n+\n+  · numeric choice → resolve_gateway_clarify(clarify_id, choice_text)\n+  · \"Other\" button → mark_awaiting_text(clarify_id) so the text-intercept\n+    captures the next user message in this session\n+  · already-resolved or unauthorized → ephemeral \"this prompt...\" reply\n+\"\"\"\n+\n+import asyncio\n+import sys\n+from pathlib import Path\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+# Repo root importable\n+_repo = str(Path(__file__).resolve().parents[2])\n+if _repo not in sys.path:\n+    sys.path.insert(0, _repo)\n+\n+# Triggers the shared discord mock from tests/gateway/conftest.py before\n+# importing the production module.\n+from gateway.platforms.discord import (  # noqa: E402\n+    ClarifyChoiceView,\n+    DiscordAdapter,\n+)\n+from gateway.config import PlatformConfig  # noqa: E402\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+def _make_adapter(*, allowed_users=None, allowed_roles=None):\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\", extra={})\n+    adapter = DiscordAdapter(config)\n+    adapter._client = MagicMock()\n+    adapter._allowed_user_ids = set(allowed_users or [])\n+    adapter._allowed_role_ids = set(allowed_roles or [])\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+def _make_interaction(*, user_id=\"42\", display_name=\"Tester\", roles=None,\n+                      include_message=True):\n+    \"\"\"Build a mock discord.Interaction with response.edit_message /\n+    send_message / defer all coroutine-callable.\"\"\"\n+    user = SimpleNamespace(\n+        id=user_id,\n+        display_name=display_name,\n+        roles=[SimpleNamespace(id=r) for r in (roles or [])],\n+    )\n+    response = SimpleNamespace(\n+        edit_message=AsyncMock(),\n+        send_message=AsyncMock(),\n+        defer=AsyncMock(),\n+    )\n+    if include_message:\n+        embed = MagicMock()\n+        embed.color = None\n+        embed.set_footer = MagicMock()\n+        message = SimpleNamespace(embeds=[embed])\n+    else:\n+        message = None\n+    return SimpleNamespace(user=user, response=response, message=message)\n+\n+\n+# ===========================================================================\n+# ClarifyChoiceView construction\n+# ===========================================================================\n+\n+class TestClarifyChoiceViewConstruction:\n+    \"\"\"The view should build numeric buttons plus an Other button.\"\"\"\n+\n+    def test_renders_n_choice_buttons_plus_other(self):\n+        view = ClarifyChoiceView(\n+            choices=[\"apple\", \"banana\", \"cherry\"],\n+            clarify_id=\"cidX\",\n+            allowed_user_ids={\"42\"},\n+        )\n+        # 3 numeric + 1 \"Other\"\n+        assert len(view.children) == 4\n+        labels = [b.label for b in view.children]\n+        assert labels[0].startswith(\"1. apple\")\n+        assert labels[1].startswith(\"2. banana\")\n+        assert labels[2].startswith(\"3. cherry\")\n+        assert \"Other\" in labels[3]\n+        # custom_ids encode clarify_id + index/other\n+        ids = [b.custom_id for b in view.children]\n+        assert ids[0] == \"clarify:cidX:0\"\n+        assert ids[1] == \"clarify:cidX:1\"\n+        assert ids[2] == \"clarify:cidX:2\"\n+        assert ids[3] == \"clarify:cidX:other\"\n+\n+    def test_caps_at_24_choices_plus_other(self):\n+        choices = [f\"choice-{i}\" for i in range(50)]\n+        view = ClarifyChoiceView(\n+            choices=choices,\n+            clarify_id=\"cidY\",\n+            allowed_user_ids=set(),\n+        )\n+        # Discord limit is 25 components; we cap choices at 24 + 1 Other = 25\n+        assert len(view.children) == 25\n+        assert \"Other\" in view.children[-1].label\n+\n+    def test_truncates_long_choice_label(self):\n+        long_choice = \"x\" * 200\n+        view = ClarifyChoiceView(\n+            choices=[long_choice],\n+            clarify_id=\"cidZ\",\n+            allowed_user_ids=set(),\n+        )\n+        # 75 chars + 3 ellipsis chars in the body, plus \"1. \" prefix\n+        first_label = view.children[0].label\n+        assert first_label.startswith(\"1. \")\n+        assert first_label.endswith(\"...\")\n+        # Final label total <= 80 (Discord cap on button labels)\n+        assert len(first_label) <= 80\n+\n+\n+# ===========================================================================\n+# Choice callback → resolve_gateway_clarify\n+# ===========================================================================\n+\n+class TestClarifyChoiceResolve:\n+    \"\"\"Clicking a numeric button should resolve the clarify entry.\"\"\"\n+\n+    def setup_method(self):\n+        _clear_clarify_state()\n+\n+    @pytest.mark.asyncio\n+    async def test_choice_resolves_with_canonical_choice_text(self):\n+        from tools import clarify_gateway as cm\n+        cm.register(\"cidA\", \"sk-A\", \"Pick\", [\"red\", \"green\", \"blue\"])\n+\n+        view = ClarifyChoiceView(\n+            choices=[\"red\", \"green\", \"blue\"],\n+            clarify_id=\"cidA\",\n+            allowed_user_ids={\"42\"},\n+        )\n+\n+        interaction = _make_interaction(user_id=\"42\")\n+        await view._resolve_choice(interaction, index=1, choice=\"green\")\n+\n+        # Resolved through clarify primitive\n+        with cm._lock:\n+            entry = cm._entries.get(\"cidA\")\n+        assert entry is not None\n+        assert entry.response == \"green\"\n+        assert entry.event.is_set()\n+        # Buttons disabled\n+        assert all(b.disabled for b in view.children)\n+        # Embed updated + edit_message called\n+        interaction.response.edit_message.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_choice_falls_back_to_label_text_when_entry_missing(self):\n+        \"\"\"If the gateway entry vanished (race / stale view), the button's\n+        own choice text is used as the response.\"\"\"\n+        from tools import clarify_gateway as cm\n+        # Note: no cm.register() — entry intentionally absent\n+\n+        view = ClarifyChoiceView(\n+            choices=[\"alpha\"],\n+            clarify_id=\"cidGone\",\n+            allowed_user_ids=set(),\n+        )\n+        interaction = _make_interaction()\n+        # Doesn't raise; resolve_gateway_clarify returns False quietly\n+        await view._resolve_choice(interaction, index=0, choice=\"alpha\")\n+        # Still marks the view resolved + disables buttons\n+        assert view.resolved is True\n+        assert all(b.disabled for b in view.children)\n+\n+    @pytest.mark.asyncio\n+    async def test_already_resolved_sends_ephemeral_reply(self):\n+        view = ClarifyChoiceView(\n+            choices=[\"a\", \"b\"],\n+            clarify_id=\"cidB\",\n+            allowed_user_ids=set(),\n+        )\n+        view.resolved = True\n+\n+        interaction = _make_interaction()\n+        await view._resolve_choice(interaction, index=0, choice=\"a\")\n+\n+        interaction.response.send_message.assert_called_once()\n+        kwargs = interaction.response.send_message.call_args.kwargs\n+        assert kwargs.get(\"ephemeral\") is True\n+        # No resolve was called\n+        interaction.response.edit_message.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_unauthorized_user_rejected(self):\n+        from tools import clarify_gateway as cm\n+        cm.register(\"cidC\", \"sk-C\", \"Pick\", [\"x\"])\n+\n+        # Allowlist set, user not in it\n+        view = ClarifyChoiceView(\n+            choices=[\"x\"],\n+            clarify_id=\"cidC\",\n+            allowed_user_ids={\"99999\"},  # not 42\n+        )\n+\n+        interaction = _make_interaction(user_id=\"42\")\n+        await view._resolve_choice(interaction, index=0, choice=\"x\")\n+\n+        # Ephemeral rejection, no resolution, no edit\n+        interaction.response.send_message.assert_called_once()\n+        kwargs = interaction.response.send_message.call_args.kwargs\n+        assert kwargs.get(\"ephemeral\") is True\n+        interaction.response.edit_message.assert_not_called()\n+        with cm._lock:\n+            entry = cm._entries.get(\"cidC\")\n+        assert entry is not None\n+        assert not entry.event.is_set()\n+\n+\n+# ===========================================================================\n+# \"Other\" button → mark_awaiting_text\n+# ===========================================================================\n+\n+class TestClarifyOtherButton:\n+    \"\"\"Clicking Other should flip the entry into text-capture mode.\"\"\"\n+\n+    def setup_method(self):\n+        _clear_clarify_state()\n+\n+    @pytest.mark.asyncio\n+    async def test_other_flips_entry_to_awaiting_text(self):\n+        from tools import clarify_gateway as cm\n+        cm.register(\"cidD\", \"sk-D\", \"Pick\", [\"x\", \"y\"])\n+\n+        view = ClarifyChoiceView(\n+            choices=[\"x\", \"y\"],\n+            clarify_id=\"cidD\",\n+            allowed_user_ids=set(),\n+        )\n+\n+        interaction = _make_interaction()\n+        await view._on_other(interaction)\n+\n+        # Entry awaiting_text now\n+        pending = cm.get_pending_for_session(\"sk-D\")\n+        assert pending is not None\n+        assert pending.clarify_id == \"cidD\"\n+        assert pending.awaiting_text is True\n+        # Entry still pending (not resolved)\n+        with cm._lock:\n+            entry = cm._entries.get(\"cidD\")\n+        assert entry is not None\n+        assert not entry.event.is_set()\n+        # View locked + buttons disabled\n+        assert view.resolved is True\n+        assert all(b.disabled for b in view.children)\n+        interaction.response.edit_message.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_other_unauthorized_user_rejected(self):\n+        from tools import clarify_gateway as cm\n+        cm.register(\"cidE\", \"sk-E\", \"Pick\", [\"x\"])\n+\n+        view = ClarifyChoiceView(\n+            choices=[\"x\"],\n+            clarify_id=\"cidE\",\n+            allowed_user_ids={\"99999\"},\n+        )\n+\n+        interaction = _make_interaction(user_id=\"42\")\n+        await view._on_other(interaction)\n+\n+        # Rejected; entry NOT awaiting text\n+        interaction.response.send_message.assert_called_once()\n+        pending = cm.get_pending_for_session(\"sk-E\")\n+        assert pending is None or pending.awaiting_text is False\n+\n+\n+# ===========================================================================\n+# DiscordAdapter.send_clarify integration\n+# ===========================================================================\n+\n+class TestDiscordSendClarify:\n+    \"\"\"Verify send_clarify renders an embed and (optionally) attaches the view.\"\"\"\n+\n+    def setup_method(self):\n+        _clear_clarify_state()\n+\n+    @pytest.mark.asyncio\n+    async def test_multi_choice_attaches_view(self):\n+        adapter = _make_adapter(allowed_users={\"42\"})\n+        channel = MagicMock()\n+        sent_msg = MagicMock()\n+        sent_msg.id = 123456\n+        channel.send = AsyncMock(return_value=sent_msg)\n+        adapter._client.get_channel = MagicMock(return_value=channel)\n+\n+        result = await adapter.send_clarify(\n+            chat_id=\"9001\",\n+            question=\"Pick a color\",\n+            choices=[\"red\", \"green\", \"blue\"],\n+            clarify_id=\"cidM\",\n+            session_key=\"sk-M\",\n+        )\n+\n+        assert result.success is True\n+        assert result.message_id == \"123456\"\n+        # Verify channel.send was called with embed + view kwargs\n+        channel.send.assert_called_once()\n+        kwargs = channel.send.call_args.kwargs\n+        assert \"embed\" in kwargs\n+        assert \"view\" in kwargs\n+        assert isinstance(kwargs[\"view\"], ClarifyChoiceView)\n+        # 3 choice buttons + 1 Other\n+        assert len(kwargs[\"view\"].children) == 4\n+\n+    @pytest.mark.asyncio\n+    async def test_open_ended_omits_view(self):\n+        adapter = _make_adapter()\n+        channel = MagicMock()\n+        sent_msg = MagicMock()\n+        sent_msg.id = 222\n+        channel.send = AsyncMock(return_value=sent_msg)\n+        adapter._client.get_channel = MagicMock(return_value=channel)\n+\n+        result = await adapter.send_clarify(\n+            chat_id=\"9001\",\n+            question=\"What is your name?\",\n+            choices=None,\n+            clarify_id=\"cidOE\",\n+            session_key=\"sk-OE\",\n+        )\n+\n+        assert result.success is True\n+        channel.send.assert_called_once()\n+        kwargs = channel.send.call_args.kwargs\n+        # Open-ended path renders embed but no view (text-capture handles reply)\n+        assert \"embed\" in kwargs\n+        assert \"view\" not in kwargs\n+\n+    @pytest.mark.asyncio\n+    async def test_routes_to_thread_when_metadata_thread_id_set(self):\n+        adapter = _make_adapter()\n+        channel = MagicMock()\n+        sent_msg = MagicMock()\n+        sent_msg.id = 333\n+        channel.send = AsyncMock(return_value=sent_msg)\n+        adapter._client.get_channel = MagicMock(return_value=channel)\n+\n+        await adapter.send_clarify(\n+            chat_id=\"9001\",\n+            question=\"?\",\n+            choices=[\"a\"],\n+            clarify_id=\"cidT\",\n+            session_key=\"sk-T\",\n+            metadata={\"thread_id\": \"7777\"},\n+        )\n+\n+        # Channel lookup should resolve to thread id, not chat_id\n+        adapter._client.get_channel.assert_called_once_with(7777)\n+\n+    @pytest.mark.asyncio\n+    async def test_not_connected_returns_failure(self):\n+        adapter = _make_adapter()\n+        adapter._client = None\n+        result = await adapter.send_clarify(\n+            chat_id=\"9001\",\n+            question=\"?\",\n+            choices=[\"a\"],\n+            clarify_id=\"cidNC\",\n+            session_key=\"sk-NC\",\n+        )\n+        assert result.success is False\n+        assert \"Not connected\" in (result.error or \"\")\n+\n+    @pytest.mark.asyncio\n+    async def test_filters_empty_and_whitespace_choices(self):\n+        adapter = _make_adapter()\n+        channel = MagicMock()\n+        sent_msg = MagicMock()\n+        sent_msg.id = 444\n+        channel.send = AsyncMock(return_value=sent_msg)\n+        adapter._client.get_channel = MagicMock(return_value=channel)\n+\n+        await adapter.send_clarify(\n+            chat_id=\"9001\",\n+            question=\"?\",\n+            choices=[\"\", \"  \", \"real-choice\", None],\n+            clarify_id=\"cidF\",\n+            session_key=\"sk-F\",\n+        )\n+        kwargs = channel.send.call_args.kwargs\n+        view = kwargs[\"view\"]\n+        # Only 1 real choice + 1 Other = 2 children\n+        assert len(view.children) == 2\n+        assert \"real-choice\" in view.children[0].label\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}