Merge remote-tracking branch 'origin/main'

This commit is contained in:
iris committed 2026-08-29 23:43:26 -04:00
commit 694535badc
3 files changed
+182 -3

No files matched your search

+134 -1
View File
@@ -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
+1 -1
View File
@@ -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,