diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt new file mode 100644 index 0000000..bd3a9a4 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt @@ -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, +) + +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 { + 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, + 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, onAnswer: (String) -> Unit) { + var chosen by remember { mutableStateOf(setOf()) } + 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, 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) } + } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index bba5a13..cf52c9f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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 = emptyList(), /** * Images this call's result carried, drawn under it. * @@ -214,7 +217,7 @@ fun foldEvent(items: List, entry: SeqEvent): List, entry: SeqEvent): List 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,16 +825,14 @@ fun SessionScreen( expandedTools - item.id else expandedTools + item.id }, - onAnswer = { answer -> - item.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) }, @@ -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) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 68d820b..81beef0 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -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), ) } - ToolInputView(tool.tool, tool.input, 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" diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index e2d2e98..cbb6595 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -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"]); diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 83e295c..8b02905 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -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, +} + 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, queued: Arc>>, - /// Id of the question currently awaiting an answer, if any. One at a - /// time is all the echo behavior ever produces. - pending_question: Mutex>, + /// 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>, } 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; - self.emit(Event::AssistantText { - delta: format!("You answered: {answer}"), + 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"), }); - self.emit(Event::Status { - state: SessionStatus::Idle, - }); - } - _ => 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, + }); } 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, });