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