Show every option a question offers, on the call that asked

Reported by Iris through the dev-updater session: a two-question
AskUserQuestion arrived with only one option visible per question, so
the answer she sent was the only one she had been offered.

The cause was a `Row`. It hands out intrinsic widths in order and clips
whatever runs past the edge, so the first option or two drew and the
rest went off the side of the screen -- which does not read as a bug, it
reads as those having been the only choices. The same Row was in the
permission ask beside it; both wrap now. That pairing is the reason to
look: a rule stated on one member of a set is usually missing from the
others.

The rest of what she asked for, and what each was:

- It drew twice, as the tool call and again as loose question cards,
  because the backend marked these questions as belonging to no call.
  They belong to the call that asked, and now say so.
- So it renders like any other tool: one card, its own heading, opened
  because a decision cannot be made from a closed row.
- Each option shows its description and its `preview` block, which is
  the part a reader is deciding on and none of which was reaching them.
- "Other" is a field on every question. The harness always offers it, so
  leaving it out narrowed a question that was never that narrow.
- A multi-select sends the labels it collected as one string, which is
  the tool's own schema rather than a guess -- its answers map is
  string-valued.
- No spinner while it waits. A spinner says the machine is working; here
  the machine is idle and the turn is stopped on the person, so the card
  says "your turn" in the colour this app already uses for that.

Verified against a real session as well as the echo fixture: haiku asked
two questions with three described options each, both were answered from
the phone, and the model carried on with the answers. Echo grew `/ask`
so the shape can be looked at without paying a model to produce one, and
its option cards are outlined rather than tinted -- as one surface step
up they were three paragraphs where three things to press should be.
This commit is contained in:
iris committed 2026-08-29 16:22:15 -04:00
1 parent cae04c2559
commit fea8e7e92b
5 files changed
+507 -50

No files matched your search

