Render single-select clarify prompts as interactive pickers
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:
1 parent
82c5a20848
commit
1ff2ef380c
2 files changed
+175
-17
No files matched your search
+80
-16
@@ -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 '
|
||||
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user