From 95aad9229b066a7f40285bf946c770a423a2be6a Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 22 Jul 2026 04:25:47 -0700 Subject: [PATCH] feat(slack): Block Kit buttons for clarify prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack now overrides send_clarify to render multi-choice clarify prompts as native Block Kit buttons (one per choice + a final '✏️ Other…' free-text button), mirroring the Telegram/Discord adapters and the existing Slack approval-button pattern. - Unique hermes_clarify_choice_ action_ids (Slack rejects duplicate action_ids within one actions block); dispatch via a compiled-regex action matcher plus hermes_clarify_other. - Chunks elements across actions blocks in groups of 5 so a larger choice list degrades gracefully instead of 400ing (invalid_blocks). - Choice taps resolve through tools.clarify_gateway .resolve_gateway_clarify with the canonical registered choice text — the same applier the typed-reply path uses — then edit the message to show the outcome and drop the buttons. - 'Other' flips the entry into text-capture via mark_awaiting_text (only on tap, never at send time) so the gateway text-intercept captures the next typed message. - Auth-gated via _is_interactive_user_authorized; atomic-pop double-click guard mirrors _approval_resolved; late taps on evicted entries surface an honest expiry notice instead of a false ✓. - Open-ended prompts delegate to the base plain-text render. Salvaged from PR #61943 by @100yenadmin. Earliest implementation of this feature was PR #28885 by @cypres0099; sibling implementations #66606 (@jaaro-ai) and #51547 (@Mongol-Jimmi) are superseded. Closes #52369 --- plugins/platforms/slack/adapter.py | 242 ++++++++++ tests/gateway/test_slack.py | 14 + tests/gateway/test_slack_clarify_buttons.py | 475 ++++++++++++++++++++ 3 files changed, 731 insertions(+) create mode 100644 tests/gateway/test_slack_clarify_buttons.py diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index c583c106c629f..7ce10bb94814d 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -598,6 +598,9 @@ class SlackAdapter(BasePlatformAdapter): # Track pending approval message_ts → resolved flag to prevent # double-clicks on approval buttons. self._approval_resolved: Dict[str, bool] = {} + # Same guard for clarify prompts (interactive multiple-choice + # buttons); mirrors _approval_resolved. + self._clarify_resolved: Dict[str, bool] = {} # Track timestamps of messages sent by the bot so we can respond # to thread replies even without an explicit @mention. self._bot_message_ts: set = set() @@ -1360,6 +1363,15 @@ class SlackAdapter(BasePlatformAdapter): self._app.action("hermes_feedback")(self._handle_feedback_action) + # Register Block Kit action handlers for clarify buttons + # (interactive multiple-choice prompts; see tools/clarify_gateway.py). + # Choice buttons use indexed action IDs so each ID is unique within + # its actions block, as required by Slack's Block Kit schema. + self._app.action( + _re.compile(r"^hermes_clarify_choice_\d+$") + )(self._handle_clarify_action) + self._app.action("hermes_clarify_other")(self._handle_clarify_action) + # Register plugin-provided Block Kit action handlers. # # Plugins call ``ctx.register_slack_action_handler(action_id, cb)`` @@ -4182,6 +4194,105 @@ class SlackAdapter(BasePlatformAdapter): logger.error("[Slack] send_slash_confirm failed: %s", e, exc_info=True) return SendResult(success=False, error=str(e)) + async def send_clarify( + self, + chat_id: str, + question: str, + choices: Optional[list], + clarify_id: str, + session_key: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Render a clarify prompt as Block Kit interactive buttons. + + Multi-choice mode (``choices`` non-empty): one button per option + (unique ``hermes_clarify_choice_`` action_id, ``value`` packs + ``clarify_id|idx``) plus a final "✏️ Other…" button + (``hermes_clarify_other``). A choice click resolves the clarify + primitive directly; the "Other" button flips the entry into + text-capture mode so the gateway's platform-agnostic text-intercept + (:meth:`GatewayRunner._handle_message`) picks up the next typed + message and resolves the clarify — no Slack-specific text machinery. + + Open-ended mode (``choices`` empty): delegates to the base + implementation, which renders the plain question and arms the same + text-intercept. + """ + # Open-ended prompts have no buttons — the base implementation renders + # the plain question and arms the gateway text-intercept for us. + if not choices: + return await super().send_clarify( + chat_id=chat_id, + question=question, + choices=choices, + clarify_id=clarify_id, + session_key=session_key, + metadata=metadata, + ) + + if not self._app: + return SendResult(success=False, error="Not connected") + + try: + thread_ts = self._resolve_thread_ts(None, metadata) + + # Escape the Slack mrkdwn control chars (&, <, >) so a question + # containing them renders literally instead of as markup/mentions. + # Section text caps at 3000 chars — budget the question so the + # wrapper never pushes the block over the limit (overflow → + # invalid_blocks → no buttons). + q = (question or "").replace("&", "&").replace("<", "<").replace(">", ">") + body = f"❓ {q}" + budget = 3000 - len("...") + if len(body) > budget: + body = body[:budget] + "..." + + # One button per choice + a free-text "Other" button. Slack caps + # an actions block at 5 elements; the clarify tool caps choices at + # 4 (+ Other = 5) so this is normally one block, but chunk anyway + # so a larger choice list degrades gracefully instead of 400ing. + elements = [] + for idx, choice in enumerate(choices): + label = str(choice).strip() or f"Option {idx + 1}" + elements.append({ + "type": "button", + "text": {"type": "plain_text", "text": label[:75], "emoji": True}, + "action_id": f"hermes_clarify_choice_{idx}", + "value": f"{clarify_id}|{idx}", + }) + elements.append({ + "type": "button", + "text": {"type": "plain_text", "text": "✏️ Other…", "emoji": True}, + "action_id": "hermes_clarify_other", + "value": f"{clarify_id}|other", + }) + + blocks: list = [ + {"type": "section", "text": {"type": "mrkdwn", "text": body}}, + ] + for start in range(0, len(elements), 5): + blocks.append({"type": "actions", "elements": elements[start:start + 5]}) + + kwargs: Dict[str, Any] = { + "channel": chat_id, + "text": body, + "blocks": blocks, + } + if thread_ts: + kwargs["thread_ts"] = thread_ts + + result = await self._get_client(chat_id).chat_postMessage(**kwargs) + msg_ts = result.get("ts", "") + if msg_ts: + # Mark unresolved so the action handler's atomic-pop guard can + # reject double-clicks (mirrors _approval_resolved). + self._clarify_resolved[msg_ts] = False + + return SendResult(success=True, message_id=msg_ts, raw_response=result) + except Exception as e: + logger.error("[Slack] send_clarify failed: %s", e, exc_info=True) + return SendResult(success=False, error=str(e)) + def _is_interactive_user_authorized( self, user_id: str, @@ -4516,6 +4627,137 @@ class SlackAdapter(BasePlatformAdapter): # (approval already resolved above; state consumed by atomic pop) + async def _update_clarify_message( + self, + channel_id: str, + msg_ts: str, + question_text: str, + decision_text: str, + ) -> None: + """Rewrite a clarify message to show the outcome and drop the buttons.""" + updated_blocks = [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": question_text or "Clarification"}, + }, + { + "type": "context", + "elements": [{"type": "mrkdwn", "text": decision_text}], + }, + ] + try: + await self._get_client(channel_id).chat_update( + channel=channel_id, + ts=msg_ts, + text=decision_text, + blocks=updated_blocks, + ) + except Exception as e: + logger.warning("[Slack] Failed to update clarify message: %s", e) + + async def _handle_clarify_action(self, ack, body, action) -> None: + """Handle a clarify button click (a choice or "Other") from Block Kit.""" + await ack() + + action_id = action.get("action_id", "") + value = action.get("value", "") + message = body.get("message", {}) + msg_ts = message.get("ts", "") + channel_id = body.get("channel", {}).get("id", "") + user_name = body.get("user", {}).get("name", "unknown") + user_id = body.get("user", {}).get("id", "") + + if not self._is_interactive_user_authorized( + user_id, + channel_id=channel_id, + user_name=user_name, + ): + logger.warning( + "[Slack] Unauthorized clarify click by %s (%s) - ignoring", + user_name, user_id, + ) + return + + # value packs ``clarify_id|``. + if "|" not in value: + logger.warning("[Slack] Malformed clarify value: %s", value) + return + clarify_id, token = value.split("|", 1) + + # Double-click guard — atomic pop; first caller gets False (proceed), + # any later click gets the True default and bails (mirrors approval). + if self._clarify_resolved.pop(msg_ts, True): + return + + # Preserve the original question so the resolved message keeps context. + original_text = "" + for block in message.get("blocks", []): + if block.get("type") == "section": + original_text = block.get("text", {}).get("text", "") + break + + from tools import clarify_gateway as _clarify_mod + + # "Other" → enter text-capture mode. The gateway's text-intercept + # resolves the clarify from the user's next typed message, so there is + # no Slack-side text bookkeeping: mark_awaiting_text flips the entry and + # GatewayRunner._handle_message does the rest. + if action_id == "hermes_clarify_other" or token == "other": + if not _clarify_mod.mark_awaiting_text(clarify_id): + # Entry evicted (clarify_timeout) or gateway restarted between + # ask and tap — a typed answer would go nowhere. + await self._update_clarify_message( + channel_id, msg_ts, original_text, + f"⏳ This prompt expired — please send a new request. (by {user_name})", + ) + return + await self._update_clarify_message( + channel_id, msg_ts, original_text, + f"✏️ Awaiting typed answer from {user_name}…", + ) + return + + # Numeric choice → resolve immediately with the chosen option text. + try: + idx = int(token) + except (ValueError, TypeError): + logger.warning("[Slack] Invalid clarify choice token: %s", token) + return + + # Look up the canonical choice text from the registered entry (mirrors + # the Telegram adapter); fall back to a positional label on a race with + # timeout / session reset. + resolved_text: Optional[str] = None + try: + entry = _clarify_mod._entries.get(clarify_id) # type: ignore[attr-defined] + if entry and entry.choices and 0 <= idx < len(entry.choices): + resolved_text = str(entry.choices[idx]) + except Exception: + resolved_text = None + if resolved_text is None: + resolved_text = f"choice {idx + 1}" + + if _clarify_mod.resolve_gateway_clarify(clarify_id, resolved_text): + await self._update_clarify_message( + channel_id, msg_ts, original_text, + f"✅ {user_name}: {resolved_text}", + ) + logger.info( + "Slack button resolved clarify (id=%s, choice=%r, user=%s)", + clarify_id, resolved_text, user_name, + ) + else: + # Entry evicted / gateway restarted — surface expiry instead of a + # misleading ✓ on a button the agent will never receive. + await self._update_clarify_message( + channel_id, msg_ts, original_text, + f"⏳ This prompt expired — please send a new request. (by {user_name})", + ) + logger.warning( + "[Slack] clarify resolve returned False (id=%s) — expired/reset", + clarify_id, + ) + # ----- Thread context fetching ----- @staticmethod diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 425ec5a07f419..6f5488c03a4b2 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -939,6 +939,20 @@ class TestSlackProxyBehavior: assert adapter._handler.proxy == "http://proxy.example.com:3128" assert adapter._handler.client.proxy == "http://proxy.example.com:3128" assert "hermes_feedback" in created_apps[0].registered_actions + assert "hermes_clarify_other" in created_apps[0].registered_actions + clarify_choice_patterns = [ + action_id + for action_id in created_apps[0].registered_actions + if hasattr(action_id, "fullmatch") + ] + assert any( + pattern.fullmatch("hermes_clarify_choice_0") + for pattern in clarify_choice_patterns + ) + assert not any( + pattern.fullmatch("hermes_clarify_choice") + for pattern in clarify_choice_patterns + ) @pytest.mark.asyncio async def test_connect_clears_proxy_when_no_proxy_matches_slack(self): diff --git a/tests/gateway/test_slack_clarify_buttons.py b/tests/gateway/test_slack_clarify_buttons.py new file mode 100644 index 0000000000000..655cb3a3ae8c0 --- /dev/null +++ b/tests/gateway/test_slack_clarify_buttons.py @@ -0,0 +1,475 @@ +"""Tests for Slack Block Kit interactive clarify buttons. + +Mirrors test_slack_approval_buttons.py (harness) and +test_telegram_clarify_buttons.py (semantics) for the ``send_clarify`` override +and the indexed ``hermes_clarify_choice_`` / +``hermes_clarify_other`` action dispatch. +""" + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Ensure the repo root is importable +# --------------------------------------------------------------------------- +_repo = str(Path(__file__).resolve().parents[2]) +if _repo not in sys.path: + sys.path.insert(0, _repo) + + +# --------------------------------------------------------------------------- +# Minimal Slack SDK mock so SlackAdapter can be imported (mirrors +# test_slack_approval_buttons.py) +# --------------------------------------------------------------------------- +def _ensure_slack_mock(): + if "slack_bolt" in sys.modules: + return + slack_bolt = MagicMock() + slack_bolt.async_app.AsyncApp = MagicMock + sys.modules["slack_bolt"] = slack_bolt + sys.modules["slack_bolt.async_app"] = slack_bolt.async_app + handler_mod = MagicMock() + handler_mod.AsyncSocketModeHandler = MagicMock + sys.modules["slack_bolt.adapter"] = MagicMock() + sys.modules["slack_bolt.adapter.socket_mode"] = MagicMock() + sys.modules["slack_bolt.adapter.socket_mode.async_handler"] = handler_mod + sdk_mod = MagicMock() + sdk_mod.web = MagicMock() + sdk_mod.web.async_client = MagicMock() + sdk_mod.web.async_client.AsyncWebClient = MagicMock + sys.modules["slack_sdk"] = sdk_mod + sys.modules["slack_sdk.web"] = sdk_mod.web + sys.modules["slack_sdk.web.async_client"] = sdk_mod.web.async_client + + +_ensure_slack_mock() + +from plugins.platforms.slack.adapter import SlackAdapter +from gateway.config import PlatformConfig + + +def _make_adapter(): + config = PlatformConfig(enabled=True, token="xoxb-test-token") + adapter = SlackAdapter(config) + adapter._app = MagicMock() + adapter._bot_user_id = "U_BOT" + adapter._team_clients = {"T1": AsyncMock()} + adapter._team_bot_user_ids = {"T1": "U_BOT"} + adapter._channel_team = {"C1": "T1"} + return adapter + + +class _AuthRunner: + def __init__(self, auth_fn=None): + self._auth_fn = auth_fn or (lambda _source: True) + + async def handle(self, event): + return None + + def _is_user_authorized(self, source): + return self._auth_fn(source) + + +def _attach_auth_runner(adapter, auth_fn=None): + adapter.set_message_handler(_AuthRunner(auth_fn=auth_fn).handle) + + +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() + + +# =========================================================================== +# send_clarify — Block Kit render (a) +# =========================================================================== + +class TestSlackSendClarify: + def setup_method(self): + _clear_clarify_state() + + @pytest.mark.asyncio + async def test_multi_choice_renders_buttons_and_other(self): + adapter = _make_adapter() + mock_client = adapter._team_clients["T1"] + mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1234.5678"}) + + result = await adapter.send_clarify( + chat_id="C1", + question="Which environment?", + choices=["staging", "production"], + clarify_id="cid1", + session_key="sk1", + ) + + assert result.success is True + assert result.message_id == "1234.5678" + # ts recorded for the double-click guard + assert adapter._clarify_resolved.get("1234.5678") is False + + kwargs = mock_client.chat_postMessage.call_args[1] + blocks = kwargs["blocks"] + assert blocks[0]["type"] == "section" + assert "Which environment?" in blocks[0]["text"]["text"] + assert blocks[1]["type"] == "actions" + elements = blocks[1]["elements"] + # 2 choices + Other + assert len(elements) == 3 + assert elements[0]["action_id"] == "hermes_clarify_choice_0" + assert elements[0]["value"] == "cid1|0" + assert elements[1]["action_id"] == "hermes_clarify_choice_1" + assert elements[1]["value"] == "cid1|1" + assert elements[0]["text"]["text"] == "staging" + # Final button is the free-text "Other" + assert elements[2]["action_id"] == "hermes_clarify_other" + assert elements[2]["value"] == "cid1|other" + for block in blocks: + if block["type"] == "actions": + action_ids = [element["action_id"] for element in block["elements"]] + assert len(action_ids) == len(set(action_ids)) + + @pytest.mark.asyncio + async def test_open_ended_no_buttons(self): + adapter = _make_adapter() + mock_client = adapter._team_clients["T1"] + mock_client.chat_postMessage = AsyncMock(return_value={"ts": "9.9"}) + + result = await adapter.send_clarify( + chat_id="C1", + question="What should I name the branch?", + choices=None, + clarify_id="cid-open", + session_key="sk-open", + ) + + assert result.success is True + kwargs = mock_client.chat_postMessage.call_args[1] + # Open-ended delegates to the base plain-text path — no action blocks. + assert "blocks" not in kwargs or all( + b.get("type") != "actions" for b in (kwargs.get("blocks") or []) + ) + assert "What should I name the branch?" in kwargs["text"] + + @pytest.mark.asyncio + async def test_mrkdwn_escapes_question(self): + adapter = _make_adapter() + mock_client = adapter._team_clients["T1"] + mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1.1"}) + + await adapter.send_clarify( + chat_id="C1", + question="Use & ?", + choices=["yes"], + clarify_id="cid2", + session_key="sk2", + ) + section_text = mock_client.chat_postMessage.call_args[1]["blocks"][0]["text"]["text"] + assert "" not in section_text + assert "<A>" in section_text + assert "&" in section_text + + @pytest.mark.asyncio + async def test_sends_in_thread(self): + adapter = _make_adapter() + mock_client = adapter._team_clients["T1"] + mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1.2"}) + + await adapter.send_clarify( + chat_id="C1", + question="?", + choices=["a"], + clarify_id="cid3", + session_key="sk3", + metadata={"thread_id": "8888.0000"}, + ) + assert mock_client.chat_postMessage.call_args[1].get("thread_ts") == "8888.0000" + + @pytest.mark.asyncio + async def test_not_connected(self): + adapter = _make_adapter() + adapter._app = None + result = await adapter.send_clarify( + chat_id="C1", question="?", choices=["a"], clarify_id="c", session_key="s" + ) + assert result.success is False + + @pytest.mark.asyncio + async def test_five_choices_chunk_across_actions_blocks(self): + """Slack caps 5 elements per actions block; 5 choices + Other = 6 + buttons must spill into a second block instead of 400ing.""" + adapter = _make_adapter() + mock_client = adapter._team_clients["T1"] + mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1.3"}) + + await adapter.send_clarify( + chat_id="C1", + question="?", + choices=["a", "b", "c", "d", "e"], + clarify_id="cid5", + session_key="sk5", + ) + blocks = mock_client.chat_postMessage.call_args[1]["blocks"] + action_blocks = [b for b in blocks if b["type"] == "actions"] + assert len(action_blocks) == 2 + for b in action_blocks: + assert len(b["elements"]) <= 5 + + +# =========================================================================== +# _handle_clarify_action — choice click resolves (b) +# =========================================================================== + +class TestSlackClarifyChoiceAction: + def setup_method(self): + _clear_clarify_state() + + @pytest.mark.asyncio + async def test_choice_resolves_with_choice_text(self): + from tools import clarify_gateway as cm + + adapter = _make_adapter() + _attach_auth_runner(adapter) + cm.register("cidA", "sk-cb", "Pick", ["red", "green", "blue"]) + adapter._clarify_resolved["1234.5678"] = False + + mock_client = adapter._team_clients["T1"] + mock_client.chat_update = AsyncMock() + + ack = AsyncMock() + body = { + "message": { + "ts": "1234.5678", + "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "❓ Pick"}}, + {"type": "actions", "elements": []}, + ], + }, + "channel": {"id": "C1"}, + "user": {"name": "norbert", "id": "U_NORBERT"}, + } + action = {"action_id": "hermes_clarify_choice_1", "value": "cidA|1"} + + await adapter._handle_clarify_action(ack, body, action) + + ack.assert_called_once() + with cm._lock: + entry = cm._entries.get("cidA") + assert entry is not None + assert entry.response == "green" + assert entry.event.is_set() + # Message updated with the answer, buttons dropped. + update_kwargs = mock_client.chat_update.call_args[1] + assert "green" in update_kwargs["text"] + assert all(b["type"] != "actions" for b in update_kwargs["blocks"]) + + @pytest.mark.asyncio + async def test_prevents_double_click(self): + from tools import clarify_gateway as cm + + adapter = _make_adapter() + _attach_auth_runner(adapter) + cm.register("cidDup", "sk-dup", "Pick", ["x"]) + adapter._clarify_resolved["1.1"] = True # already resolved + + mock_client = adapter._team_clients["T1"] + mock_client.chat_update = AsyncMock() + + ack = AsyncMock() + body = { + "message": {"ts": "1.1", "blocks": []}, + "channel": {"id": "C1"}, + "user": {"name": "n", "id": "U1"}, + } + action = {"action_id": "hermes_clarify_choice", "value": "cidDup|0"} + + await adapter._handle_clarify_action(ack, body, action) + + ack.assert_called_once() + with cm._lock: + entry = cm._entries.get("cidDup") + assert entry is not None + assert not entry.event.is_set() + mock_client.chat_update.assert_not_called() + + @pytest.mark.asyncio + async def test_unauthorized_click_ignored(self): + from tools import clarify_gateway as cm + + adapter = _make_adapter() + _attach_auth_runner(adapter, auth_fn=lambda _s: False) + cm.register("cidAuth", "sk-auth", "Pick", ["a", "b"]) + adapter._clarify_resolved["2.2"] = False + + ack = AsyncMock() + body = { + "message": {"ts": "2.2", "blocks": []}, + "channel": {"id": "C1"}, + "user": {"name": "mallory", "id": "U_BAD"}, + } + action = {"action_id": "hermes_clarify_choice", "value": "cidAuth|0"} + + await adapter._handle_clarify_action(ack, body, action) + + with cm._lock: + entry = cm._entries.get("cidAuth") + assert entry is not None + assert not entry.event.is_set() + + @pytest.mark.asyncio + async def test_expired_choice_shows_notice(self): + """Late tap after the entry was evicted must surface expiry, not a ✓.""" + adapter = _make_adapter() + _attach_auth_runner(adapter) + # No entry registered → resolve returns False. + adapter._clarify_resolved["3.3"] = False + + mock_client = adapter._team_clients["T1"] + mock_client.chat_update = AsyncMock() + + ack = AsyncMock() + body = { + "message": {"ts": "3.3", "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "❓ Pick"}}, + ]}, + "channel": {"id": "C1"}, + "user": {"name": "t", "id": "U_T"}, + } + action = {"action_id": "hermes_clarify_choice", "value": "cidGone|0"} + + await adapter._handle_clarify_action(ack, body, action) + + assert "expired" in mock_client.chat_update.call_args[1]["text"].lower() + + +# =========================================================================== +# _handle_clarify_action — "Other" → text-capture → typed reply (c) +# =========================================================================== + +class TestSlackClarifyOtherFlow: + def setup_method(self): + _clear_clarify_state() + + @pytest.mark.asyncio + async def test_other_flips_to_text_mode_then_typed_reply_resolves(self): + from tools import clarify_gateway as cm + + adapter = _make_adapter() + _attach_auth_runner(adapter) + cm.register("cidO", "sk-other", "Pick", ["x", "y"]) + adapter._clarify_resolved["4.4"] = False + + mock_client = adapter._team_clients["T1"] + mock_client.chat_update = AsyncMock() + + ack = AsyncMock() + body = { + "message": {"ts": "4.4", "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "❓ Pick"}}, + {"type": "actions", "elements": []}, + ]}, + "channel": {"id": "C1"}, + "user": {"name": "norbert", "id": "U_N"}, + } + action = {"action_id": "hermes_clarify_other", "value": "cidO|other"} + + await adapter._handle_clarify_action(ack, body, action) + + # Entry flipped to text-capture; NOT yet resolved. + pending = cm.get_pending_for_session("sk-other") + assert pending is not None and pending.clarify_id == "cidO" + assert pending.awaiting_text is True + with cm._lock: + entry = cm._entries.get("cidO") + assert not entry.event.is_set() + assert "awaiting" in mock_client.chat_update.call_args[1]["text"].lower() + + # Now the gateway text-intercept (platform-agnostic) resolves from the + # user's next typed message. We exercise that leveraged path directly. + assert cm.resolve_text_response_for_session("sk-other", "my custom answer") is True + with cm._lock: + entry = cm._entries.get("cidO") + assert entry.response == "my custom answer" + assert entry.event.is_set() + + @pytest.mark.asyncio + async def test_other_expired_shows_notice(self): + adapter = _make_adapter() + _attach_auth_runner(adapter) + # No entry → mark_awaiting_text returns False. + adapter._clarify_resolved["5.5"] = False + + mock_client = adapter._team_clients["T1"] + mock_client.chat_update = AsyncMock() + + ack = AsyncMock() + body = { + "message": {"ts": "5.5", "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": "❓ Pick"}}, + ]}, + "channel": {"id": "C1"}, + "user": {"name": "t", "id": "U_T"}, + } + action = {"action_id": "hermes_clarify_other", "value": "cidOtherGone|other"} + + await adapter._handle_clarify_action(ack, body, action) + assert "expired" in mock_client.chat_update.call_args[1]["text"].lower() + + @pytest.mark.asyncio + async def test_malformed_value_ignored(self): + adapter = _make_adapter() + _attach_auth_runner(adapter) + adapter._clarify_resolved["6.6"] = False + mock_client = adapter._team_clients["T1"] + mock_client.chat_update = AsyncMock() + + ack = AsyncMock() + body = { + "message": {"ts": "6.6", "blocks": []}, + "channel": {"id": "C1"}, + "user": {"name": "t", "id": "U_T"}, + } + action = {"action_id": "hermes_clarify_choice", "value": "no-delimiter"} + + await adapter._handle_clarify_action(ack, body, action) + mock_client.chat_update.assert_not_called() + + +# =========================================================================== +# Base text-fallback unchanged for platforms without an override (e) +# =========================================================================== + +class TestBaseAdapterClarifyFallbackUnchanged: + @pytest.mark.asyncio + async def test_base_numbered_text_fallback(self): + from gateway.platforms.base import BasePlatformAdapter, SendResult + + class _Stub(BasePlatformAdapter): + name = "stub" + + def __init__(self): + self.sent: list = [] + + async def connect(self, *, is_reconnect: bool = False): pass + async def disconnect(self): pass + async def send(self, chat_id, content, **kw): + self.sent.append(content) + return SendResult(success=True, message_id="1") + async def edit(self, *a, **k): return SendResult(success=False) + async def get_history(self, *a, **k): return [] + async def get_chat_info(self, *a, **k): return {} + + adapter = _Stub() + result = await adapter.send_clarify( + chat_id="c", question="Pick a fruit", + choices=["apple", "banana"], clarify_id="x", session_key="s", + ) + assert result.success is True + text = adapter.sent[0] + assert "Pick a fruit" in text + assert "1." in text and "apple" in text + assert "2." in text and "banana" in text