@@ -0,0 +1,293 @@
package com.example.aiapp
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.
*
* 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.
*/
@Composable
fun AskUserQuestionBody(
input: String,
asks: List<TranscriptItem.QuestionCard>,
onAnswer: (questionId: String, answer: 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) },
)
}
}
}
@Composable
private fun AskedQuestionCard(
ask: TranscriptItem.QuestionCard,
asked: AskedQuestion?,
onAnswer: (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.
Text(
header.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
if (ask.answer != null) {
Text(
"Answered: ${ask.answer}",
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) }
}
OtherAnswer(onAnswer)
}
}
/**
* 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.
*/
@Composable
private fun MultipleChoice(options: List<AskedOption>, onAnswer: (String) -> Unit) {
var chosen by remember { mutableStateOf(setOf<String>()) }
options.forEach { option ->
OptionCard(option, selected = option.label in chosen) {
chosen = if (option.label in chosen) chosen - option.label else chosen + option.label
}
}
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = {
onAnswer(options.filter { it.label in chosen }.joinToString(", ") { it.label })
},
enabled = chosen.isNotEmpty(),
) {
Text(if (chosen.size <= 1) "Send answer" else "Send ${chosen.size} answers")
}
}
/**
* One option: what it is called, what it means, and what it would produce.
*
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
* should have been, which is the failure a tint step routinely produces on a dark theme. A border
* is one cue and it is unambiguous.
*/
@Composable
private fun OptionCard(option: AskedOption, selected: Boolean, onPick: () -> Unit) {
OutlinedCard(
onClick = onPick,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
colors =
CardDefaults.outlinedCardColors(
containerColor =
if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface
),
// Picked shows in the border as well as the fill, because the fill alone is a colour
// difference somebody has to have seen the unpicked version to notice.
border =
BorderStroke(
if (selected) 2.dp else 1.dp,
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Text(option.label, style = MaterialTheme.typography.titleSmall)
option.description?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
option.preview?.let { Preview(it) }
}
}
}
/** An option's worked example, shown as written. */
@Composable
private fun Preview(preview: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLowest,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(
preview,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines
// of the thing being previewed.
softWrap = false,
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
)
}
}
/**
* The choice the harness always adds, 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) {
var text by remember { mutableStateOf("") }
Row(Modifier.fillMaxWidth().padding(top = 8.dp)) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Other") },
singleLine = true,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { onAnswer(text.trim()) }, enabled = text.isNotBlank()) {
Text("Send")
}
}
}
/**
* The answers to a question, 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.
* That does not read as a bug: it reads as those having been the only choices, which is the worst
* way for a list of choices to be wrong.
*/
@Composable
fun AnswerOptions(options: List<String>, onAnswer: (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) }
}
}
}
@@ -97,14 +97,17 @@ sealed class TranscriptItem {
val output: String,
val done: Boolean,
/**
* The permission ask for this call, when there is one.
* The questions this call is waiting on, in the order they were asked.
*
* On the call's own row rather than beside it: the ask used to arrive as a second card
* On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice and had to work
* out that it was one event. The backend says which call a permission is about, so this is
* a fact rather than a match on the input.
* out that it was one event. The backend says which call a question is about, so this is a
* fact rather than a match on the input.
*
* A list because AskUserQuestion asks up to four at once, and they are one decision to make
* -- a permission is the case of exactly one, not a different shape.
*/
val ask: QuestionCard? = null,
val asks: List<QuestionCard> = emptyList(),
/**
* Images this call's result carried, drawn under it.
*
@@ -214,7 +217,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(ask = card) }
updateTool(items, event.about) { it.copy(asks = it.asks + card) }
} else {
items + card
}
@@ -227,8 +230,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
it.copy(answer = event.answer)
it is TranscriptItem.ToolRun && it.ask?.id == event.id ->
it.copy(ask = it.ask.copy(answer = event.answer))
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
}
)
else -> it
}
}
@@ -798,10 +806,8 @@ fun SessionScreen(
if (id in expandedTools) expandedTools - id
else expandedTools + id
},
onAnswer = { call, answer ->
call.ask?.let { ask ->
act { answerQuestion(settings, summary.id, ask.id, answer) }
}
onAnswer = { questionId, answer ->
act { answerQuestion(settings, summary.id, questionId, answer) }
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
@@ -819,17 +825,15 @@ fun SessionScreen(
expandedTools - item.id
else expandedTools + item.id
},
onAnswer = { answer ->
item.ask?.let { ask ->
onAnswer = { questionId, answer ->
act {
answerQuestion(
settings,
summary.id,
ask.id,
questionId,
answer,
)
}
}
},
image = { ref -> SessionImage(settings, summary.id, ref) },
)
@@ -1033,11 +1037,11 @@ private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
question.options.forEach { option ->
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
}
}
// 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)
}
}
}
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -103,7 +102,7 @@ fun ToolGroup(
onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit,
onAnswer: (TranscriptItem.ToolRun, String) -> Unit,
onAnswer: (questionId: String, answer: String) -> Unit,
image: @Composable (String) -> Unit,
) {
if (!expanded) {
@@ -127,7 +126,7 @@ fun ToolGroup(
tool = call,
expanded = isToolExpanded(call.id),
onToggle = { onToolToggle(call.id) },
onAnswer = { answer -> onAnswer(call, answer) },
onAnswer = onAnswer,
image = image,
)
}
@@ -169,11 +168,11 @@ fun ToolCard(
tool: TranscriptItem.ToolRun,
expanded: Boolean,
onToggle: () -> Unit,
onAnswer: (String) -> Unit,
onAnswer: (questionId: String, answer: String) -> Unit,
image: @Composable (String) -> Unit = {},
) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val deciding = tool.ask != null && tool.ask.answer == null
val deciding = tool.asks.any { it.answer == null }
val open = expanded || deciding
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
@@ -200,7 +199,18 @@ fun ToolCard(
)
} ?: Spacer(Modifier.weight(1f))
}
if (!tool.done) {
// A spinner says the machine is working. While this call is waiting on an
// answer the machine is doing nothing at all -- the turn is stopped on the
// person reading it -- so it says whose move it is instead, in the colour this
// app uses everywhere for that.
if (deciding) {
Spacer(Modifier.width(8.dp))
Text(
"your turn",
style = MaterialTheme.typography.labelLarge,
color = awaitingColor,
)
} else if (!tool.done) {
Spacer(Modifier.width(8.dp))
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
@@ -217,7 +227,12 @@ fun ToolCard(
modifier = Modifier.padding(top = 4.dp),
)
}
// Everything AskUserQuestion carries is the questions, and those are drawn
// below as something answerable; dumping the same JSON above them would be the
// decision stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp))
}
if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
@@ -229,7 +244,15 @@ fun ToolCard(
// less than the one line it replaced -- unlike a command, which
// is what the closed line already summarises.
tool.images.forEach { ref -> image(ref) }
tool.ask?.let { ask -> PermissionAsk(ask, onAnswer) }
if (tool.asks.isNotEmpty()) {
if (tool.tool == ASK_USER_QUESTION) {
AskUserQuestionBody(tool.input, tool.asks, onAnswer)
} else {
tool.asks.forEach { ask ->
PermissionAsk(ask) { answer -> onAnswer(ask.id, answer) }
}
}
}
}
}
}
@@ -255,10 +278,9 @@ private fun PermissionAsk(ask: TranscriptItem.QuestionCard, onAnswer: (String) -
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ask.options.forEach { option ->
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
}
}
AnswerOptions(ask.options, onAnswer)
}
}
/** The tool whose input is a question rather than a command; see [AskUserQuestionBody]. */
private const val ASK_USER_QUESTION = "AskUserQuestion"
+13 -2
View File
@@ -377,8 +377,14 @@ impl Translator {
id: format!("{request_id}#{i}"),
prompt: text.clone(),
options,
// A question the model asked, not permission for a call.
about: None,
// The call that is asking, so all of this draws as one
// thing. It used to be `None` on the grounds that a
// question the model asked is not permission for a
// call -- true, and beside the point: the reader was
// shown the AskUserQuestion call *and* its questions
// as two separate cards for one event, and the call
// itself said nothing they could act on.
about: about.clone(),
});
questions.push(text);
}
@@ -823,6 +829,11 @@ mod tests {
.collect();
assert_eq!(questions.len(), 2);
assert_eq!(questions[0].0, "req-3#0");
// Both belong to the call that asked, so a phone draws them on it.
assert!(events.iter().all(|event| match event {
Event::Question { about, .. } => about.as_deref() == Some("toolu_04"),
_ => true,
}));
assert_eq!(questions[0].1, "Which color?");
assert_eq!(questions[0].2, vec!["Red", "Blue"]);
+142 -15
View File
@@ -10,6 +10,9 @@
//! - `/tools [n]` -- n calls back to back, for what a run of them looks
//! like when a screen groups them.
//! - `/question [text]` -- a question, exercising the answer path.
//! - `/ask` -- an AskUserQuestion call: two questions on one tool call,
//! with descriptions, a preview and a multi-select, which is the shape
//! that is awkward to get a real model to produce on demand.
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states that only
//! exist *while* something is happening can be looked at.
//! - `/error [text]` -- a failure, which is otherwise awkward to cause.
@@ -50,6 +53,16 @@ const DELTA_DELAY: Duration = Duration::from_millis(50);
/// short to watch its elapsed count reach two digits.
const COMPACT_TIME: Duration = Duration::from_secs(13);
/// A question echo is waiting on, and the tool call it belongs to.
///
/// `call` is `None` for `/question`, which asks on its own the way a
/// permission does; `Some` for `/ask`, where several questions share one
/// call and the call ends when the last of them is answered.
struct PendingQuestion {
id: String,
call: Option<String>,
}
pub struct EchoDriver {
sink: EventSink,
/// Whether a turn is in flight, and what arrived during it.
@@ -61,16 +74,100 @@ pub struct EchoDriver {
/// pending. Holding it here is what makes echo able to stand in.
busy: Arc<AtomicBool>,
queued: Arc<Mutex<Vec<String>>>,
/// Id of the question currently awaiting an answer, if any. One at a
/// time is all the echo behavior ever produces.
pending_question: Mutex<Option<String>>,
/// Ids of the questions awaiting an answer, in the order they were
/// asked. A list because `/ask` puts up to four on one tool call, the
/// way AskUserQuestion does, and the turn resumes when the last of
/// them is answered rather than the first.
pending_questions: Mutex<Vec<PendingQuestion>>,
}
impl EchoDriver {
/// An AskUserQuestion call, in the shape the CLI sends one.
///
/// Two questions on one call, because that is where the display is
/// hardest and where it was wrong: one question with four options
/// reads fine even when the options are laid out badly. Written out
/// in full rather than generated so it carries the parts that are
/// easy to leave out of a fixture -- a header, an option with a
/// description, an option with a preview block, and a multi-select.
fn ask_user_question(&self) {
let call = format!("echo-ask-{}", super::random_hex());
let questions = serde_json::json!({"questions": [
{
"question": "Which colour scheme should the transcript use?",
"header": "Theme",
"multiSelect": false,
"options": [
{"label": "Catppuccin Mocha (Recommended)",
"description": "What the app uses now: a dark base with muted accents."},
{"label": "Solarized Dark",
"description": "Lower contrast, warmer. Easier at night, harder in sun."},
{"label": "High contrast",
"description": "Pure black behind white text, for reading outdoors.",
"preview": "background: #000000\nforeground: #ffffff\naccent: #ffd700"},
],
},
{
"question": "Which of these should be shown collapsed by default?",
"header": "Collapsed",
"multiSelect": true,
"options": [
{"label": "Tool calls", "description": "A run of them becomes one card."},
{"label": "Peer messages", "description": "Messages from other agents."},
{"label": "Compaction notes", "description": "What a compaction recovered."},
],
},
]});
self.emit(Event::Status {
state: SessionStatus::Running,
});
self.emit(Event::ToolStart {
id: call.clone(),
tool: "AskUserQuestion".to_string(),
input: questions,
});
let mut pending = self.pending_questions.lock().unwrap();
for (index, question) in [
(
"Which colour scheme should the transcript use?",
vec![
"Catppuccin Mocha (Recommended)",
"Solarized Dark",
"High contrast",
],
),
(
"Which of these should be shown collapsed by default?",
vec!["Tool calls", "Peer messages", "Compaction notes"],
),
]
.into_iter()
.enumerate()
{
let id = format!("{call}#{index}");
pending.push(PendingQuestion {
id: id.clone(),
call: Some(call.clone()),
});
self.emit(Event::Question {
id,
prompt: question.0.to_string(),
options: question.1.into_iter().map(str::to_string).collect(),
// The call that asked, so all of it draws as one thing --
// which is the whole point of the fixture.
about: Some(call.clone()),
});
}
drop(pending);
self.emit(Event::Status {
state: SessionStatus::AwaitingInput,
});
}
pub fn new(sink: EventSink) -> Self {
let driver = Self {
sink,
pending_question: Mutex::new(None),
pending_questions: Mutex::new(Vec::new()),
busy: Arc::new(AtomicBool::new(false)),
queued: Arc::new(Mutex::new(Vec::new())),
};
@@ -154,6 +251,12 @@ impl Driver for EchoDriver {
return;
}
if text.trim() == "/ask" {
self.emit(Event::MessageTaken { text });
self.ask_user_question();
return;
}
if let Some(rest) = text.strip_prefix("/question") {
let id = format!("q-{}", super::random_hex());
let prompt = if rest.trim().is_empty() {
@@ -161,7 +264,13 @@ impl Driver for EchoDriver {
} else {
format!("Echo asks: {}", rest.trim())
};
*self.pending_question.lock().unwrap() = Some(id.clone());
self.pending_questions
.lock()
.unwrap()
.push(PendingQuestion {
id: id.clone(),
call: None,
});
self.emit(Event::Status {
state: SessionStatus::Running,
});
@@ -304,27 +413,45 @@ impl Driver for EchoDriver {
}
fn answer_question(&self, id: &str, answer: &str) {
let mut pending = self.pending_question.lock().unwrap();
match pending.as_deref() {
Some(expected) if expected == id => {
*pending = None;
let (answered, waiting) = {
let mut pending = self.pending_questions.lock().unwrap();
let Some(at) = pending.iter().position(|question| question.id == id) else {
self.emit(Event::Error {
message: format!("no question {id} is awaiting an answer"),
});
return;
};
let answered = pending.remove(at);
// Whether anything on the same call is still unanswered: a
// tool that asked four questions ends once, not four times.
let waiting = answered
.call
.as_ref()
.is_some_and(|call| pending.iter().any(|q| q.call.as_ref() == Some(call)));
(answered, waiting)
};
if waiting {
return;
}
if let Some(call) = answered.call {
self.emit(Event::ToolEnd {
id: call,
output: format!("answered: {answer}"),
});
} else {
self.emit(Event::AssistantText {
delta: format!("You answered: {answer}"),
});
}
self.emit(Event::Status {
state: SessionStatus::Idle,
});
}
_ => self.emit(Event::Error {
message: format!("no question {id} is awaiting an answer"),
}),
}
}
fn interrupt(&self) {
// Nothing real to stop; a pending question is abandoned so the
// session isn't stuck awaiting input forever.
*self.pending_question.lock().unwrap() = None;
self.pending_questions.lock().unwrap().clear();
self.emit(Event::Status {
state: SessionStatus::Idle,
});