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:
1 parent
cae04c2559
commit
fea8e7e92b
5 files changed
+518
-61
No files matched your search
@@ -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"]);
|
||||
|
||||
|
||||
+146
-19
@@ -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;
|
||||
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,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user