Add interactive choice-picker menus for finite-choice slash commands
CI / Gateway plugin tests (push) Successful in 4m48s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s

Slash commands with a finite set of options (/reasoning, /fast, ...) now
render a tappable card with buttons (2 per row, ✓ on the current value)
instead of a plain text status card. The mechanism is generic: any command
that calls the adapter's send_choice_picker() gets a picker automatically.

Wire protocol (docs/04, frames.schema.json):
- picker.choice (server→app): {picker_id, title, choices[]}
- picker.select (app→server): {picker_id, value}
- pickers capability flag now True in server_caps

gateway-plugin:
- protocol.py: picker.choice/picker.select frame types + picker_choice()
- dispatch.py: route picker.select → adapter.on_picker_select
- adapter.py: send_choice_picker() (fails cleanly with no live device so
  hermes falls back to text), on_picker_select(), in-memory pending pickers
  (gateway restart expires them; stale select is a no-op), pickers=True

app (KMP):
- Protocol.kt: PickerChoice/PickerChoicePayload + pickerSelectFrame()
- ChatStore.kt: PickerItem + onPickerChoice (idempotent) + resolvePicker
  (optimistic, one-shot)
- ChatDb.kt: persist PickerItem in the messages table (polymorphic decode)
- IrisController.kt: picker.choice routing + selectPicker() action
- ChatScreen.kt: PickerCard composable (locks after selection)

Tests:
- python: 3 picker tests (roundtrip, no-device fallback, stale-select noop)
- kotlin: ChatStorePickerTest (add/idempotent/resolve/one-shot/noop/serialize)
- fixture fix: clear leaked IRIS_HTTP_PORT/IRIS_WS_HOST env so the adapter
  binds the ephemeral port (a prior test's interactive_setup() polluted the
  process env, colliding with a live gateway on 8791)

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:33:53 +02:00
1 parent 34a64d6e53
commit 82c5a20848
12 files changed
+565 -8

No files matched your search

