fix(clarify): reject arbitrary prose for native interactive multi-choice clarifies

In Slack threads, ordinary follow-up messages sent while a native
multi-choice clarify was pending were consumed as clarify answers —
_coerce_text_response accepted arbitrary text for any pending entry, so
the gateway text-intercept swallowed unrelated thread messages and the
user's messages appeared to be ignored.

Tighten resolve_text_response_for_session for native interactive
multi-choice prompts (buttons rendered, awaiting_text=False):

- numeric selections ("2") still resolve to the canonical choice
- exact choice-label matches (case-insensitive) still resolve
- arbitrary prose is now REJECTED (returns False) so the message
  continues as a normal turn instead of vanishing into the clarify

Behavior is preserved everywhere free text is legitimately the answer:
open-ended clarifies, explicit 'Other' text-capture mode, and the base
adapter's numbered-text fallback (which flips awaiting_text at send
time).

Salvaged from PR #62042 by @liuhao1024.

Fixes #62034
This commit is contained in:
liuhao1024 2026-07-22 04:26:49 -07:00 committed by Teknium
parent 95aad9229b
commit 07cbb500b6
2 changed files with 98 additions and 16 deletions

View file

@ -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

View file

@ -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,
)