diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index f3e3b0b..5225d0d 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -177,6 +177,52 @@ sealed class 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, later: List): List { + val startedEarlier = + earlier.filterIsInstance().mapTo(mutableSetOf()) { it.id } + if (startedEarlier.isEmpty()) return earlier + later + val endedLater = + later + .filterIsInstance() + .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, entry: SeqEvent): List = when (val event = entry.event) { is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(entry.seq, event.text) @@ -636,7 +682,7 @@ fun SessionScreen( earlier = foldEvent(earlier, entry) } } - items = earlier + items + items = joinPages(earlier, items) } } catch (_: ApiException) { // Leave `moreHistory` alone: the next scroll asks again. diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 5e7b5a1..a64d60d 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -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>>, + /// 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::().unwrap_or(12).clamp(1, 400)); let linger = text.strip_prefix("/slow").map(|rest| { Duration::from_secs(rest.trim().parse::().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 diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index eefc701..337d47d 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -1235,7 +1235,7 @@ fn launch( } let driver: Arc = 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,