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