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

+95 -1
View File
@@ -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 ─────────────────────────────────────