fix: accept typed clarify choice replies

This commit is contained in:
tymrtn 2026-06-03 23:22:21 +02:00 committed by Teknium
parent 9bb5a809b5
commit d7f655f370
5 changed files with 174 additions and 22 deletions

View file

@ -4410,11 +4410,11 @@ class BasePlatformAdapter(ABC):
logger.error("[%s] Command '/%s' dispatch failed: %s", self.name, cmd, e, exc_info=True)
return
# Clarify text-capture bypass: if the agent is blocked on a
# clarify_tool call awaiting a free-form text response (open-
# ended clarify, or user picked "Other"), the next non-command
# message in this session MUST reach the runner so the
# clarify-intercept can resolve it and unblock the agent.
# Clarify reply bypass: if the agent is blocked on a
# clarify_tool call, the next non-command message in this
# session MUST reach the runner so typed numeric choices,
# exact choices, and free-form "Other" answers can resolve the
# clarify-intercept and unblock the agent.
#
# Without this bypass: the message gets queued in
# _pending_messages as a follow-up turn instead of reaching the
@ -4427,7 +4427,10 @@ class BasePlatformAdapter(ABC):
try:
from tools import clarify_gateway as _clarify_mod
_has_text_clarify = (
_clarify_mod.get_pending_for_session(session_key) is not None
_clarify_mod.get_pending_for_session(
session_key,
include_choice_prompts=True,
) is not None
)
except Exception:
_has_text_clarify = False

View file

@ -8037,26 +8037,28 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
_update_prompts.pop(_quick_key, None)
# Intercept messages that are responses to a pending clarify
# request that is awaiting free-form text (either an open-ended
# clarify with no choices, or one where the user picked the
# "Other" button). The first non-empty user message in the
# session resolves the clarify and unblocks the agent thread —
# we do NOT route it to the agent as a new turn.
# Intercept messages that are responses to a pending clarify.
# Open-ended prompts and "Other" responses are captured as free text;
# direct replies to multi-choice prompts are accepted too ("2" maps
# to the second option, arbitrary text becomes a custom answer). Slash
# commands still bypass this path so /stop and friends keep working.
_clarify_mod = None
try:
from tools import clarify_gateway as _clarify_mod
_pending_clarify = _clarify_mod.get_pending_for_session(_quick_key)
_pending_clarify = _clarify_mod.get_pending_for_session(
_quick_key, include_choice_prompts=True,
)
except Exception:
_pending_clarify = None
if _pending_clarify is not None:
if _pending_clarify is not None and _clarify_mod is not None:
_raw_clarify_reply = (event.text or "").strip()
# Skip slash commands — the user clearly wanted to issue a
# command, not answer the clarify. Leave the clarify pending
# so the user can retry; if it times out, the agent unblocks
# with an empty response.
if _raw_clarify_reply and not _raw_clarify_reply.startswith("/"):
_resolved = _clarify_mod.resolve_gateway_clarify(
_pending_clarify.clarify_id, _raw_clarify_reply,
_resolved = _clarify_mod.resolve_text_response_for_session(
_quick_key, _raw_clarify_reply,
)
if _resolved:
logger.info(

View file

@ -0,0 +1,87 @@
"""Regression tests for clarify replies while a gateway session is busy."""
import asyncio
from unittest.mock import AsyncMock
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
from gateway.session import SessionSource, build_session_key
class _ClarifyBypassAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="test"), Platform.TELEGRAM)
async def connect(self):
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="text")
async def get_chat_info(self, chat_id):
return {"id": chat_id, "type": "private"}
def _event(text="custom answer"):
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=SessionSource(
platform=Platform.TELEGRAM,
chat_id="12345",
chat_type="private",
user_id="user1",
),
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()
@pytest.mark.asyncio
async def test_active_session_routes_typed_choice_clarify_reply_to_runner_not_busy_queue():
"""Typed text must resolve a pending choice clarify even while the agent is busy.
Telegram button clarifies keep the adapter session active while the agent
thread blocks on ``wait_for_response``. If the adapter only bypasses for
entries already marked ``awaiting_text``, typed replies to the visible
multi-choice prompt are handled as busy follow-ups and the clarify wait is
never resolved.
"""
_clear_clarify_state()
from tools import clarify_gateway as cm
adapter = _ClarifyBypassAdapter()
adapter._message_handler = AsyncMock(return_value="")
adapter._busy_session_handler = AsyncMock(return_value=True)
event = _event("None of those are valid options")
session_key = build_session_key(
event.source,
group_sessions_per_user=adapter.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=adapter.config.extra.get("thread_sessions_per_user", False),
)
adapter._active_sessions[session_key] = asyncio.Event()
cm.register("clarify-1", session_key, "Pick one", ["A", "B"])
await adapter.handle_message(event)
adapter._message_handler.assert_awaited_once_with(event)
adapter._busy_session_handler.assert_not_awaited()
assert adapter._pending_messages == {}

