Carry a question in the event model, not in one provider's JSON

A question is now fully described by the event that reports it: the tag
it was asked under, each option's label, what it means, and the sample of
what picking it would produce, plus whether several may be picked at
once. The app renders from that alone.

It had been reading Claude Code's tool input to find the parts the event
dropped -- that dialect's schema, written out a second time in Kotlin,
where no other provider could reach it and where it would drift the
first time the schema moved. Echo could not describe an option at all,
and llama never will.

Answers travel as a list for the same reason. A question that takes one
answer sends a list of one rather than being a different shape, and the
one place that flattens it is where the CLI is spoken to: its answers
map holds a string, so several choices are joined there. That join was
in the phone.

Also here because it is the same rule: the permission ask reuses the
question body rather than owning a second one, so Allow/Deny renders and
resolves through exactly the code an AskUserQuestion does.

Verified against both, since a refactor that only satisfies the case it
was written for has been tried on the half that cannot fail: a two
question `/ask` answered from the phone, one option and then two, and a
real sonnet session's `rm -f` permission asked, allowed, and run.
This commit is contained in:
iris committed 2026-08-29 16:46:43 -04:00
1 parent fea8e7e92b
commit bebaae7a94
13 files changed
+419 -237

No files matched your search

@@ -460,17 +460,28 @@ fun fetchUsage(settings: ServerSettings): List<UsageSnapshot> =
}
}
/**
* Answers one question with everything that was chosen.
*
* A list even when one thing was picked, because that is the shape of the answer rather than a
* special case of it. What a provider makes of several answers is its own business and is decided
* on the server; nothing here joins, splits or reformats them for one.
*/
fun answerQuestion(
settings: ServerSettings,
sessionId: String,
questionId: String,
answer: String,
answers: List<String>,
) {
requestFromServer(
settings,
"/sessions/$sessionId/answer",
method = "POST",
jsonBody = JSONObject().put("questionId", questionId).put("answer", answer).toString(),
jsonBody =
JSONObject()
.put("questionId", questionId)
.put("answers", JSONArray(answers))
.toString(),
) {}
}
@@ -27,112 +27,41 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import org.json.JSONException
import org.json.JSONObject
/**
* A question the model asked, as AskUserQuestion states it.
* Every question one tool call is waiting on.
*
* Parsed from the tool call's own input rather than carried in the question event, because the
* event's job is to be answerable -- an id and the labels that can be sent back -- and this is
* everything the reader needs to *decide*, which is a longer list: what each option means, and
* sometimes a worked example of what picking it would produce.
*/
data class AskedQuestion(
val question: String,
/** A short tag for the question, at most a dozen characters. Absent in older calls. */
val header: String?,
/** Whether more than one option may be chosen at once. */
val multiSelect: Boolean,
val options: List<AskedOption>,
)
data class AskedOption(
val label: String,
val description: String?,
/** A block to show as-is -- a mockup, a diff, a config file -- when the option carries one. */
val preview: String?,
)
/**
* Reads the questions out of an AskUserQuestion call's input.
*
* Empty when the input is not that shape, which is what a caller falls back on: a question that
* cannot be read in full is still answerable from the labels the event carried, and showing those
* beats showing nothing.
*/
fun parseAskedQuestions(input: String): List<AskedQuestion> {
val body =
try {
JSONObject(input)
} catch (_: JSONException) {
return emptyList()
}
val questions = body.optJSONArray("questions") ?: return emptyList()
return (0 until questions.length()).mapNotNull { index ->
val entry = questions.optJSONObject(index) ?: return@mapNotNull null
val options = entry.optJSONArray("options")
AskedQuestion(
question =
entry.optString("question").ifEmpty {
return@mapNotNull null
},
header = entry.optString("header").ifEmpty { null },
multiSelect = entry.optBoolean("multiSelect", false),
options =
(0 until (options?.length() ?: 0)).mapNotNull { at ->
val option = options?.optJSONObject(at) ?: return@mapNotNull null
AskedOption(
label =
option.optString("label").ifEmpty {
return@mapNotNull null
},
description = option.optString("description").ifEmpty { null },
preview = option.optString("preview").ifEmpty { null },
)
},
)
}
}
/**
* Everything an AskUserQuestion call is waiting for, on the call itself.
*
* The questions come from the call's input and the ids to answer with come from the events; they
* are matched by the question's own text, which the backend uses verbatim as the prompt. An event
* with no matching question still renders -- from its labels alone -- because a question that draws
* as nothing is indistinguishable from one that was never asked, and the turn is blocked on it
* either way.
* All of it comes from the question events themselves -- what each option means, what picking it
* would produce, whether several may be picked at once. None of it is read out of the call's own
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
* app, where no other provider can reach it and where it drifts the first time the schema moves.
*/
@Composable
fun AskUserQuestionBody(
input: String,
asks: List<TranscriptItem.QuestionCard>,
onAnswer: (questionId: String, answer: String) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
) {
val asked = remember(input) { parseAskedQuestions(input) }
Column(Modifier.fillMaxWidth()) {
asks.forEach { ask ->
Spacer(Modifier.height(12.dp))
AskedQuestionCard(
ask = ask,
asked = asked.firstOrNull { it.question == ask.prompt },
onAnswer = { answer -> onAnswer(ask.id, answer) },
)
AskedQuestion(ask) { answers -> onAnswer(ask.id, answers) }
}
}
}
/**
* One question: what is being asked, what can be answered, and what was.
*
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
* when nothing did. A question is the same thing either way, and two renderings of it would be two
* places for an answer to go missing.
*/
@Composable
private fun AskedQuestionCard(
ask: TranscriptItem.QuestionCard,
asked: AskedQuestion?,
onAnswer: (String) -> Unit,
) {
fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) {
Column(Modifier.fillMaxWidth()) {
asked?.header?.let { header ->
// The tool's own chip text. Its own line rather than beside the question, because it
// is a label *for* the question and the question is the thing to read.
ask.header?.let { header ->
// Its own line rather than beside the question, because it is a label *for* the
// question and the question is the thing to read.
Text(
header.uppercase(),
style = MaterialTheme.typography.labelSmall,
@@ -141,20 +70,26 @@ private fun AskedQuestionCard(
}
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
if (ask.answer != null) {
if (ask.answers.isNotEmpty()) {
// Joined for reading only: they arrived as a list and stay one everywhere else.
Text(
"Answered: ${ask.answer}",
"Answered: ${ask.answers.joinToString(", ")}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}
val options = asked?.options ?: ask.options.map { AskedOption(it, null, null) }
if (asked?.multiSelect == true) MultipleChoice(options, onAnswer)
else
options.forEach { option ->
OptionCard(option, selected = false) { onAnswer(option.label) }
if (ask.multiSelect) {
MultipleChoice(ask.options, onAnswer)
} else if (ask.options.all { it.description == null && it.preview == null }) {
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
// do not need a card each.
AnswerOptions(ask.options, onAnswer)
} else {
ask.options.forEach { option ->
OptionCard(option, selected = false) { onAnswer(listOf(option.label)) }
}
}
OtherAnswer(onAnswer)
}
}
@@ -162,12 +97,12 @@ private fun AskedQuestionCard(
/**
* Options that can be chosen together, with one button to send them.
*
* The answer goes back as a string whatever the question, so several choices are one line: the
* labels in the order they were offered. That is the tool's own schema -- its `answers` map is
* string-valued -- rather than a guess about how a list would be encoded.
* The answer goes back as the list it is. What a provider makes of several answers is decided where
* that provider is spoken to -- Claude Code's answers map holds a string, so they are joined there
* -- and nothing on this side has to know that.
*/
@Composable
private fun MultipleChoice(options: List<AskedOption>, onAnswer: (String) -> Unit) {
private fun MultipleChoice(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
var chosen by remember { mutableStateOf(setOf<String>()) }
options.forEach { option ->
OptionCard(option, selected = option.label in chosen) {
@@ -176,9 +111,9 @@ private fun MultipleChoice(options: List<AskedOption>, onAnswer: (String) -> Uni
}
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = {
onAnswer(options.filter { it.label in chosen }.joinToString(", ") { it.label })
},
// In the order they were offered rather than the order they were tapped: the reader is
// answering a list, and it should read back as that list.
onClick = { onAnswer(options.map { it.label }.filter { it in chosen }) },
enabled = chosen.isNotEmpty(),
) {
Text(if (chosen.size <= 1) "Send answer" else "Send ${chosen.size} answers")
@@ -194,7 +129,7 @@ private fun MultipleChoice(options: List<AskedOption>, onAnswer: (String) -> Uni
* is one cue and it is unambiguous.
*/
@Composable
private fun OptionCard(option: AskedOption, selected: Boolean, onPick: () -> Unit) {
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
OutlinedCard(
onClick = onPick,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
@@ -228,7 +163,13 @@ private fun OptionCard(option: AskedOption, selected: Boolean, onPick: () -> Uni
}
}
/** An option's worked example, shown as written. */
/**
* An option's worked example, shown as written.
*
* On its own surface, because it is a different kind of thing from the sentence above it: that
* describes the option, this is a sample of what the option produces, and monospace alone reads as
* a description that happens to be in code font.
*/
@Composable
private fun Preview(preview: String) {
Surface(
@@ -248,14 +189,14 @@ private fun Preview(preview: String) {
}
/**
* The choice the harness always adds, and the app has to as well.
* The choice the asker always leaves open, and the app has to as well.
*
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
* rather than pick. Leaving it out narrows a question that was never that narrow, and the reader
* cannot tell that it was ever open.
*/
@Composable
private fun OtherAnswer(onAnswer: (String) -> Unit) {
private fun OtherAnswer(onAnswer: (List<String>) -> Unit) {
var text by remember { mutableStateOf("") }
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
OutlinedTextField(
@@ -265,14 +206,14 @@ private fun OtherAnswer(onAnswer: (String) -> Unit) {
singleLine = true,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { onAnswer(text.trim()) }, enabled = text.isNotBlank()) {
TextButton(onClick = { onAnswer(listOf(text.trim())) }, enabled = text.isNotBlank()) {
Text("Send")
}
}
}
/**
* The answers to a question, wrapped rather than in a row.
* Bare options, wrapped rather than in a row.
*
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
* with four options showed the first one or two and dropped the rest off the side of the screen.
@@ -280,14 +221,14 @@ private fun OtherAnswer(onAnswer: (String) -> Unit) {
* way for a list of choices to be wrong.
*/
@Composable
fun AnswerOptions(options: List<String>, onAnswer: (String) -> Unit) {
fun AnswerOptions(options: List<QuestionOption>, onAnswer: (List<String>) -> Unit) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
options.forEach { option ->
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
OutlinedButton(onClick = { onAnswer(listOf(option.label)) }) { Text(option.label) }
}
}
}
@@ -10,6 +10,15 @@ import org.json.JSONObject
/** One transcript line: the event plus its resume cursor and time. */
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
/**
* One choice offered in answer to a question.
*
* More than a label because the reader is deciding rather than confirming: what an option means,
* and what picking it would produce, are the things that decide it. Both are absent on a
* permission, whose Allow and Deny mean exactly what they say.
*/
data class QuestionOption(val label: String, val description: String?, val preview: String?)
sealed class SessionEvent {
data class UserMessage(val text: String) : SessionEvent()
@@ -30,12 +39,17 @@ sealed class SessionEvent {
data class Question(
val id: String,
val prompt: String,
val options: List<String>,
/** A few words naming what the question is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** The tool call this is permission for, or null when it is not about one. */
val about: String?,
) : SessionEvent()
data class Answered(val id: String, val answer: String) : SessionEvent()
/** Everything chosen for one question, in the order it was offered. */
data class Answered(val id: String, val answers: List<String>) : SessionEvent()
/**
* A message another agent sent this session.
@@ -106,13 +120,28 @@ fun parseSeqEvent(json: String): SeqEvent {
SessionEvent.Question(
id = body.getString("id"),
prompt = body.getString("prompt"),
header = body.optString("header").ifEmpty { null },
options =
body.getJSONArray("options").let { options ->
(0 until options.length()).map { options.getString(it) }
(0 until options.length()).map { at ->
val option = options.getJSONObject(at)
QuestionOption(
label = option.getString("label"),
description = option.optString("description").ifEmpty { null },
preview = option.optString("preview").ifEmpty { null },
)
}
},
multiSelect = body.optBoolean("multiSelect", false),
about = body.optString("about").ifEmpty { null },
)
"answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer"))
"answered" ->
SessionEvent.Answered(
body.getString("id"),
body.getJSONArray("answers").let { answers ->
(0 until answers.length()).map { answers.getString(it) }
},
)
"peerMessage" ->
SessionEvent.PeerMessage(body.getString("from"), body.getString("text"))
"status" -> SessionEvent.Status(body.getString("state"))
@@ -121,8 +121,13 @@ sealed class TranscriptItem {
override val seq: Long,
val id: String,
val prompt: String,
val options: List<String>,
val answer: String?,
/** A few words naming what this is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** What was chosen, once something was; empty until then. */
val answers: List<String>,
) : TranscriptItem()
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
@@ -207,8 +212,10 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
entry.seq,
event.id,
event.prompt,
event.header,
event.options,
null,
event.multiSelect,
emptyList(),
)
// A question with no tool behind it -- AskUserQuestion, or an ask
// whose call fell outside the loaded window -- is a card of its
@@ -229,12 +236,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
items.map {
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
it.copy(answer = event.answer)
it.copy(answers = event.answers)
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
it.copy(
asks =
it.asks.map { ask ->
if (ask.id == event.id) ask.copy(answer = event.answer) else ask
if (ask.id == event.id) ask.copy(answers = event.answers)
else ask
}
)
else -> it
@@ -806,8 +814,10 @@ fun SessionScreen(
if (id in expandedTools) expandedTools - id
else expandedTools + id
},
onAnswer = { questionId, answer ->
act { answerQuestion(settings, summary.id, questionId, answer) }
onAnswer = { questionId, answers ->
act {
answerQuestion(settings, summary.id, questionId, answers)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
@@ -825,22 +835,22 @@ fun SessionScreen(
expandedTools - item.id
else expandedTools + item.id
},
onAnswer = { questionId, answer ->
onAnswer = { questionId, answers ->
act {
answerQuestion(
settings,
summary.id,
questionId,
answer,
answers,
)
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
is TranscriptItem.QuestionCard ->
QuestionRow(item) { answer ->
QuestionRow(item) { answers ->
act {
answerQuestion(settings, summary.id, item.id, answer)
answerQuestion(settings, summary.id, item.id, answers)
}
}
is TranscriptItem.ErrorMsg ->
@@ -1025,24 +1035,15 @@ private fun UserBubble(text: String, pending: Boolean = false) {
* connected device.
*/
@Composable
private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String) -> Unit) {
private fun QuestionRow(
question: TranscriptItem.QuestionCard,
onAnswer: (List<String>) -> Unit,
) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(question.prompt, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
if (question.answer != null) {
Text(
"Answered: ${question.answer}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// Wrapped, not in a Row. A Row hands out intrinsic widths in order and clips
// whatever runs past the edge, so a question with four options showed the first
// one or two and silently dropped the rest off the side of the screen -- which
// does not look like a bug, it looks like those were the only choices.
AnswerOptions(question.options, onAnswer)
}
// The same body the questions on a tool call get: one question is the same
// thing whether or not something else asked it.
AskedQuestion(question, onAnswer)
}
}
}
@@ -102,7 +102,7 @@ fun ToolGroup(
onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit,
onAnswer: (questionId: String, answer: String) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit,
) {
if (!expanded) {
@@ -168,11 +168,11 @@ fun ToolCard(
tool: TranscriptItem.ToolRun,
expanded: Boolean,
onToggle: () -> Unit,
onAnswer: (questionId: String, answer: String) -> Unit,
onAnswer: (questionId: String, answers: List<String>) -> Unit,
image: @Composable (String) -> Unit = {},
) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val deciding = tool.asks.any { it.answer == null }
val deciding = tool.asks.any { it.answers.isEmpty() }
val open = expanded || deciding
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
@@ -246,10 +246,10 @@ fun ToolCard(
tool.images.forEach { ref -> image(ref) }
if (tool.asks.isNotEmpty()) {
if (tool.tool == ASK_USER_QUESTION) {
AskUserQuestionBody(tool.input, tool.asks, onAnswer)
AskUserQuestionBody(tool.asks, onAnswer)
} else {
tool.asks.forEach { ask ->
PermissionAsk(ask) { answer -> onAnswer(ask.id, answer) }
PermissionAsk(ask) { answers -> onAnswer(ask.id, answers) }
}
}
}
@@ -264,16 +264,16 @@ fun ToolCard(
* the ask can stand alone, and here it does not have to -- the card above is showing exactly that.
*/
@Composable
private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (String) -> Unit) {
private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (List<String>) -> Unit) {
Spacer(Modifier.height(8.dp))
Text(
ask.prompt.substringBefore('\n'),
style = MaterialTheme.typography.bodyMedium,
color = awaitingColor,
)
if (ask.answer != null) {
if (ask.answers.isNotEmpty()) {
Text(
"Answered: ${ask.answer}",
"Answered: ${ask.answers.joinToString(", ")}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)