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

+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