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:
1 parent
fea8e7e92b
commit
bebaae7a94
13 files changed
+419
-237
No files matched your search
+86
-55
@@ -37,7 +37,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, QuestionOption, SessionStatus};
|
||||
|
||||
/// Delay between streamed deltas -- long enough that streaming is visibly
|
||||
/// streaming in the UI, short enough that tests waiting on a full turn
|
||||
@@ -91,74 +91,102 @@ impl EchoDriver {
|
||||
/// 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) {
|
||||
// Written once, in the shape the events carry, and turned into
|
||||
// the tool call's own input below -- the CLI sends both, and two
|
||||
// hand-written copies of one question would drift.
|
||||
let asked = [
|
||||
(
|
||||
"Theme",
|
||||
"Which colour scheme should the transcript use?",
|
||||
false,
|
||||
vec![
|
||||
QuestionOption {
|
||||
label: "Catppuccin Mocha (Recommended)".to_string(),
|
||||
description: Some(
|
||||
"What the app uses now: a dark base with muted accents.".to_string(),
|
||||
),
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Solarized Dark".to_string(),
|
||||
description: Some(
|
||||
"Lower contrast, warmer. Easier at night, harder in sun.".to_string(),
|
||||
),
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "High contrast".to_string(),
|
||||
description: Some(
|
||||
"Pure black behind white text, for reading outdoors.".to_string(),
|
||||
),
|
||||
preview: Some(
|
||||
"background: #000000\nforeground: #ffffff\naccent: #ffd700"
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"Collapsed",
|
||||
"Which of these should be shown collapsed by default?",
|
||||
true,
|
||||
vec![
|
||||
QuestionOption {
|
||||
label: "Tool calls".to_string(),
|
||||
description: Some("A run of them becomes one card.".to_string()),
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Peer messages".to_string(),
|
||||
description: Some("Messages from other agents.".to_string()),
|
||||
preview: None,
|
||||
},
|
||||
QuestionOption {
|
||||
label: "Compaction notes".to_string(),
|
||||
description: Some("What a compaction recovered.".to_string()),
|
||||
preview: None,
|
||||
},
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
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,
|
||||
input: serde_json::json!({"questions": asked
|
||||
.iter()
|
||||
.map(|(header, question, multi, options)| serde_json::json!({
|
||||
"question": question,
|
||||
"header": header,
|
||||
"multiSelect": multi,
|
||||
"options": options,
|
||||
}))
|
||||
.collect::<Vec<_>>()}),
|
||||
});
|
||||
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()
|
||||
{
|
||||
for (index, (header, question, multi, options)) in asked.into_iter().enumerate() {
|
||||
let id = format!("{call}#{index}");
|
||||
pending.push(PendingQuestion {
|
||||
id: id.clone(),
|
||||
call: Some(call.clone()),
|
||||
});
|
||||
self.pending_questions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.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(),
|
||||
prompt: question.to_string(),
|
||||
header: Some(header.to_string()),
|
||||
options,
|
||||
multi_select: multi,
|
||||
// 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,
|
||||
});
|
||||
@@ -277,7 +305,9 @@ impl Driver for EchoDriver {
|
||||
self.emit(Event::Question {
|
||||
id,
|
||||
prompt,
|
||||
options: vec!["Yes".to_string(), "No".to_string()],
|
||||
header: None,
|
||||
options: vec![QuestionOption::plain("Yes"), QuestionOption::plain("No")],
|
||||
multi_select: false,
|
||||
about: None,
|
||||
});
|
||||
self.emit(Event::Status {
|
||||
@@ -412,7 +442,8 @@ impl Driver for EchoDriver {
|
||||
});
|
||||
}
|
||||
|
||||
fn answer_question(&self, id: &str, answer: &str) {
|
||||
fn answer_question(&self, id: &str, answers: &[String]) {
|
||||
let answer = answers.join(", ");
|
||||
let (answered, waiting) = {
|
||||
let mut pending = self.pending_questions.lock().unwrap();
|
||||
let Some(at) = pending.iter().position(|question| question.id == id) else {
|
||||
|
||||
Reference in new issue
Block a user