diff --git a/tests/tools/test_clarify_gateway.py b/tests/tools/test_clarify_gateway.py index c617a4be6946..a7082708a6e5 100644 --- a/tests/tools/test_clarify_gateway.py +++ b/tests/tools/test_clarify_gateway.py @@ -82,14 +82,58 @@ class TestClarifyPrimitive: 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.""" + """Arbitrary typed text should resolve as a custom Other answer when awaiting_text is True.""" from tools import clarify_gateway as cm cm.register("id3d", "sk3d", "Pick", ["X", "Y"]) + # Flip to text-capture mode (user picked "Other") + cm.mark_awaiting_text("id3d") 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_resolve_text_rejects_arbitrary_prose_for_native_multi_choice(self): + """Native interactive multi-choice clarifies reject arbitrary prose unless awaiting_text is True.""" + from tools import clarify_gateway as cm + + # Native multi-choice (buttons, not awaiting text) + cm.register("id-strict", "sk-strict", "Pick one", ["A", "B", "C"]) + + # Arbitrary prose should be rejected + assert cm.resolve_text_response_for_session("sk-strict", "just checking the visual UI") is False + assert cm.resolve_text_response_for_session("sk-strict", "present 3 buttons") is False + + # Numeric choices should still work + assert cm.resolve_text_response_for_session("sk-strict", "2") is True + assert cm.wait_for_response("id-strict", timeout=0.1) == "B" + + # Exact label match should still work + cm.register("id-strict2", "sk-strict2", "Pick", ["Option Alpha", "Option Beta"]) + assert cm.resolve_text_response_for_session("sk-strict2", "Option Alpha") is True + assert cm.wait_for_response("id-strict2", timeout=0.1) == "Option Alpha" + + def test_text_fallback_mode_allows_any_text(self): + """Text fallback mode (after base send_clarify calls mark_awaiting_text) accepts any text.""" + from tools import clarify_gateway as cm + + entry = cm.register("id-tf", "sk-tf", "Pick one", ["A", "B", "C"]) + assert entry.awaiting_text is False + + # Simulate base send_clarify calling mark_awaiting_text + cm.mark_awaiting_text("id-tf") + assert entry.awaiting_text is True + + # Now arbitrary text is accepted + custom = "I choose a custom answer" + assert cm.resolve_text_response_for_session("sk-tf", custom) is True + assert cm.wait_for_response("id-tf", timeout=0.1) == custom + + # Numeric choices also work + cm.register("id-tf2", "sk-tf2", "Pick", ["X", "Y"]) + cm.mark_awaiting_text("id-tf2") + assert cm.resolve_text_response_for_session("sk-tf2", "1") is True + assert cm.wait_for_response("id-tf2", timeout=0.1) == "X" + 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 diff --git a/tools/clarify_gateway.py b/tools/clarify_gateway.py index 676530261b15..be527008a22d 100644 --- a/tools/clarify_gateway.py +++ b/tools/clarify_gateway.py @@ -187,30 +187,68 @@ def get_pending_for_session( return None -def _coerce_text_response(entry: _ClarifyEntry, response: str) -> str: - """Map typed choice replies to canonical choice text, otherwise keep custom text.""" +def _coerce_text_response(entry: _ClarifyEntry, response: str) -> Optional[str]: + """Map typed choice replies to canonical choice text, otherwise keep or reject custom text. + + For native interactive multi-choice clarifies (button UI, awaiting_text=False): + - Accept numeric selections ("2" → choice[1]) + - Accept exact choice label matches (case-insensitive) + - Reject arbitrary prose (return None) so the message continues as a normal turn + + For text fallback or awaiting_text mode: + - Accept any text (numeric/label/custom) after passing through coercion + + For open-ended clarifies (no choices): + - Accept any text + + Returns None when the response should be rejected (arbitrary prose for native multi-choice). + """ 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 + + if not entry.choices: + # Open-ended: accept any text + return text + + # Try numeric selection first (always valid for multi-choice) + try: + idx = int(text) - 1 + except ValueError: + idx = -1 + + if 0 <= idx < len(entry.choices): + return entry.choices[idx] + + # Try exact choice label match (always valid for multi-choice) + for choice in entry.choices: + if text.casefold() == str(choice).strip().casefold(): + return str(choice).strip() + + # For text fallback or awaiting_text mode, accept custom text + # For native interactive multi-choice mode, reject arbitrary prose + if entry.awaiting_text: + return text + + return None def resolve_text_response_for_session(session_key: str, response: str) -> bool: - """Resolve the oldest pending clarify in ``session_key`` from typed text.""" + """Resolve the oldest pending clarify in ``session_key`` from typed text. + + Returns False if no pending clarify exists or if the response was rejected + (arbitrary prose for native interactive multi-choice clarifies). + """ entry = get_pending_for_session(session_key, include_choice_prompts=True) if entry is None: return False + + coerced = _coerce_text_response(entry, response) + if coerced is None: + # Response rejected: message should continue as a normal turn + return False + return resolve_gateway_clarify( entry.clarify_id, - _coerce_text_response(entry, response), + coerced, )