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
@@ -0,0 +1,108 @@
|
||||
package iris.data
|
||||
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.PickerChoice
|
||||
import iris.protocol.PickerChoicePayload
|
||||
import iris.protocol.TYPE_PICKER_CHOICE
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ChatStorePickerTest {
|
||||
private fun pickerFrame(
|
||||
pickerId: String,
|
||||
title: String = "Reasoning",
|
||||
chatId: String = "chan_1",
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_PICKER_CHOICE,
|
||||
chatId = chatId,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
PickerChoicePayload.serializer(),
|
||||
PickerChoicePayload(
|
||||
pickerId = pickerId,
|
||||
title = title,
|
||||
choices =
|
||||
listOf(
|
||||
PickerChoice(value = "low", label = "Low"),
|
||||
PickerChoice(value = "high", label = "High", isCurrent = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun onPickerChoiceAddsItem() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
val items = store.lanes.value["chan_1"]!!
|
||||
assertEquals(1, items.size)
|
||||
val picker = assertIs<PickerItem>(items[0])
|
||||
assertEquals("pk_1", picker.id)
|
||||
assertEquals("Reasoning", picker.title)
|
||||
assertEquals(2, picker.choices.size)
|
||||
assertNull(picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onPickerChoiceIsIdempotent() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.onFrame(pickerFrame("pk_1")) // duplicate (e.g. outbox re-delivery)
|
||||
val items = store.lanes.value["chan_1"]!!
|
||||
assertEquals(1, items.size)
|
||||
assertEquals("pk_1", items[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePickerMarksSelected() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.resolvePicker("pk_1", "low")
|
||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
||||
assertEquals("low", picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePickerIsOneShot() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.resolvePicker("pk_1", "low")
|
||||
store.resolvePicker("pk_1", "high") // second tap is ignored
|
||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
||||
assertEquals("low", picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePickerUnknownIdIsNoop() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.resolvePicker("pk_missing", "low") // stale select after restart
|
||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
||||
assertNull(picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickerItemSerializationRoundTrip() {
|
||||
val item =
|
||||
PickerItem(
|
||||
id = "pk_1",
|
||||
title = "Reasoning",
|
||||
choices =
|
||||
listOf(
|
||||
PickerChoice(value = "low", label = "Low"),
|
||||
PickerChoice(value = "high", label = "High", isCurrent = true),
|
||||
),
|
||||
ts = 1234L,
|
||||
selected = "high",
|
||||
)
|
||||
val json = IrisJson.instance.encodeToString(PickerItem.serializer(), item)
|
||||
val back = IrisJson.instance.decodeFromString(PickerItem.serializer(), json)
|
||||
assertEquals(item, back)
|
||||
assertTrue(back.choices[1].isCurrent)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user