+80 -1
View File
@@ -562,6 +562,10 @@ def _mint_message_id() -> str:
return f"m_{uuid.uuid4().hex[:16]}"
def _mint_picker_id() -> str:
return f"pc_{uuid.uuid4().hex[:16]}"
def _thread_id_from_metadata(metadata: dict[str, Any] | None) -> str | None:
if not metadata:
return None
@@ -1212,6 +1216,10 @@ class IrisAdapter(BasePlatformAdapter):
# last finalized assistant message id per chat (offer association).
self._media = media_bridge.MediaStore(get_hermes_home())
self._last_message_id: dict[str, str] = {}
# Interactive choice pickers (slash-command menus, e.g. /reasoning,
# /fast): picker_id -> pending state. In-memory only — a gateway
# restart expires them (a stale picker.select is a no-op).
self._pending_pickers: dict[str, dict] = {}
# M5: push backend (FCM primary, ntfy fallback) + the throttle for
# the outbox-prune banner.
self._push: PushBackend = build_push_backend(
@@ -2725,6 +2733,40 @@ class IrisAdapter(BasePlatformAdapter):
return
logger.info("iris: push tokens updated for %s", device_id)
# ── Interactive pickers (slash-command choice menus) ─────────────────
async def on_picker_select(self, frame: protocol.Frame, device_id: str) -> None:
"""Resolve a pending choice picker (``picker.select`` from the app).
Runs the command's selection callback and delivers its reply text as
a normal final message in the picker's chat. Unknown/expired picker
ids (gateway restart, double tap) are a no-op — the app already
marked the card resolved locally.
"""
picker_id = frame.payload.get("picker_id")
value = frame.payload.get("value")
if not isinstance(picker_id, str) or not isinstance(value, str):
return
state = self._pending_pickers.pop(picker_id, None)
if state is None:
logger.info("iris: picker.select for unknown/expired picker %s", picker_id)
return
callback = state.get("on_choice_selected")
if callback is None:
return
try:
result_text = await callback(state["chat_id"], value)
except Exception:
logger.error("iris: picker selection failed for %s", picker_id, exc_info=True)
return
if not result_text:
return
await self.send(
state["chat_id"],
str(result_text),
metadata={"notify": True, "thread_id": state.get("thread_id")},
)
# ── M5: approval / clarify banners ────────────────────────────────────
async def send_slash_confirm(
@@ -2757,6 +2799,43 @@ class IrisAdapter(BasePlatformAdapter):
chat_id, title, message, session_key, confirm_id, metadata=metadata
)
async def send_choice_picker(
self,
chat_id: str,
title: str,
choices: list,
session_key: str,
on_choice_selected,
metadata: dict[str, Any] | None = None,
) -> SendResult:
"""Send an interactive choice picker (one tap → one value).
The generic companion to Telegram's inline-keyboard pickers, used by
``/reasoning``, ``/fast``, and any future finite-choice slash command
(hermes detects this method on the adapter type). Emits a
``picker.choice`` frame; the app answers with ``picker.select``,
which runs ``on_choice_selected(chat_id, value)`` and delivers the
returned text as a normal message. Outboxed, so a reconnecting
device re-renders a still-pending picker.
With no live device the picker could never be answered, so report
failure and let hermes fall back to the text status card.
"""
if not self._http_server.has_devices():
return SendResult(success=False, error="no live devices for picker")
thread_id = _thread_id_from_metadata(metadata)
picker_id = _mint_picker_id()
self._pending_pickers[picker_id] = {
"chat_id": chat_id,
"thread_id": thread_id,
"on_choice_selected": on_choice_selected,
}
await self._broadcast_or_log(
chat_id,
protocol.picker_choice(picker_id, title, choices, chat_id, thread_id=thread_id),
)
return SendResult(success=True, message_id=picker_id)
async def send_clarify(
self,
chat_id: str,
@@ -2873,7 +2952,7 @@ class IrisAdapter(BasePlatformAdapter):
"push_ntfy_server": (
self._push.server_url if isinstance(self._push, NtfyBackend) else ""
),
"pickers": False, # M2+
"pickers": True, # picker.choice / picker.select (slash-command menus)
}
def channel_list(self) -> list[dict[str, Any]]:
+2
View File
@@ -57,6 +57,8 @@ async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) ->
await adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_FCM_REGISTER:
await adapter.on_fcm_register(frame, device_id)
elif frame.type == protocol.TYPE_PICKER_SELECT:
await adapter.on_picker_select(frame, device_id)
# Unknown types are ignored (forward-compat).
+35
View File
@@ -71,6 +71,10 @@ TYPE_SEARCH_RESULTS = "search.results"
# Slash-command catalog (app's "/" drawer)
TYPE_COMMANDS_CATALOG = "commands.catalog"
# Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
TYPE_PICKER_CHOICE = "picker.choice"
TYPE_PICKER_SELECT = "picker.select"
# Reconnect catch-up (M3 outbox; extended by M5 push)
TYPE_SYNC = "sync"
TYPE_SYNC_DONE = "sync.done"
@@ -606,6 +610,37 @@ def commands_catalog(commands: list[dict[str, Any]], *, id: int | None = None) -
)
# ---------------------------------------------------------------------------
# Picker frames (interactive slash-command choice menus)
# ---------------------------------------------------------------------------
def picker_choice(
picker_id: str,
title: str,
choices: list[dict[str, Any]],
chat_id: str,
*,
thread_id: str | None = None,
) -> Frame:
"""Event: an interactive choice picker (one tap → one value).
Used by slash commands with a finite option set (``/reasoning``,
``/fast``, …) on platforms that support pickers. The app renders the
title + choice buttons and answers with a ``picker.select`` frame
carrying the same ``picker_id``. Outboxed, so a reconnecting device
re-renders a still-pending picker.
Each choice: ``{"value": str, "label": str, "is_current": bool}``.
"""
return Frame(
type=TYPE_PICKER_CHOICE,
chat_id=chat_id,
thread_id=thread_id,
payload={"picker_id": picker_id, "title": title, "choices": choices},
)
# ---------------------------------------------------------------------------
# Sync frames (M3 outbox)
# ---------------------------------------------------------------------------
+92 -1
View File
@@ -97,6 +97,13 @@ def plugin():
def adapter(plugin, monkeypatch):
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
# Clear any IRIS transport overrides leaked into the process env by earlier
# tests (e.g. test_interactive_setup_prints_qr runs the real
# interactive_setup(), which save_env_value()s IRIS_HTTP_PORT/IRIS_WS_HOST).
# Without this, a later adapter would bind the leaked port (8791) instead of
# the ephemeral 0 below, colliding with a live gateway on that port.
monkeypatch.delenv("IRIS_HTTP_PORT", raising=False)
monkeypatch.delenv("IRIS_WS_HOST", raising=False)
from gateway.platform_registry import PlatformEntry, platform_registry
# Platform("iris") resolves only once the platform is registered
@@ -2397,4 +2404,88 @@ async def test_commands_catalog_frame_roundtrip(plugin, adapter, ws_client):
for c in commands:
assert c["name"].startswith("/")
assert "description" in c and "args_hint" in c and "category" in c
assert isinstance(c["aliases"], list)
assert isinstance(c["aliases"], list)
# ── Interactive pickers (picker.choice / picker.select) ───────────────────
@pytest.mark.asyncio
async def test_choice_picker_roundtrip(plugin, adapter, ws_client):
"""send_choice_picker emits a picker.choice frame; a picker.select from
the app runs the selection callback and delivers its reply as a normal
final message. The picker is consumed (a second select is a no-op)."""
ws, _ = ws_client
picked: list = []
async def on_choice(chat_id, value):
picked.append((chat_id, value))
return f"picked {value}"
res = await adapter.send_choice_picker(
"default",
"Pick one",
[
{"value": "a", "label": "A", "is_current": False},
{"value": "b", "label": "B", "is_current": True},
],
"sess",
on_choice,
)
assert res.success
frames = await recv_until(ws, lambda f: f.get("type") == "picker.choice")
pc = frames[-1]
assert pc["chat_id"] == "default"
pid = pc["payload"]["picker_id"]
assert pid == res.message_id
assert pc["payload"]["title"] == "Pick one"
assert pc["payload"]["choices"][1]["is_current"] is True
# The app taps a button -> picker.select.
await ws.send(
json.dumps(
{"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "b"}}
)
)
assert picked == [("default", "b")]
# The callback's reply arrives as a normal final assistant message.
frames2 = await recv_until(
ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "picked b"
)
assert frames2[-1]["payload"]["role"] == "assistant"
# The picker is consumed: a second select for the same id is a no-op.
await ws.send(
json.dumps(
{"type": "picker.select", "id": 2, "payload": {"picker_id": pid, "value": "a"}}
)
)
await asyncio.sleep(0.2)
assert picked == [("default", "b")]
@pytest.mark.asyncio
async def test_choice_picker_requires_live_device(adapter):
"""With no live device the picker could never be answered, so the adapter
reports failure and hermes falls back to the text status card."""
res = await adapter.send_choice_picker(
"default",
"t",
[{"value": "a", "label": "A"}],
"s",
lambda c, v: "x",
)
assert not res.success
assert not adapter._pending_pickers
@pytest.mark.asyncio
async def test_picker_select_unknown_id_is_noop(plugin, adapter, ws_client):
"""A stale picker.select (gateway restart expired the pending state) is
ignored without error."""
ws, _ = ws_client
await ws.send(
json.dumps(
{"type": "picker.select", "id": 1, "payload": {"picker_id": "pc_nope", "value": "a"}}
)
)
await asyncio.sleep(0.2)
assert not adapter._pending_pickers