"""Regression tests for #62034 — pending multi-choice clarify prompts must not swallow unrelated thread follow-up messages. When a NATIVE interactive multi-choice clarify (buttons rendered, ``awaiting_text=False``) is pending, the gateway text-intercept used to consume ANY non-command message in the session as the clarify answer — arbitrary prose vanished into clarify resolution and the agent appeared to ignore the user's thread messages. After the fix (``tools/clarify_gateway._coerce_text_response`` rejects arbitrary prose for native multi-choice prompts): * numeric selections ("2") and exact choice labels still resolve, and * arbitrary prose falls through the intercept and continues as a normal message-handling turn. Open-ended clarifies, explicit "Other" text-capture mode, and the base adapter's numbered-text fallback (which flips ``awaiting_text`` at send time) keep accepting free text. """ from unittest.mock import patch import pytest from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, MessageType, SendResult, ) from gateway.session import SessionSource SESSION_KEY = "agent:main:slack:dm:D123:1111.2222" class _StubAdapter(BasePlatformAdapter): def __init__(self): super().__init__(PlatformConfig(enabled=True, token="test"), Platform.SLACK) async def connect(self, *, is_reconnect: bool = False): 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="m1") async def get_chat_info(self, chat_id): return {"id": chat_id, "type": "im"} class _FellThroughIntercept(Exception): """Sentinel: _handle_message got PAST the clarify text-intercept.""" def _event(text): return MessageEvent( text=text, message_type=MessageType.TEXT, source=SessionSource( platform=Platform.SLACK, chat_id="D123", chat_type="dm", user_id="U1", thread_id="1111.2222", ), 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() def _make_runner(adapter): from gateway.run import GatewayRunner runner = GatewayRunner.__new__(GatewayRunner) runner._startup_restore_in_progress = False runner._scale_to_zero_note_real_inbound = lambda: None runner._is_user_authorized = lambda source: True runner._session_key_for_source = lambda source: SESSION_KEY runner._adapter_for_source = lambda source: adapter runner._update_prompt_pending = {} return runner async def _dispatch(runner, event): """Run _handle_message with a tripwire installed AFTER the clarify intercept (the slash-confirm pending lookup is the next statement), so a raised ``_FellThroughIntercept`` proves the message was NOT swallowed.""" import tools.slash_confirm as slash_confirm_mod def _tripwire(_key): raise _FellThroughIntercept() with patch("hermes_cli.plugins.invoke_hook", return_value=[]), \ patch.object(slash_confirm_mod, "get_pending", _tripwire): return await runner._handle_message(event) @pytest.mark.asyncio async def test_thread_prose_not_swallowed_by_native_multi_choice_clarify(): """Arbitrary prose during a pending button-clarify continues as a normal turn.""" _clear_clarify_state() from tools import clarify_gateway as cm adapter = _StubAdapter() runner = _make_runner(adapter) # Native interactive multi-choice prompt: awaiting_text stays False. entry = cm.register("cl-native", SESSION_KEY, "Pick a UI variant", ["buttons", "dropdown"]) assert entry.awaiting_text is False with pytest.raises(_FellThroughIntercept): await _dispatch(runner, _event("just checking the visual UI, no need to pass any data")) # The prose is not accepted as the answer, but the clarify must be # released before normal busy routing so redirect-to-steer can drain. with cm._lock: entry = cm._entries.get("cl-native") assert entry is not None assert entry.event.is_set() assert entry.response == "" _clear_clarify_state() @pytest.mark.asyncio async def test_thread_prose_does_not_overwrite_concurrent_button_choice(): """A button result that wins the race remains the clarify response.""" _clear_clarify_state() from tools import clarify_gateway as cm adapter = _StubAdapter() runner = _make_runner(adapter) entry = cm.register( "cl-button-race", SESSION_KEY, "Pick a UI variant", ["buttons", "dropdown"], ) assert cm.resolve_gateway_clarify("cl-button-race", "buttons") is True with pytest.raises(_FellThroughIntercept): await _dispatch(runner, _event("one more unrelated thought")) assert entry.event.is_set() assert entry.response == "buttons" _clear_clarify_state() @pytest.mark.asyncio async def test_native_multi_select_out_of_range_keeps_clarify_pending(): """Out-of-range multi-select numbers must not cancel the pending prompt.""" _clear_clarify_state() from tools import clarify_gateway as cm adapter = _StubAdapter() runner = _make_runner(adapter) entry = cm.register( "cl-ms-oor", SESSION_KEY, "Pick some targets", ["staging", "prod", "canary"], multi_select=True, ) assert entry.awaiting_text is False result = await _dispatch(runner, _event("99")) assert result == "" with cm._lock: still = cm._entries.get("cl-ms-oor") assert still is not None assert not still.event.is_set() assert still.response is None _clear_clarify_state() @pytest.mark.asyncio async def test_native_multi_select_bad_comma_list_keeps_clarify_pending(): """Unrecognised comma-lists are retryable selection attempts, not prose.""" _clear_clarify_state() from tools import clarify_gateway as cm adapter = _StubAdapter() runner = _make_runner(adapter) entry = cm.register( "cl-ms-bad", SESSION_KEY, "Pick some targets", ["staging", "prod", "canary"], multi_select=True, ) assert entry.awaiting_text is False result = await _dispatch(runner, _event("1,99")) assert result == "" with cm._lock: still = cm._entries.get("cl-ms-bad") assert still is not None assert not still.event.is_set() assert still.response is None _clear_clarify_state() @pytest.mark.asyncio async def test_native_multi_select_prose_releases_clarify_before_routing(): """Free prose on multi-select still breaks the redirect/steer deadlock.""" _clear_clarify_state() from tools import clarify_gateway as cm adapter = _StubAdapter() runner = _make_runner(adapter) cm.register( "cl-ms-prose", SESSION_KEY, "Pick some targets", ["staging", "prod"], multi_select=True, ) with pytest.raises(_FellThroughIntercept): await _dispatch( runner, _event("just checking the visual UI, no need to pass any data"), ) with cm._lock: entry = cm._entries.get("cl-ms-prose") assert entry is not None assert entry.event.is_set() assert entry.response == "" _clear_clarify_state() @pytest.mark.asyncio async def test_prose_still_accepted_after_other_flips_text_capture(): """After the user taps 'Other', free text IS the answer — must resolve.""" _clear_clarify_state() from tools import clarify_gateway as cm adapter = _StubAdapter() runner = _make_runner(adapter) cm.register("cl-other", SESSION_KEY, "Pick a UI variant", ["buttons", "dropdown"]) assert cm.mark_awaiting_text("cl-other") is True result = await _dispatch(runner, _event("a carousel actually")) assert result == "" with cm._lock: entry = cm._entries.get("cl-other") assert entry is not None assert entry.event.is_set() assert entry.response == "a carousel actually" _clear_clarify_state()