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

@@ -177,6 +177,52 @@ sealed class TranscriptItem {
) : TranscriptItem() ) : TranscriptItem()
} }
/**
* Puts a page of older items in front of the ones already loaded, healing any tool call the page
* boundary cut in two.
*
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
* concatenating the two lists left *both*: the same call twice, once as a proper card and once as a
* nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than
* the miscount -- the extra row is at the join, so it also moves everything the reader was looking
* at.
*
* Merged by the call's own id rather than by position, because position is exactly what a page
* boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the
* newer on what an end knows (the output, and whether it finished), which is the only way round
* that loses nothing.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val startedEarlier =
earlier.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
if (startedEarlier.isEmpty()) return earlier + later
val endedLater =
later
.filterIsInstance<TranscriptItem.ToolRun>()
.associateBy { it.id }
.filterKeys { it in startedEarlier }
if (endedLater.isEmpty()) return earlier + later
val healed = earlier.map { row ->
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
if (row is TranscriptItem.ToolRun && half != null) {
row.copy(
output = half.output,
done = half.done,
// Kept from both halves: a question or an image can be attached to either,
// depending on which side of the boundary its event fell.
asks = row.asks + half.asks,
images = row.images + half.images,
)
} else {
row
}
}
return healed + later.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
}
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> = fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
when (val event = entry.event) { when (val event = entry.event) {
is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text) is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text)
@@ -636,7 +682,7 @@ fun SessionScreen(
earlier = foldEvent(earlier, entry) earlier = foldEvent(earlier, entry)
} }
} }
items = earlier + items items = joinPages(earlier, items)
} }
} catch (_: ApiException) { } catch (_: ApiException) {
// Leave `moreHistory` alone: the next scroll asks again. // Leave `moreHistory` alone: the next scroll asks again.
+134 -1
View File
@@ -25,6 +25,12 @@
//! This is exactly the event vocabulary the real drivers produce, so a UI //! This is exactly the event vocabulary the real drivers produce, so a UI
//! that renders echo sessions correctly renders the real thing. //! 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 //! `/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 //! 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 //! 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 //! account. A driver that can be *told* to take its time costs nothing and
//! is the same every run. //! is the same every run.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@@ -76,6 +83,9 @@ pub struct EchoDriver {
/// Held messages with the id of the `MessageQueued` each one announced, /// Held messages with the id of the `MessageQueued` each one announced,
/// so the announcement can say which waiting bubble it resolves. /// so the announcement can say which waiting bubble it resolves.
queued: Arc<Mutex<Vec<(String, String)>>>, 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 /// 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 /// 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 /// 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 // 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 // pinned running for an hour by a typo is a worse outcome than a
// short wait. // 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| { let linger = text.strip_prefix("/slow").map(|rest| {
Duration::from_secs(rest.trim().parse::<u64>().unwrap_or(30).clamp(1, 600)) 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()); .map(|rest| rest.trim().to_string());
let busy = Arc::clone(&self.busy); let busy = Arc::clone(&self.busy);
let queued = Arc::clone(&self.queued); let queued = Arc::clone(&self.queued);
let dir = self.session_dir.clone();
busy.store(true, Ordering::SeqCst); busy.store(true, Ordering::SeqCst);
tokio::spawn(async move { tokio::spawn(async move {
let send = |event: Event| { let send = |event: Event| {
@@ -396,6 +410,14 @@ impl EchoDriver {
return; 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 { if let Some(input) = run_tool {
let id = format!("t-{}", super::random_hex()); let id = format!("t-{}", super::random_hex());
send(Event::ToolStart { 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 { let driver = Self {
sink, sink,
pending_questions: Mutex::new(Vec::new()), pending_questions: Mutex::new(Vec::new()),
busy: Arc::new(AtomicBool::new(false)), busy: Arc::new(AtomicBool::new(false)),
queued: Arc::new(Mutex::new(Vec::new())), queued: Arc::new(Mutex::new(Vec::new())),
session_dir,
}; };
driver.emit(Event::Status { driver.emit(Event::Status {
state: SessionStatus::Idle, 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 /// 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 /// 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 /// 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 { 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( DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
&meta, &meta,
provider, provider,