Render single-select clarify prompts as interactive pickers
CI / Gateway plugin tests (push) Successful in 5m2s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m34s

Clarify questions with a finite option set now render as the same
tappable picker card used by /reasoning and /fast, instead of a numbered
text list. The change is gateway-only: it reuses the existing
picker.choice frame and the app's PickerCard UI, so no app change or
reinstall is needed.

- send_clarify: single-select + live device emits a picker.choice frame
  (one button per option + an "Other (type your answer)" button) and
  registers a pending picker; the selection resolves via
  resolve_gateway_clarify (the agent then continues and replies).
- "Other" flips the entry to text-capture (mark_awaiting_text) and
  prompts the user to type; an unmappable value also flips to text so a
  clarify never dead-ends.
- Multi-select, open-ended, and no-live-device clarifies keep the
  numbered-text fallback (unchanged behavior).
- New helpers _clarify_is_multi / _clarify_picker_callback; positional
  option values (c0..cN, other) mirror the relay adapter.

Tests: updated test_clarify_emits_banner_and_message to multi-select
(text fallback) + 3 new tests (single-select emits picker & resolves,
Other flips to text, no-device falls back to text). Full suite 90/90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ARIAandClaude Opus 4.8 committed 2026-08-22 23:51:14 +02:00
1 parent 82c5a20848
commit 1ff2ef380c
2 files changed
+175 -17

No files matched your search

+80 -16
View File
@@ -566,6 +566,51 @@ def _mint_picker_id() -> str:
return f"pc_{uuid.uuid4().hex[:16]}" 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: def _thread_id_from_metadata(metadata: dict[str, Any] | None) -> str | None:
if not metadata: if not metadata:
return None return None
@@ -2847,10 +2892,14 @@ class IrisAdapter(BasePlatformAdapter):
) -> SendResult: ) -> SendResult:
"""Banner + push for a clarify prompt. """Banner + push for a clarify prompt.
Renders the prompt as a proper ``message`` frame (the base text Single-select clarifies with a live device render as an interactive
fallback would route through ``send()`` and be misclassified as ``picker.choice`` card (one tap per option + an "Other" free-text
commentary/tool progress) and keeps the gateway's text intercept button), reusing the slash-command picker mechanism. A real pick
working via ``mark_awaiting_text``. 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) thread_id = _thread_id_from_metadata(metadata)
await self._broadcast_or_log( await self._broadcast_or_log(
@@ -2863,23 +2912,38 @@ class IrisAdapter(BasePlatformAdapter):
thread_id=thread_id, thread_id=thread_id,
), ),
) )
if choices: # Single-select + live device → interactive picker card.
# Multi-select clarifies register their flag on the pending entry; if choices and not _clarify_is_multi(clarify_id) and self._http_server.has_devices():
# look it up by id (mirrors the base text fallback). picker_id = _mint_picker_id()
_is_multi = False self._pending_pickers[picker_id] = {
try: "chat_id": chat_id,
from tools import clarify_gateway as _cg "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: # Text fallback (multi-select / open-ended / no live device).
_entry = _cg._entries.get(clarify_id) if choices:
_is_multi = bool(_entry and getattr(_entry, "multi_select", False))
except Exception:
_is_multi = False
lines = [f"❓ {question}", ""] lines = [f"❓ {question}", ""]
for i, choice in enumerate(choices, start=1): for i, choice in enumerate(choices, start=1):
lines.append(f" {i}. {choice}") lines.append(f" {i}. {choice}")
lines.append("") lines.append("")
if _is_multi: if _clarify_is_multi(clarify_id):
lines.append( lines.append(
"Multiple selections allowed — reply with the numbers " "Multiple selections allowed — reply with the numbers "
'separated by commas or spaces (e.g. "1, 3"), the option ' 'separated by commas or spaces (e.g. "1, 3"), the option '
+95 -1
View File
@@ -1945,7 +1945,9 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
ws, _ = ws_client ws, _ = ws_client
from tools import clarify_gateway as cg 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: try:
res = await adapter.send_clarify( res = await adapter.send_clarify(
"default", "Which one?", ["A", "B"], "cl_1", "sk" "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) 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 ───────────────────────────────────── # ── M5: sync catch-up + outbox row cap ─────────────────────────────────────