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
+12
-3
@@ -15,7 +15,7 @@
|
||||
//! (a backlog past CATCH_UP_LIMIT arrives as a
|
||||
//! `reset` frame plus the newest window)
|
||||
//! POST /sessions/{id}/message {text, attachmentIds?}
|
||||
//! POST /sessions/{id}/answer {questionId, answer} (questions and permissions)
|
||||
//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions)
|
||||
//! POST /sessions/{id}/interrupt
|
||||
//! POST /sessions/{id}/title {title}
|
||||
//! POST /sessions/{id}/model {model}
|
||||
@@ -620,7 +620,11 @@ async fn message(
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AnswerRequest {
|
||||
question_id: String,
|
||||
answer: String,
|
||||
/// Everything chosen, in the order it was offered. A question that
|
||||
/// takes one answer sends a list of one, so there is one shape here
|
||||
/// rather than a single-answer route and a multi-answer route beside
|
||||
/// it.
|
||||
answers: Vec<String>,
|
||||
}
|
||||
|
||||
async fn answer(
|
||||
@@ -628,7 +632,12 @@ async fn answer(
|
||||
UrlPath(id): UrlPath<String>,
|
||||
axum::Json(body): axum::Json<AnswerRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
lookup(&manager, &id)?.answer_question(&body.question_id, &body.answer);
|
||||
if body.answers.is_empty() {
|
||||
return Err(bad_request(anyhow::anyhow!(
|
||||
"an answer needs at least one choice"
|
||||
)));
|
||||
}
|
||||
lookup(&manager, &id)?.answer_question(&body.question_id, &body.answers);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
@@ -473,10 +473,10 @@ impl Driver for ClaudeDriver {
|
||||
self.send_line(line);
|
||||
}
|
||||
|
||||
fn answer_question(&self, id: &str, answer: &str) {
|
||||
fn answer_question(&self, id: &str, answers: &[String]) {
|
||||
let response = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.answer(id, answer)
|
||||
state.answer(id, answers)
|
||||
};
|
||||
match response {
|
||||
AnswerOutcome::Respond(control_response) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::super::driver::{Event, SessionStatus};
|
||||
use super::super::driver::{Event, QuestionOption, SessionStatus};
|
||||
|
||||
/// What answering a question produced.
|
||||
pub(super) enum AnswerOutcome {
|
||||
@@ -365,18 +365,33 @@ impl Translator {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("(question)")
|
||||
.to_string();
|
||||
// Everything the reader decides on, carried in the event.
|
||||
// The alternative -- and what this was -- is the phone
|
||||
// reaching into the tool call's input for the parts the
|
||||
// event dropped, which puts this dialect's schema in the
|
||||
// app where no other dialect can reach it.
|
||||
let options = question
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|option| option.get("label").and_then(Value::as_str))
|
||||
.map(String::from)
|
||||
.filter_map(|option| {
|
||||
Some(QuestionOption {
|
||||
label: option.get("label").and_then(Value::as_str)?.to_string(),
|
||||
description: text_field(option, "description"),
|
||||
preview: text_field(option, "preview"),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
events.push(Event::Question {
|
||||
id: format!("{request_id}#{i}"),
|
||||
prompt: text.clone(),
|
||||
header: text_field(question, "header"),
|
||||
options,
|
||||
multi_select: question
|
||||
.get("multiSelect")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
// 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
|
||||
@@ -394,7 +409,14 @@ impl Translator {
|
||||
events.push(Event::Question {
|
||||
id: request_id.clone(),
|
||||
prompt: format!("Allow {tool_name}?\n{summary}"),
|
||||
options: vec!["Allow".to_string(), "Deny".to_string()],
|
||||
// No header: the question is about the call it names, and
|
||||
// the phone draws it on that call's own row.
|
||||
header: None,
|
||||
options: vec![
|
||||
QuestionOption::plain("Allow"),
|
||||
QuestionOption::plain("Deny"),
|
||||
],
|
||||
multi_select: false,
|
||||
about: about.clone(),
|
||||
});
|
||||
}
|
||||
@@ -415,7 +437,13 @@ impl Translator {
|
||||
|
||||
/// Applies one answer from the phone. Question ids are the control
|
||||
/// request id, suffixed `#i` for AskUserQuestion sub-questions.
|
||||
pub(super) fn answer(&mut self, question_id: &str, answer: &str) -> AnswerOutcome {
|
||||
pub(super) fn answer(&mut self, question_id: &str, answers: &[String]) -> AnswerOutcome {
|
||||
// Where this dialect's shape is put on: the CLI's `answers` map is
|
||||
// string-valued whatever the question, so several choices become
|
||||
// one line here rather than everything upstream pretending a
|
||||
// question can only ever have one answer.
|
||||
let answer = answers.join(", ");
|
||||
let answer = answer.as_str();
|
||||
let (request_id, sub) = match question_id.split_once('#') {
|
||||
Some((request_id, index)) => (request_id, index.parse::<usize>().ok()),
|
||||
None => (question_id, None),
|
||||
@@ -514,6 +542,19 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
/// A string field that is there and not empty, or `None`.
|
||||
///
|
||||
/// The CLI omits these rather than sending them empty, but a caller that
|
||||
/// sends `""` means the same thing and should not produce a description
|
||||
/// that draws as a blank line.
|
||||
fn text_field(value: &Value, name: &str) -> Option<String> {
|
||||
value
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Decodes one base64 image block into `files/` and returns its ref.
|
||||
///
|
||||
/// A free function rather than a method because the import replay needs
|
||||
@@ -551,6 +592,15 @@ pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What a phone sends back: everything chosen, even when that is one.
|
||||
fn chose(answer: &str) -> Vec<String> {
|
||||
vec![answer.to_string()]
|
||||
}
|
||||
|
||||
fn labels(options: &[QuestionOption]) -> Vec<&str> {
|
||||
options.iter().map(|option| option.label.as_str()).collect()
|
||||
}
|
||||
|
||||
fn translate_lines(translator: &mut Translator, lines: &[&str]) -> Vec<Event> {
|
||||
lines
|
||||
.iter()
|
||||
@@ -754,6 +804,7 @@ mod tests {
|
||||
prompt,
|
||||
options,
|
||||
about,
|
||||
..
|
||||
} = &events[0]
|
||||
else {
|
||||
panic!("expected a question, got {events:?}");
|
||||
@@ -763,7 +814,7 @@ mod tests {
|
||||
// tool's row instead of as a second card repeating its input.
|
||||
assert_eq!(about.as_deref(), Some("toolu_03"));
|
||||
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
|
||||
assert_eq!(options, &["Allow", "Deny"]);
|
||||
assert_eq!(labels(options), ["Allow", "Deny"]);
|
||||
assert_eq!(
|
||||
events[1],
|
||||
Event::Status {
|
||||
@@ -772,7 +823,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Allowing echoes the input back; the request is then gone.
|
||||
let AnswerOutcome::Respond(response) = translator.answer("req-1", "Allow") else {
|
||||
let AnswerOutcome::Respond(response) = translator.answer("req-1", &chose("Allow")) else {
|
||||
panic!("expected a control response");
|
||||
};
|
||||
assert_eq!(response["response"]["request_id"], "req-1");
|
||||
@@ -782,7 +833,7 @@ mod tests {
|
||||
"rm -rf /tmp/x"
|
||||
);
|
||||
assert!(matches!(
|
||||
translator.answer("req-1", "Allow"),
|
||||
translator.answer("req-1", &chose("Allow")),
|
||||
AnswerOutcome::Unknown
|
||||
));
|
||||
}
|
||||
@@ -797,7 +848,7 @@ mod tests {
|
||||
r#"{"type":"control_request","request_id":"req-2","request":{"subtype":"can_use_tool","tool_name":"Write","input":{"file_path":"/etc/passwd"}}}"#,
|
||||
],
|
||||
);
|
||||
let AnswerOutcome::Respond(response) = translator.answer("req-2", "Deny") else {
|
||||
let AnswerOutcome::Respond(response) = translator.answer("req-2", &chose("Deny")) else {
|
||||
panic!("expected a control response");
|
||||
};
|
||||
assert_eq!(response["response"]["response"]["behavior"], "deny");
|
||||
@@ -822,8 +873,9 @@ mod tests {
|
||||
id,
|
||||
prompt,
|
||||
options,
|
||||
multi_select,
|
||||
..
|
||||
} => Some((id.clone(), prompt.clone(), options.clone())),
|
||||
} => Some((id.clone(), prompt.clone(), options.clone(), *multi_select)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -835,15 +887,15 @@ mod tests {
|
||||
_ => true,
|
||||
}));
|
||||
assert_eq!(questions[0].1, "Which color?");
|
||||
assert_eq!(questions[0].2, vec!["Red", "Blue"]);
|
||||
assert_eq!(labels(&questions[0].2), ["Red", "Blue"]);
|
||||
|
||||
// First answer alone isn't enough; the response goes out when the
|
||||
// last sub-question is answered, with all answers aboard.
|
||||
assert!(matches!(
|
||||
translator.answer("req-3#0", "Blue"),
|
||||
translator.answer("req-3#0", &chose("Blue")),
|
||||
AnswerOutcome::Pending
|
||||
));
|
||||
let AnswerOutcome::Respond(response) = translator.answer("req-3#1", "L") else {
|
||||
let AnswerOutcome::Respond(response) = translator.answer("req-3#1", &chose("L")) else {
|
||||
panic!("expected a control response");
|
||||
};
|
||||
let updated = &response["response"]["response"]["updatedInput"];
|
||||
@@ -852,6 +904,59 @@ mod tests {
|
||||
assert_eq!(updated["questions"][0]["question"], "Which color?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_carries_what_it_takes_to_answer_it() {
|
||||
// Descriptions and previews are what the reader decides on, and a
|
||||
// multi-select is how many answers the question takes. All of it
|
||||
// travels in the event: a phone that had to read this dialect's
|
||||
// tool input to find them would be the only place that knew how,
|
||||
// and no other provider could reach it.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
r#"{"type":"control_request","request_id":"req-9","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":[{"question":"Which to collapse?","header":"Collapsed","multiSelect":true,"options":[{"label":"Tool calls","description":"A run becomes one card."},{"label":"Peer messages","description":"From other agents.","preview":"from: dev-updater\\npull before you touch it"}]}]},"tool_use_id":"toolu_09"}}"#,
|
||||
],
|
||||
);
|
||||
let Event::Question {
|
||||
header,
|
||||
options,
|
||||
multi_select,
|
||||
..
|
||||
} = &events[0]
|
||||
else {
|
||||
panic!("expected a question, got {events:?}");
|
||||
};
|
||||
assert_eq!(header.as_deref(), Some("Collapsed"));
|
||||
assert!(multi_select);
|
||||
assert_eq!(
|
||||
options[0].description.as_deref(),
|
||||
Some("A run becomes one card.")
|
||||
);
|
||||
assert!(options[0].preview.is_none());
|
||||
assert!(
|
||||
options[1]
|
||||
.preview
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("dev-updater")
|
||||
);
|
||||
|
||||
// Two choices, one answer: the joining is this dialect's shape,
|
||||
// done where it is spoken. The CLI's answers map holds strings.
|
||||
let AnswerOutcome::Respond(response) = translator.answer(
|
||||
"req-9#0",
|
||||
&["Tool calls".to_string(), "Peer messages".to_string()],
|
||||
) else {
|
||||
panic!("expected a control response");
|
||||
};
|
||||
assert_eq!(
|
||||
response["response"]["response"]["updatedInput"]["answers"]["Which to collapse?"],
|
||||
"Tool calls, Peer messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_in_tool_results_are_saved_and_referenced() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
@@ -15,6 +15,35 @@ use tokio::sync::mpsc;
|
||||
/// directions use the one id so the transcript renders them identically.
|
||||
pub type ImageRef = String;
|
||||
|
||||
/// One choice offered in answer to a [`Event::Question`].
|
||||
///
|
||||
/// More than a label because the reader is deciding, not confirming: what
|
||||
/// an option means, and what picking it would produce, are the things that
|
||||
/// decide it. Both are optional -- a permission's Allow and Deny mean
|
||||
/// exactly what they say.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuestionOption {
|
||||
pub label: String,
|
||||
/// A sentence about what this option means.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// A block to show as written -- a mockup, a diff, a config file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
impl QuestionOption {
|
||||
/// An option that is only its label, which is most of them.
|
||||
pub fn plain(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
description: None,
|
||||
preview: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a session can tell the outside world. Every event is
|
||||
/// appended to the session's transcript with a sequence number, then fanned
|
||||
/// out to SSE subscribers; the phone renders purely from this stream, so
|
||||
@@ -93,7 +122,22 @@ pub enum Event {
|
||||
Question {
|
||||
id: String,
|
||||
prompt: String,
|
||||
options: Vec<String>,
|
||||
/// A few words naming what the question is about, when the asker
|
||||
/// offered one -- a tag beside the question rather than part of
|
||||
/// it. `None` for a permission, which is about the call above it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
header: Option<String>,
|
||||
options: Vec<QuestionOption>,
|
||||
/// Whether several options may be chosen at once.
|
||||
///
|
||||
/// Here rather than left for a phone to work out from the dialect
|
||||
/// underneath: how many answers a question takes is a fact about
|
||||
/// the question, and the alternative was the app parsing Claude
|
||||
/// Code's tool input to find out -- one dialect's schema, written
|
||||
/// out a second time in Kotlin, where no other dialect could
|
||||
/// reach it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
multi_select: bool,
|
||||
/// The tool call this is permission for, when it is one.
|
||||
///
|
||||
/// The CLI's `can_use_tool` request carries the `tool_use_id` of
|
||||
@@ -120,9 +164,15 @@ pub enum Event {
|
||||
/// The manager's record of a question being answered, so a rendered
|
||||
/// question card resolves on every device, not just the one that
|
||||
/// answered it.
|
||||
///
|
||||
/// A list because a question can take several answers, and one that
|
||||
/// took one is the list of length one rather than a different shape.
|
||||
/// What a dialect makes of that -- Claude Code's answers map holds a
|
||||
/// string, so several become one line -- is that dialect's business
|
||||
/// and is done where it talks to it.
|
||||
Answered {
|
||||
id: String,
|
||||
answer: String,
|
||||
answers: Vec<String>,
|
||||
},
|
||||
Status {
|
||||
state: SessionStatus,
|
||||
@@ -217,7 +267,10 @@ pub trait Driver: Send + Sync {
|
||||
/// message in the transcript, so a driver that never sends it drops
|
||||
/// the message from the conversation entirely.
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>);
|
||||
fn answer_question(&self, id: &str, answer: &str);
|
||||
/// Answers one question with everything that was chosen, in the order
|
||||
/// it was offered. One answer is a list of one; a driver whose dialect
|
||||
/// takes a single value joins them where it writes it.
|
||||
fn answer_question(&self, id: &str, answers: &[String]);
|
||||
/// Stop mid-run; the session survives.
|
||||
fn interrupt(&self);
|
||||
fn set_model(&self, model: &str);
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -371,7 +371,7 @@ impl Driver for LlamaDriver {
|
||||
});
|
||||
}
|
||||
|
||||
fn answer_question(&self, _id: &str, _answer: &str) {
|
||||
fn answer_question(&self, _id: &str, _answers: &[String]) {
|
||||
// Nothing here asks questions: this driver has no tools, so no
|
||||
// permission prompts and no AskUserQuestion.
|
||||
}
|
||||
|
||||
@@ -163,12 +163,12 @@ impl LiveSession {
|
||||
self.driver.send_user_message(text, images);
|
||||
}
|
||||
|
||||
pub fn answer_question(&self, question_id: &str, answer: &str) {
|
||||
pub fn answer_question(&self, question_id: &str, answers: &[String]) {
|
||||
let _ = self.sink.send(Event::Answered {
|
||||
id: question_id.to_string(),
|
||||
answer: answer.to_string(),
|
||||
answers: answers.to_vec(),
|
||||
});
|
||||
self.driver.answer_question(question_id, answer);
|
||||
self.driver.answer_question(question_id, answers);
|
||||
}
|
||||
|
||||
pub fn interrupt(&self) {
|
||||
@@ -1360,11 +1360,11 @@ mod tests {
|
||||
})
|
||||
.expect("question event");
|
||||
|
||||
session.answer_question(&question_id, "Yes");
|
||||
session.answer_question(&question_id, &["Yes".to_string()]);
|
||||
let seen = collect_until(&mut rx, is_idle).await;
|
||||
assert!(seen.iter().any(|entry| matches!(
|
||||
&entry.event,
|
||||
Event::Answered { id, answer } if *id == question_id && answer == "Yes"
|
||||
Event::Answered { id, answers } if *id == question_id && answers == &["Yes".to_string()]
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::driver::SessionStatus;
|
||||
use crate::session::driver::{QuestionOption, SessionStatus};
|
||||
|
||||
fn text(delta: &str) -> Event {
|
||||
Event::AssistantText {
|
||||
@@ -337,12 +337,14 @@ mod tests {
|
||||
Event::Question {
|
||||
id: "q1".into(),
|
||||
prompt: "Allow?".into(),
|
||||
options: vec!["Yes".into(), "No".into()],
|
||||
header: None,
|
||||
options: vec![QuestionOption::plain("Yes"), QuestionOption::plain("No")],
|
||||
multi_select: false,
|
||||
about: None,
|
||||
},
|
||||
Event::Answered {
|
||||
id: "q1".into(),
|
||||
answer: "Yes".into(),
|
||||
answers: vec!["Yes".into()],
|
||||
},
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
|
||||
Reference in new issue
Block a user