View file

@ -64,6 +64,32 @@ class TestClarifyPrimitive:
assert entry.awaiting_text is False
assert cm.get_pending_for_session("sk3") is None
def test_include_choice_prompts_returns_multi_choice_entry(self):
"""Gateway typed replies must see active choice prompts too."""
from tools import clarify_gateway as cm
cm.register("id3b", "sk3b", "Pick", ["X", "Y"])
pending = cm.get_pending_for_session("sk3b", include_choice_prompts=True)
assert pending is not None
assert pending.clarify_id == "id3b"
def test_resolve_text_response_maps_numeric_choice(self):
"""Typed numbers should resolve to the canonical choice string."""
from tools import clarify_gateway as cm
cm.register("id3c", "sk3c", "Pick", ["X", "Y"])
assert cm.resolve_text_response_for_session("sk3c", "2") is True
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."""
from tools import clarify_gateway as cm
cm.register("id3d", "sk3d", "Pick", ["X", "Y"])
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_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

@ -162,12 +162,19 @@ def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:
return True
def get_pending_for_session(session_key: str) -> Optional[_ClarifyEntry]:
"""Return the OLDEST pending clarify entry for a session, or None.
def get_pending_for_session(
session_key: str,
*,
include_choice_prompts: bool = False,
) -> Optional[_ClarifyEntry]:
"""Return the oldest pending clarify entry for a session, or None.
Used by the text-fallback intercept in ``_handle_message`` when a
clarify is awaiting a free-form text response, the next user message
in that session is captured as the answer.
By default this only returns entries awaiting free-form text (open-ended
clarifies, or a multi-choice clarify after the user picked ``Other``).
Gateways may pass ``include_choice_prompts=True`` when the user has typed
directly in response to an active multi-choice prompt; in that case the
oldest unresolved clarify is returned so the text can resolve it instead
of being queued as an unrelated follow-up turn.
"""
with _lock:
ids = _session_index.get(session_key) or []
@ -175,11 +182,38 @@ def get_pending_for_session(session_key: str) -> Optional[_ClarifyEntry]:
entry = _entries.get(cid)
if entry is None:
continue
if entry.awaiting_text:
if include_choice_prompts or entry.awaiting_text:
return entry
return None
def _coerce_text_response(entry: _ClarifyEntry, response: str) -> str:
"""Map typed choice replies to canonical choice text, otherwise keep custom text."""
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
def resolve_text_response_for_session(session_key: str, response: str) -> bool:
"""Resolve the oldest pending clarify in ``session_key`` from typed text."""
entry = get_pending_for_session(session_key, include_choice_prompts=True)
if entry is None:
return False
return resolve_gateway_clarify(
entry.clarify_id,
_coerce_text_response(entry, response),
)
def mark_awaiting_text(clarify_id: str) -> bool:
"""Flip an entry into text-capture mode (user picked the 'Other' button).