Add interactive choice-picker menus for finite-choice slash commands
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:
1 parent
34a64d6e53
commit
82c5a20848
12 files changed
+565
-8
No files matched your search
@@ -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]]:
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user