Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
694535badc
3 files changed
+182
-3
No files matched your search
+134
-1
@@ -25,6 +25,12 @@
|
||||
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
||||
//! that renders echo sessions correctly renders the real thing.
|
||||
//!
|
||||
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
|
||||
//! different lengths, single tool calls, runs of adjacent ones, images
|
||||
//! and a peer message. Rows of every shape and height the app draws, in
|
||||
//! one session, which is what a scrolling problem needs in order to be
|
||||
//! reproduced twice the same way.
|
||||
//!
|
||||
//! `/slow` earns its place: a queued message, a Stop button, a spinner
|
||||
//! where the answer will go are all states that only exist mid-turn, and
|
||||
//! the obvious way to get one -- ask a real model to sleep -- does not
|
||||
@@ -33,6 +39,7 @@
|
||||
//! account. A driver that can be *told* to take its time costs nothing and
|
||||
//! is the same every run.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -76,6 +83,9 @@ pub struct EchoDriver {
|
||||
/// Held messages with the id of the `MessageQueued` each one announced,
|
||||
/// so the announcement can say which waiting bubble it resolves.
|
||||
queued: Arc<Mutex<Vec<(String, String)>>>,
|
||||
/// Where `/mixed` writes the images it references, which is the same
|
||||
/// directory the files route serves them from.
|
||||
session_dir: PathBuf,
|
||||
/// 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
|
||||
@@ -316,6 +326,9 @@ impl EchoDriver {
|
||||
// rather than trusted: this is a test affordance, and a session
|
||||
// pinned running for an hour by a typo is a worse outcome than a
|
||||
// short wait.
|
||||
let mixed = text
|
||||
.strip_prefix("/mixed")
|
||||
.map(|rest| rest.trim().parse::<usize>().unwrap_or(12).clamp(1, 400));
|
||||
let linger = text.strip_prefix("/slow").map(|rest| {
|
||||
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600))
|
||||
});
|
||||
@@ -324,6 +337,7 @@ impl EchoDriver {
|
||||
.map(|rest| rest.trim().to_string());
|
||||
let busy = Arc::clone(&self.busy);
|
||||
let queued = Arc::clone(&self.queued);
|
||||
let dir = self.session_dir.clone();
|
||||
busy.store(true, Ordering::SeqCst);
|
||||
tokio::spawn(async move {
|
||||
let send = |event: Event| {
|
||||
@@ -396,6 +410,14 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(beats) = mixed {
|
||||
for beat in 1..=beats {
|
||||
write_beat(&sink, &dir, beat).await;
|
||||
}
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(input) = run_tool {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
@@ -429,12 +451,13 @@ impl EchoDriver {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn new(sink: EventSink) -> Self {
|
||||
pub fn new(sink: EventSink, session_dir: PathBuf) -> Self {
|
||||
let driver = Self {
|
||||
sink,
|
||||
pending_questions: Mutex::new(Vec::new()),
|
||||
busy: Arc::new(AtomicBool::new(false)),
|
||||
queued: Arc::new(Mutex::new(Vec::new())),
|
||||
session_dir,
|
||||
};
|
||||
driver.emit(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
@@ -450,6 +473,116 @@ impl EchoDriver {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 16x10 checkerboard, the smallest thing that is recognisably an image
|
||||
/// rather than a blank rectangle.
|
||||
///
|
||||
/// Embedded rather than generated because the alternative is a PNG encoder
|
||||
/// in a test rig, and drawn at the transcript's fixed thumbnail height
|
||||
/// anyway -- what a scroll test needs from an image is that it occupies an
|
||||
/// image's worth of space, not that it is pretty.
|
||||
const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg==";
|
||||
|
||||
/// One beat of `/mixed`: a row shape chosen by position, so the same N
|
||||
/// always produces the same transcript.
|
||||
///
|
||||
/// Repeatable on purpose. A scrolling fault is judged by watching the same
|
||||
/// content behave differently, and a rig that produced a different
|
||||
/// transcript each run would make every comparison an argument about
|
||||
/// whether the content changed.
|
||||
async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
let send = |event: Event| {
|
||||
let _ = sink.send(event);
|
||||
};
|
||||
match beat % 5 {
|
||||
// A paragraph, of three lengths, because a list of uniform rows
|
||||
// hides exactly the faults that uneven ones expose.
|
||||
1 => {
|
||||
let words = match beat % 3 {
|
||||
0 => 12,
|
||||
1 => 60,
|
||||
_ => 220,
|
||||
};
|
||||
// Deliberately ragged: each word's length is a function of its
|
||||
// position, so no two lines wrap the same way. A paragraph of
|
||||
// uniform tokens is a wall that looks identical at every
|
||||
// offset, which makes it impossible to tell a scroll of one
|
||||
// line from a scroll of ten -- by eye or by comparing frames.
|
||||
let body: String = (0..words)
|
||||
.map(|w| {
|
||||
let len = 3 + (w * 7 + beat * 3) % 14;
|
||||
format!("{beat}.{w}{} ", "x".repeat(len))
|
||||
})
|
||||
.collect();
|
||||
send(Event::AssistantText {
|
||||
delta: format!("\n\nParagraph at beat {beat}:\n{body}"),
|
||||
});
|
||||
}
|
||||
// One call on its own -- drawn as a card rather than a group.
|
||||
2 => {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "Read".to_string(),
|
||||
input: serde_json::json!({ "file_path": format!("/tmp/beat-{beat}.txt") }),
|
||||
});
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}: forty-two lines of nothing in particular"),
|
||||
});
|
||||
}
|
||||
// A run of three, which the app folds into one collapsed group --
|
||||
// the row whose identity depends on what is next to it.
|
||||
3 => {
|
||||
for i in 1..=3 {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: if i % 2 == 0 { "Bash" } else { "Grep" }.to_string(),
|
||||
input: serde_json::json!({ "command": format!("grep -rn 'beat {beat}' /tmp") }),
|
||||
});
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}, call {i} of 3"),
|
||||
});
|
||||
}
|
||||
}
|
||||
// An image, under the call that produced it, which is where a real
|
||||
// screenshot lands.
|
||||
4 => {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "Screenshot".to_string(),
|
||||
input: serde_json::json!({ "description": format!("beat {beat}") }),
|
||||
});
|
||||
let part = serde_json::json!({
|
||||
"source": {"media_type": "image/png", "data": SAMPLE_PNG}
|
||||
});
|
||||
if let Some(name) = super::claude::translate::save_image(session_dir, &part) {
|
||||
send(Event::Image {
|
||||
image: name,
|
||||
about: Some(id.clone()),
|
||||
});
|
||||
}
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("beat {beat}: captured"),
|
||||
});
|
||||
}
|
||||
// Somebody else's voice, which is its own row shape.
|
||||
_ => {
|
||||
send(Event::PeerMessage {
|
||||
from: format!("beat-{beat}-peer"),
|
||||
text: format!("Message {beat} from another session, for the row it becomes."),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Slow enough that the phone renders each beat as it arrives rather
|
||||
// than composing the whole run in one frame -- which is the condition
|
||||
// a scrolling fault actually happens under.
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
}
|
||||
|
||||
/// Ending a turn is also when anything held during it is taken up -- the
|
||||
/// moment a real CLI would have injected it. One place, because a turn has
|
||||
/// several ways to end (a reply, an interrupt, a compaction) and every one
|
||||
|
||||
@@ -1235,7 +1235,7 @@ fn launch(
|
||||
}
|
||||
|
||||
let driver: Arc<dyn Driver> = match provider.kind {
|
||||
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone())),
|
||||
DriverKind::Echo => Arc::new(EchoDriver::new(sink.clone(), dir.clone())),
|
||||
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
|
||||
&meta,
|
||||
provider,
|
||||
|
||||
Reference in new issue
Block a user