diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index c80d09f..6943075 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -566,6 +566,51 @@ def _mint_picker_id() -> str: return f"pc_{uuid.uuid4().hex[:16]}" +def _clarify_is_multi(clarify_id: str) -> bool: + """True when the pending clarify [clarify_id] allows multiple selections. + + The flag lives on the gateway's pending entry; a missing/expired entry (or + any lookup error) is treated as single-select. + """ + try: + from tools import clarify_gateway as _cg + + with _cg._lock: + _entry = _cg._entries.get(clarify_id) + return bool(_entry and getattr(_entry, "multi_select", False)) + except Exception: + return False + + +def _clarify_picker_callback(clarify_id: str, choices: list[str]): + """Build the ``on_choice_selected`` callback for a clarify picker. + + Option values are positional (``c0``..``cN``) plus an ``other`` sentinel; + the closure maps them back to the real choice strings. A real pick resolves + the clarify (the agent then continues and replies); "Other" flips the entry + to text-capture so the next typed message is the answer. An unmappable value + also flips to text so a clarify never dead-ends. + """ + + async def on_choice_selected(chat_id: str, value: str) -> str | None: + from tools.clarify_gateway import mark_awaiting_text, resolve_gateway_clarify + + if value == "other": + mark_awaiting_text(clarify_id) + return "✏️ Type your answer:" + try: + idx = int(value[1:]) if value.startswith("c") else -1 + except ValueError: + idx = -1 + if 0 <= idx < len(choices): + resolve_gateway_clarify(clarify_id, choices[idx]) + return None + mark_awaiting_text(clarify_id) + return "✏️ Type your answer:" + + return on_choice_selected + + def _thread_id_from_metadata(metadata: dict[str, Any] | None) -> str | None: if not metadata: return None @@ -2847,10 +2892,14 @@ class IrisAdapter(BasePlatformAdapter): ) -> SendResult: """Banner + push for a clarify prompt. - Renders the prompt as a proper ``message`` frame (the base text - fallback would route through ``send()`` and be misclassified as - commentary/tool progress) and keeps the gateway's text intercept - working via ``mark_awaiting_text``. + Single-select clarifies with a live device render as an interactive + ``picker.choice`` card (one tap per option + an "Other" free-text + button), reusing the slash-command picker mechanism. A real pick + resolves via ``resolve_gateway_clarify`` (the agent then continues and + replies); "Other" flips the entry to text-capture. Multi-select, + open-ended, and no-live-device clarifies fall back to a numbered text + list whose reply the gateway's text-intercept captures via + ``mark_awaiting_text``. """ thread_id = _thread_id_from_metadata(metadata) await self._broadcast_or_log( @@ -2863,23 +2912,38 @@ class IrisAdapter(BasePlatformAdapter): thread_id=thread_id, ), ) - if choices: - # Multi-select clarifies register their flag on the pending entry; - # look it up by id (mirrors the base text fallback). - _is_multi = False - try: - from tools import clarify_gateway as _cg + # Single-select + live device → interactive picker card. + if choices and not _clarify_is_multi(clarify_id) and self._http_server.has_devices(): + picker_id = _mint_picker_id() + self._pending_pickers[picker_id] = { + "chat_id": chat_id, + "thread_id": thread_id, + "on_choice_selected": _clarify_picker_callback( + clarify_id, [str(c) for c in choices] + ), + } + frame_choices = [ + {"value": f"c{i}", "label": str(c)[:75], "is_current": False} + for i, c in enumerate(choices) + ] + frame_choices.append( + {"value": "other", "label": "✏️ Other (type your answer)", "is_current": False} + ) + await self._broadcast_or_log( + chat_id, + protocol.picker_choice( + picker_id, f"❓ {question}", frame_choices, chat_id, thread_id=thread_id + ), + ) + return SendResult(success=True, message_id=picker_id) - with _cg._lock: - _entry = _cg._entries.get(clarify_id) - _is_multi = bool(_entry and getattr(_entry, "multi_select", False)) - except Exception: - _is_multi = False + # Text fallback (multi-select / open-ended / no live device). + if choices: lines = [f"❓ {question}", ""] for i, choice in enumerate(choices, start=1): lines.append(f" {i}. {choice}") lines.append("") - if _is_multi: + if _clarify_is_multi(clarify_id): lines.append( "Multiple selections allowed — reply with the numbers " 'separated by commas or spaces (e.g. "1, 3"), the option ' diff --git a/gateway-plugin/tests/test_android.py b/gateway-plugin/tests/test_android.py index e78f487..f257a9c 100644 --- a/gateway-plugin/tests/test_android.py +++ b/gateway-plugin/tests/test_android.py @@ -1945,7 +1945,9 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client): ws, _ = ws_client from tools import clarify_gateway as cg - cg.register("cl_1", "sk", "Which one?", ["A", "B"]) + # Multi-select forces the numbered-text fallback (single-select with a live + # device now renders a picker -- see test_clarify_single_select_emits_picker). + cg.register("cl_1", "sk", "Which one?", ["A", "B"], multi_select=True) try: res = await adapter.send_clarify( "default", "Which one?", ["A", "B"], "cl_1", "sk" @@ -1965,6 +1967,98 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client): cg._session_index.pop("sk", None) +@pytest.mark.asyncio +async def test_clarify_single_select_emits_picker(plugin, adapter, ws_client): + """Single-select clarify with a live device renders as a picker.choice + card (options + Other). Tapping an option resolves the clarify with the + choice text; the picker is consumed.""" + ws, _ = ws_client + from tools import clarify_gateway as cg + + cg.register("cl_pk1", "sk", "Which one?", ["A", "B"]) + try: + res = await adapter.send_clarify("default", "Which one?", ["A", "B"], "cl_pk1", "sk") + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "picker.choice") + pc = frames[-1] + pid = pc["payload"]["picker_id"] + assert pid == res.message_id + assert pc["payload"]["title"] == "❓ Which one?" + assert [c["value"] for c in pc["payload"]["choices"]] == ["c0", "c1", "other"] + assert [c["label"] for c in pc["payload"]["choices"]] == [ + "A", + "B", + "✏️ Other (type your answer)", + ] + # Tap "A" (c0) -> resolves the clarify with the choice text. + await ws.send( + json.dumps( + {"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "c0"}} + ) + ) + entry = cg._entries.get("cl_pk1") + for _ in range(50): + if entry.event.is_set(): + break + await asyncio.sleep(0.05) + assert entry.event.is_set() + assert entry.response == "A" + assert pid not in adapter._pending_pickers + finally: + cg._entries.pop("cl_pk1", None) + cg._session_index.pop("sk", None) + + +@pytest.mark.asyncio +async def test_clarify_picker_other_flips_to_text(plugin, adapter, ws_client): + """Tapping 'Other' on a clarify picker flips the entry to text-capture + (awaiting_text) and prompts the user to type; the clarify is NOT resolved.""" + ws, _ = ws_client + from tools import clarify_gateway as cg + + cg.register("cl_pk2", "sk", "Which one?", ["A", "B"]) + try: + res = await adapter.send_clarify("default", "Which one?", ["A", "B"], "cl_pk2", "sk") + assert res.success + frames = await recv_until(ws, lambda f: f.get("type") == "picker.choice") + pid = frames[-1]["payload"]["picker_id"] + await ws.send( + json.dumps( + {"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "other"}} + ) + ) + frames2 = await recv_until( + ws, + lambda f: f.get("type") == "message" + and "Type your answer" in f["payload"].get("text", ""), + ) + assert frames2[-1]["payload"]["role"] == "assistant" + entry = cg._entries.get("cl_pk2") + assert entry.awaiting_text is True + assert not entry.event.is_set() + finally: + cg._entries.pop("cl_pk2", None) + cg._session_index.pop("sk", None) + + +@pytest.mark.asyncio +async def test_clarify_single_select_no_device_falls_back_to_text(adapter): + """Single-select clarify with NO live device falls back to the numbered + text list (no picker) and enables text-capture.""" + from tools import clarify_gateway as cg + + cg.register("cl_pk3", "sk", "Which one?", ["A", "B"]) + try: + res = await adapter.send_clarify("default", "Which one?", ["A", "B"], "cl_pk3", "sk") + assert res.success + assert not adapter._pending_pickers + entry = cg._entries.get("cl_pk3") + assert entry.awaiting_text is True + finally: + cg._entries.pop("cl_pk3", None) + cg._session_index.pop("sk", None) + + # ── M5: sync catch-up + outbox row cap ─────────────────────────────────────