//! The fake driver: no child process, just events. It proves the whole pipe -- //! spawn, transcript, SSE cursors, questions, interrupts, compaction -- and //! stays useful afterwards as a connectivity check that costs no tokens. It //! produces exactly the event vocabulary the real drivers do, so a UI that //! renders echo sessions correctly renders the real thing. //! //! Every message is echoed back as a few streamed text deltas. A leading word //! asks for something more specific: //! //! - `/tool [input]` -- a full tool run, start through end. //! - `/bash [command]` -- a Bash call carrying that command, for what the //! phone's shell highlighting does to a particular line. //! - `/patch` -- one common patch call, for the diff presentation shared by //! real Codex and Claude sessions. //! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one //! call and the next, which is what makes a run *grow* while somebody is //! looking at it -- the only way to reach the state where a call opened on //! its own gains a neighbour. The first call carries a screenshot, so that //! state is also reachable with an image open full screen. //! - `/question [text]` -- a question, exercising the answer path. //! - `/ask` -- an AskUserQuestion call: two questions on one tool call, with //! descriptions, a preview and a multi-select, which is the shape that is //! awkward to get a real model to produce on demand. Wrapped in a run of //! ordinary calls on each side, because being asked something happens in the //! middle of work. //! - `/slow [seconds]` -- a turn that stays running (default 30), so states //! that only exist *while* something is happening can be looked at. //! - `/error [text]` -- a failure, which is otherwise awkward to cause. //! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the //! in-place and the live shapes. //! - `/usage [what]` -- an invented rate-limit answer, or `/usage off` to take //! it away. An echo session meters nothing, so it draws no usage bar until //! this is set; what it exists for is the states that bar can be in, which //! otherwise cost real quota to reach. `/usage 42`, `/usage 95 20`, //! `/usage 42 never`, `/usage notloggedin`, `/usage unreachable`, //! `/usage failed`. The vocabulary is `usage::Fixture`'s, where the states //! live. //! - `/limit [minutes]` -- a turn that stops because the account is out of //! quota, saying the limit lifts in `minutes` (default 5, and `never` for a //! limit with no stated reset). What it exists for is auto-resume, which is //! otherwise reachable only by actually exhausting somebody's account: pair //! it with `/usage 100 5` for a meter that agrees, and then `/usage 20` for //! the moment the limit lifts. The wait itself is decided by the meter, so //! those two commands are the whole rig. //! - `/compact` -- a compaction, start to finish. //! - `/stream N` -- one long answer in N small pieces, 50ms apart: the shape a //! real model's reply arrives in, and the one where the row a reader is //! anchored to is the row that keeps changing height. //! - `/mixed N` -- N beats of an interleaved transcript: 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. //! - `/table [columns]` -- a markdown table with cells too long for one line. //! - `/subagent [n]` -- n subagents at once (default 1), each named //! "helper k", its prompt recorded as its own first user message: a //! streamed reply, one Bash call, then it finishes about three seconds //! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`. //! The parent's own turn ends in `waiting` rather than `idle` while they //! run, each records its closing report in its own transcript, and the //! parent then runs a turn answering it -- which is the whole of the shape a //! real background Task produces, and the one where two replies used to be //! drawn as one paragraph. //! - `/background [seconds]` -- a backgrounded *command*: the same shape with //! no subagent behind it, so its report has nowhere to go but the card that //! launched it. Until then that card says the command is running, which is //! the stale claim this exists to watch being corrected. //! //! `/slow` earns its place: a queued message, a Stop button and a spinner are //! states that only exist mid-turn, and the obvious way to get one -- ask a //! real model to sleep -- does not work. It declines and answers instantly, so //! the state never arrives and the attempt still costs a turn. use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use super::driver::{ AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued, patch_start, }; use super::subagent::Subagents; /// Delay between streamed deltas -- long enough that streaming is visibly /// streaming, short enough that tests waiting on a full turn stay fast. const DELTA_DELAY: Duration = Duration::from_millis(50); /// How long a fake compaction takes. A measured one, near enough: driving a /// real session through `/compact` on 2026-08-29 took 13 seconds for a small /// conversation. Three seconds -- what this was -- is too short to look at the /// row that only exists while a compaction is running. const COMPACT_TIME: Duration = Duration::from_secs(13); /// A question echo is waiting on, and the tool call it belongs to. `call` is /// `None` for `/question`, which asks on its own the way a permission does; /// `Some` for `/ask`, where several questions share one call. struct PendingQuestion { id: String, call: Option, } pub struct EchoDriver { sink: EventSink, /// Whether a turn is in flight, and what arrived during it. /// /// A real CLI holds a message sent mid-turn and injects it at the next tool /// boundary; echo used to answer it on the spot, which made it the wrong /// shape for testing anything about queueing. busy: Arc, /// 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 attachments 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 asked. A list /// because `/ask` puts up to four on one tool call, and the turn resumes /// when the last is answered rather than the first. pending_questions: Mutex>, /// The invented rate-limit answer `/usage` sets, shared with the usage /// monitor that serves it. An echo session meters nothing, so this is unset /// until a test asks for something -- see [`crate::usage::Fixture`]. usage: crate::usage::Fixture, /// A pretend context, so the status row has something that behaves the way /// a real one does: it grows with each turn, drops to what the compaction /// says it recovered, and a clear leaves it unmeasured. What is real is /// which way the numbers move. context: Arc, /// Live background commands, for the same count a real provider reports. /// This is the deterministic UI/session-lifecycle rig for that state. background_tasks: Arc>, /// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the /// test rig for the same registry the claude driver routes real Task /// calls into. subagents: Arc, } impl EchoDriver { /// A short run of ordinary calls, to sit either side of something. Three, /// because two is the fewest that groups and three makes it obvious the /// group is a group. fn some_calls(&self, label: &str) { for index in 0..3 { let id = format!("echo-{label}-{index}-{}", super::random_hex()); self.emit(Event::ToolStart { id: id.clone(), tool: "echo-tool".to_string(), input: serde_json::json!({ "step": format!("{label} {index}") }), }); self.emit(Event::ToolEnd { id, output: format!("{label} step {index} finished"), }); } } /// An AskUserQuestion call, in the shape the CLI sends one. /// /// Two questions on one call, because that is where the display is hardest /// and where it was wrong. Written out in full rather than generated so it /// carries the parts that are 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()); self.emit(Event::Status { state: SessionStatus::Running, }); self.some_calls("before"); self.emit(Event::ToolStart { id: call.clone(), tool: "AskUserQuestion".to_string(), input: serde_json::json!({"questions": asked .iter() .map(|(header, question, multi, options)| serde_json::json!({ "question": question, "header": header, "multiSelect": multi, "options": options, })) .collect::>()}), }); for (index, (header, question, multi, options)) in asked.into_iter().enumerate() { let id = format!("{call}#{index}"); self.pending_questions .lock() .unwrap() .push(PendingQuestion { id: id.clone(), call: Some(call.clone()), }); self.emit(Event::Question { id, 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. about: Some(call.clone()), }); } self.emit(Event::Status { state: SessionStatus::AwaitingInput, }); } /// One typed line, whether it arrived as a message or as a command. /// /// `announce` is the whole difference: a message is announced with /// `MessageTaken`, which is what puts it in the transcript, and a command is /// not -- the manager has already recorded that one was sent, and saying so /// twice drew the same line in both colours. fn handle(&self, text: String, attachments: Vec, announce: bool) { let sink = self.sink.clone(); // Mid-turn messages are held rather than answered, the way a real CLI // holds them until the next tool boundary. Without this the session went // idle the instant one arrived, and every state that only exists while // something is queued was untestable. if self.busy.load(Ordering::SeqCst) { // The waiting is recorded, exactly as the real driver records it: // the phone draws its pending bubbles from the server, so an echo // session has to produce the same events. let id = super::random_hex(); self.queued .lock() .unwrap() .push((id.clone(), text.clone(), attachments.clone())); if announce { self.emit(Event::MessageQueued { id, text, attachments, }); } return; } // The live Claude Code shape, which is the one the ordering has to // survive: the CLI says nothing about a peer message until the turn's // `result`, so the event arrives below the whole reply it caused and the // phone has to put it back. Checked before `/peer`, which would // otherwise take the rest of this word as the body. if let Some(rest) = text.strip_prefix("/peer-turn") { if announce { self.emit(Event::MessageTaken { id: None, text: text.clone(), attachments: attachments.clone(), }); } self.emit(Event::Status { state: SessionStatus::Running, }); self.some_calls("peer"); self.emit(Event::AssistantText { delta: "Pulled, and AGENTS.md is up to date here now.".to_string(), }); self.emit(Event::PeerMessage { from: "dev-updater-f5".to_string(), text: if rest.trim().is_empty() { "Pull before you touch AGENTS.md.".to_string() } else { rest.trim().to_string() }, // Stamped by the manager, exactly as a real one is. turn_start: None, }); self.emit(Event::Status { state: SessionStatus::Idle, }); return; } if let Some(rest) = text.strip_prefix("/peer") { if announce { self.emit(Event::MessageTaken { id: None, text: text.clone(), attachments: attachments.clone(), }); } self.emit(Event::PeerMessage { from: "dev-updater-f5".to_string(), text: if rest.trim().is_empty() { "Pull before you touch AGENTS.md -- I pushed three commits to it \ in the last hour, and origin/main has moved since you last looked.\n\n\ The tree is clean as of now, but it was not for most of that time." .to_string() } else { rest.trim().to_string() }, turn_start: None, }); return; } // Answered here rather than in the turn below, because it is not a // turn: nothing is generated, and what is being exercised is the // *other* screens -- the bar under the header, the button beside it and // the dialog it opens, which read the usage route, not this transcript. if let Some(rest) = text.strip_prefix("/usage") { if announce { self.emit(Event::MessageTaken { id: None, text: text.clone(), attachments, }); } let said = self.usage.command(rest); self.emit(Event::AssistantText { delta: format!("{said}\n"), }); self.emit(Event::Status { state: SessionStatus::Idle, }); return; } // A turn that ends the way a real one does when the account runs out: // the same event a real driver reports, so what acts on it -- the // transcript row and `crate::resume` -- is exercised rather than // imitated. The meter it should agree with is `/usage`'s fixture, // deliberately separate: the two disagreeing is a state worth being // able to produce, since it is what a stale reset time looks like. if let Some(rest) = text.strip_prefix("/limit") { if announce { self.emit(Event::MessageTaken { id: None, text: text.clone(), attachments, }); } let rest = rest.trim(); let resets_at = match rest { "never" | "none" => None, "" => Some(super::now() + 5.0 * 60.0), minutes => Some(super::now() + minutes.parse::().unwrap_or(5.0) * 60.0), }; self.emit(Event::Status { state: SessionStatus::Running, }); self.emit(Event::AssistantText { delta: "Working on it".to_string(), }); self.emit(Event::LimitReached { resets_at }); self.emit(Event::Status { state: SessionStatus::Idle, }); return; } // `n` subagents at once, each with its own transcript in the // registry a real Task call routes into -- see `SUBAGENTS.md`. The // parent's own Task calls end when their subagent does, three // seconds later, which is long enough to see the running state on // the phone before it finishes. if let Some(rest) = text.strip_prefix("/subagent") { let n = rest.trim().parse::().unwrap_or(1).clamp(1, 8); if announce { self.emit(Event::MessageTaken { id: None, text: text.clone(), attachments, }); } self.emit(Event::Status { state: SessionStatus::Running, }); let sink = self.sink.clone(); let subagents = Arc::clone(&self.subagents); tokio::spawn(async move { let mut helpers = Vec::new(); for k in 1..=n { let id = format!("echo-subagent-{k}-{}", super::random_hex()); let title = format!("helper {k}"); let prompt = format!( "You are helper {k} of {n}. Say a few words, run a command, then stop." ); let _ = sink.send(Event::ToolStart { id: id.clone(), tool: "Task".to_string(), input: serde_json::json!({ "description": title, "prompt": prompt, "subagent_type": "general-purpose", }), }); subagents.start(&id, &title, Some(&prompt)); helpers.push((k, id, title, sink.clone(), Arc::clone(&subagents))); } // How many are still to report, so the last one to finish // is the one that puts the session back to idle -- see // `SessionStatus::Waiting`. let outstanding = Arc::new(AtomicU64::new(helpers.len() as u64)); for (k, id, title, sink, subagents) in helpers { tokio::spawn(run_helper( k, id, title, sink, subagents, Arc::clone(&outstanding), )); } // Not idle: the session's turn is over but its helpers are // still going, and it will speak again with nobody having // typed anything. let _ = sink.send(Event::Status { state: SessionStatus::Waiting, }); }); return; } // A backgrounded command: the task shape with no subagent behind it. // Its own verb because it is the *other* half of what a task // notification does -- a subagent's report goes into the subagent's // transcript, and this one has nowhere to go but the card that // launched it, which until then is still saying the command is // running. if let Some(rest) = text.strip_prefix("/background") { let seconds = rest.trim().parse::().unwrap_or(4).clamp(1, 120); if announce { self.emit(Event::MessageTaken { id: None, text: text.clone(), attachments, }); } self.emit(Event::Status { state: SessionStatus::Running, }); let id = format!("echo-background-{}", super::random_hex()); let command = format!("sleep {seconds} && echo done"); self.emit(Event::ToolStart { id: id.clone(), tool: "Bash".to_string(), input: serde_json::json!({ "command": command, "description": "wait a moment", "run_in_background": true, }), }); self.emit(Event::ToolEnd { id: id.clone(), output: format!("Command running in background with ID: {id}"), }); let background_tasks = Arc::clone(&self.background_tasks); { let mut count = background_tasks.lock().unwrap(); *count += 1; self.emit(Event::BackgroundTasks { count: *count }); } for word in "Started it; I'll pick this up when it lands.".split_inclusive(' ') { self.emit(Event::AssistantText { delta: word.to_string(), }); } // Not idle: the command is still going, and the session will speak // again with nobody having typed anything. self.emit(Event::Status { state: SessionStatus::Waiting, }); let sink = self.sink.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(seconds)).await; { let mut count = background_tasks.lock().unwrap(); *count -= 1; let _ = sink.send(Event::BackgroundTasks { count: *count }); } let _ = sink.send(Event::ToolUpdate { id, output: format!(r#"Background command "{command}" completed (exit code 0)"#), }); let _ = sink.send(Event::Status { state: SessionStatus::Running, }); for word in "Done -- it finished cleanly.".split_inclusive(' ') { let _ = sink.send(Event::AssistantText { delta: word.to_string(), }); tokio::time::sleep(DELTA_DELAY).await; } { let count = background_tasks.lock().unwrap(); let _ = sink.send(Event::Status { state: if *count == 0 { SessionStatus::Idle } else { SessionStatus::Waiting }, }); } }); return; } // The same word the real CLI takes, so a phone drives both the same way. // `Driver::compact` is what the manager's route calls; this is the typed // path onto it. if text.trim() == "/compact" { if announce { self.emit(Event::MessageTaken { id: None, text, attachments, }); } self.compact(); return; } if text.trim() == "/ask" { if announce { self.emit(Event::MessageTaken { id: None, text, attachments, }); } self.ask_user_question(); return; } if let Some(rest) = text.strip_prefix("/question") { let id = format!("q-{}", super::random_hex()); let prompt = if rest.trim().is_empty() { "Echo asks: proceed?".to_string() } else { format!("Echo asks: {}", rest.trim()) }; self.pending_questions .lock() .unwrap() .push(PendingQuestion { id: id.clone(), call: None, }); self.emit(Event::Status { state: SessionStatus::Running, }); self.emit(Event::Question { id, prompt, header: None, options: vec![QuestionOption::plain("Yes"), QuestionOption::plain("No")], multi_select: false, about: None, }); self.emit(Event::Status { state: SessionStatus::AwaitingInput, }); return; } // Checked before `/tool`, which is a prefix of it: matching the shorter // one first would read "/tools 4" as a single tool whose input is "s 4". let many_tools = text.strip_prefix("/tools").map(|rest| { let mut words = rest.split_whitespace(); // At least two, because one call is not a run of them. let count = words .next() .and_then(|w| w.parse().ok()) .unwrap_or(3usize) .clamp(2, 12); // How long each call spends running, default none. A run that // arrives all at once cannot exercise a run *growing*: the case // worth watching is a call somebody has opened and is reading when // the next one turns it into a group. Spent between the call's start // and its end rather than between one call and the next, because // that is where a real session's time goes -- and a call is drawn // outside its group while it runs, which is a state nothing could // see while every call here ended a few milliseconds after it // started. let gap = Duration::from_secs( words .next() .and_then(|w| w.parse().ok()) .unwrap_or(0u64) .clamp(0, 30), ); (count, gap) }); let run_tool = if many_tools.is_some() { None } else { text.strip_prefix("/tool") .map(|rest| rest.trim().to_string()) }; let run_bash = text .strip_prefix("/bash") .map(|rest| rest.trim().to_string()); let run_patch = text == "/patch"; // Seconds to stay running before answering, default 30. Clamped rather // than trusted: a session pinned running for an hour by a typo is a // worse outcome than a short wait. let stream = text .strip_prefix("/stream") .map(|rest| rest.trim().parse::().unwrap_or(400).clamp(1, 4000)); let mixed = text .strip_prefix("/mixed") .map(|rest| rest.trim().parse::().unwrap_or(12).clamp(1, 400)); // How many columns wide a fixture table should be, default six. The // count is the parameter because it is what the phone has to react to: a // narrow table lays itself out across the screen and a wide one has to // scroll sideways, and the boundary is where the layout is wrong. let table = text .strip_prefix("/table") .map(|rest| rest.trim().parse::().unwrap_or(6).clamp(1, 12)); // Seconds to spend thinking before the reply, default three. The rig // for the thinking card: a block that runs long enough to watch the // spinner, then ends with a duration to read. let think = text .strip_prefix("/think") .map(|rest| Duration::from_secs(rest.trim().parse::().unwrap_or(3).clamp(1, 600))); let linger = text.strip_prefix("/slow").map(|rest| { Duration::from_secs(rest.trim().parse::().unwrap_or(30).clamp(1, 600)) }); let fail = text .strip_prefix("/error") .map(|rest| rest.trim().to_string()); let busy = Arc::clone(&self.busy); let queued = Arc::clone(&self.queued); let context = Arc::clone(&self.context); let dir = self.session_dir.clone(); busy.store(true, Ordering::SeqCst); tokio::spawn(async move { let send = |event: Event| { let _ = sink.send(event); }; let finish = || finish_turn(&sink, &queued, &busy); // Echo takes a message the instant it gets one, but says so anyway: // a driver that skips this leaves the phone holding a message it // thinks is still queued. if announce { send(Event::MessageTaken { id: None, text: text.clone(), attachments: attachments.clone(), }); } send(Event::Status { state: SessionStatus::Running, }); if let Some(linger) = linger { // A delta a second: visibly alive rather than merely slow. let seconds = linger.as_secs(); for remaining in (1..=seconds).rev() { send(Event::AssistantText { delta: format!("still working, {remaining}s\n"), }); tokio::time::sleep(Duration::from_secs(1)).await; } send(Event::AssistantText { delta: "done.".to_string(), }); finish(); return; } if let Some(message) = fail { send(Event::Error { message: if message.is_empty() { "echo was asked to fail".to_string() } else { message }, }); finish(); return; } if let Some((count, gap)) = many_tools { for i in 1..=count { let id = format!("t-{}", super::random_hex()); send(Event::ToolStart { id: id.clone(), tool: if i % 2 == 0 { "Read" } else { "Bash" }.to_string(), input: serde_json::json!({ "command": format!("grep -rn 'call {i}' /tmp | head -3"), "file_path": format!("/tmp/call-{i}.txt"), "description": format!("The {i} of {count} calls in this run"), "timeout": 5000, }), }); // The first call carries a screenshot, and only the first. // That is what makes this rig cover the case a growing run // is about: an image opened full screen from a call that is // alone, and then a second call turning that row into a // group. The dialog used to be inside the row, so the reader // was thrown back to the transcript by the session making // another tool call. The first call is the one that is on // its own for a whole `gap`. if i == 1 { let part = serde_json::json!({ "source": {"media_type": "image/png", "data": SAMPLE_PNG} }); if let Some(name) = super::claude::translate::save_image(&dir, &part) { send(Event::Image { image: name, about: Some(id.clone()), }); } } tokio::time::sleep(DELTA_DELAY + gap).await; send(Event::ToolEnd { id, output: format!("call {i} finished"), }); } finish(); return; } // One long answer arriving in small pieces, which is what a real // model does and what `/slow` does not: `/slow` emits a line a // second, so its message grows in steps a reader can watch one at a // time. A jump caused by the *anchor row itself* changing height // needs growth that is continuous. if let Some(pieces) = stream { for i in 0..pieces { let len = 3 + (i * 7) % 14; send(Event::AssistantText { delta: format!("{i}{} ", "x".repeat(len)), }); tokio::time::sleep(Duration::from_millis(50)).await; } finish(); return; } if let Some(columns) = table { send(Event::AssistantText { delta: markdown_table(columns), }); finish(); return; } if let Some(beats) = mixed { for beat in 1..=beats { write_beat(&sink, &dir, beat).await; } finish(); return; } if let Some(command) = run_bash { let id = format!("b-{}", super::random_hex()); send(Event::ToolStart { id: id.clone(), tool: "Bash".to_string(), input: serde_json::json!({ "command": command, "description": "Run what /bash was given", }), }); tokio::time::sleep(DELTA_DELAY).await; send(Event::ToolEnd { id, output: format!("ran: {command}"), }); } if run_patch { let id = format!("p-{}", super::random_hex()); send(patch_start( id.clone(), "--- src/example.rs\n+++ src/example.rs\n@@ -1,3 +1,3 @@\n fn answer() -> u8 {\n- 41\n+ 42\n }\n\n--- /dev/null\n+++ notes.md\n+- added bullet\n+added prose\n\n--- old-notes.md\n+++ /dev/null\n-- removed bullet\n-removed prose\n" .to_string(), )); tokio::time::sleep(DELTA_DELAY).await; send(Event::ToolEnd { id, output: String::new(), }); } if let Some(input) = run_tool { let id = format!("t-{}", super::random_hex()); send(Event::ToolStart { id: id.clone(), tool: "echo-tool".to_string(), input: serde_json::json!({ "input": input }), }); tokio::time::sleep(DELTA_DELAY).await; send(Event::ToolUpdate { id: id.clone(), output: "working...".to_string(), }); tokio::time::sleep(DELTA_DELAY).await; send(Event::ToolEnd { id, output: format!("echoed: {input}"), }); } if let Some(think) = think { let started = std::time::Instant::now(); for remaining in (1..=think.as_secs()).rev() { send(Event::Thinking { delta: format!( "Considering what to echo back, {remaining}s of it left. \ The reply is the message, which took some working out.\n\n" ), }); tokio::time::sleep(Duration::from_secs(1)).await; } // Measured here for the same reason a driver measures it: the // phone can only see when an event arrived. send(Event::ThinkingDone { ms: started.elapsed().as_millis() as u64, }); } let streaming = std::time::Instant::now(); let mut words = 0u64; for word in format!("You said: {text}").split_inclusive(' ') { send(Event::AssistantText { delta: word.to_string(), }); words += 1; tokio::time::sleep(DELTA_DELAY).await; } // A conversation gets bigger, so the pretend context does too: // roughly a hundred tokens a turn plus the words themselves. let spent = text.split_whitespace().count() as u64; send(Event::UsageDelta { tokens: spent, context: Some(context.fetch_add(spent + 100, Ordering::SeqCst) + spent + 100), // A real measurement of a pretend model: what this driver // emitted, over how long it took. A rig owes the app a figure // of the shape a real one has, not an invented value. tokens_per_second: Some(streaming.elapsed().as_secs_f64()) .filter(|elapsed| *elapsed > 0.0) .map(|elapsed| words as f64 / elapsed), // Nothing to read: this driver has no prompt to process. prefill_ms: None, }); finish(); }); } pub fn new( sink: EventSink, session_dir: PathBuf, usage: crate::usage::Fixture, subagents: Arc, ) -> Self { let driver = Self { sink, pending_questions: Mutex::new(Vec::new()), context: Arc::new(AtomicU64::new(0)), background_tasks: Arc::new(Mutex::new(0)), busy: Arc::new(AtomicBool::new(false)), queued: Arc::new(Mutex::new(Vec::new())), session_dir, usage, subagents, }; driver.emit(Event::Status { state: SessionStatus::Idle, }); driver } /// Sends are infallible from the driver's point of view: a closed sink /// means the session is being torn down. fn emit(&self, event: Event) { let _ = self.sink.send(event); } } /// A 16x10 checkerboard, the smallest thing recognisably an image rather than a /// blank rectangle. Embedded rather than generated because the alternative is a /// PNG encoder in a test rig, and what a scroll test needs from an image is /// that it occupies an image's worth of space. 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 looks identical at every offset, which makes it // impossible to tell a scroll of one line from a scroll of ten. 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."), turn_start: None, }); } } // 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; } /// One `/subagent` helper: a few streamed words, one Bash call, then /// `Status::Exited` about three seconds after it started -- long enough that /// its `Running` state can be seen on the phone before it finishes. The /// parent's own Task call for it ends at the same moment, the same way a /// real Task's `tool_result` ends it. /// One echo subagent's whole life, ending in the report its parent wakes up /// for. `outstanding` is how many helpers are still to report; the one that /// takes it to zero is the one that says the session is idle again, and `k` is /// which helper this is, which is what staggers them. async fn run_helper( k: usize, id: String, title: String, sink: EventSink, subagents: Arc, outstanding: Arc, ) { let start = tokio::time::Instant::now(); for word in "Working on it now.".split_inclusive(' ') { subagents.record( &id, Event::AssistantText { delta: word.to_string(), }, ); tokio::time::sleep(DELTA_DELAY).await; } let tool_id = format!("{id}-bash"); subagents.record( &id, Event::ToolStart { id: tool_id.clone(), tool: "Bash".to_string(), input: serde_json::json!({ "command": "echo helper done" }), }, ); tokio::time::sleep(DELTA_DELAY).await; subagents.record( &id, Event::ToolEnd { id: tool_id, output: "helper done".to_string(), }, ); // Staggered, one second apart: two helpers reporting at the same instant // interleave the parent's replies word by word, which is a fixture // artefact -- a real CLI runs one turn at a time -- and it hides the very // thing this is a fixture for. let target = Duration::from_secs(2 + k as u64); let elapsed = start.elapsed(); if elapsed < target { tokio::time::sleep(target - elapsed).await; } // The subagent's closing report, in the subagent's own transcript, which // is where a real one's goes and the only place it belongs -- recorded // before the ending, so it is not below it. let summary = format!("{title} finished and had nothing to report."); subagents.record( &id, Event::AssistantText { delta: summary.clone(), }, ); subagents.finish(&id); let _ = sink.send(Event::ToolEnd { id, output: "subagent finished".to_string(), }); // And then the turn the session runs because the task reported back. The // parent has to say something afterwards, since the defect this // reproduces is two replies meeting with nothing between them -- and // nothing at all about the helper goes into the *parent's* transcript, // which is the shape being reproduced. let _ = sink.send(Event::Status { state: SessionStatus::Running, }); for word in format!("Noted, {title} is done.").split_inclusive(' ') { let _ = sink.send(Event::AssistantText { delta: word.to_string(), }); tokio::time::sleep(DELTA_DELAY).await; } let last = outstanding.fetch_sub(1, Ordering::SeqCst) <= 1; let _ = sink.send(Event::Status { state: if last { SessionStatus::Idle } else { SessionStatus::Waiting }, }); } /// A message written during a turn and waiting for it to end: the id of the /// `MessageQueued` that announced it, what it said, and what was attached. All /// three, because all three are what the `MessageTaken` at the other end owes. type Held = (String, String, Vec); /// A markdown table [columns] wide, with cells too long for one line. /// /// Both halves matter. Long cells are what the renderer used to cut off with an /// ellipsis, and a cut cell looks exactly like a short one, so a fixture of /// tidy one-word values would have rendered perfectly while the defect was /// still there. The column count decides whether the table fits the screen. /// /// Written out as markdown rather than assembled from a grid type because what /// is being tested is the renderer's parse of the syntax a model actually /// writes, pipes and alignment row included. fn markdown_table(columns: usize) -> String { let headings = [ "What it is", "Where it lives", "What it costs", "Who asked for it", "When it changed", "Why it is here", "What replaces it", "What it breaks", "How it fails", "What to run", "Where to look", "What it assumes", ]; let values = [ "a value long enough that a single line of a narrow column cannot hold all of it", "~/.config/ai-app/config.ron", "about four gigabytes resident, measured rather than estimated", "somebody who could not read the last one of these", "2026-08-31, in the same change as the wrapping", "because a cell that ends in an ellipsis says nothing about what it left out", "nothing yet", "the alignment of every row beside it", "silently, which is the expensive way", "cargo test -p ai-server", "server/src/session/echo.rs", "that the reader can scroll sideways", ]; let mut out = String::from("Here is what that produced:\n\n"); let row = |cells: &mut dyn Iterator| { let mut line = String::from("|"); for cell in cells { line.push(' '); line.push_str(cell); line.push_str(" |"); } line.push('\n'); line }; out.push_str(&row(&mut headings.iter().take(columns).copied())); out.push_str(&row(&mut std::iter::repeat_n("---", columns))); for offset in 0..4 { out.push_str(&row( &mut (0..columns).map(|column| values[(column + offset * 5) % values.len()]) )); } out.push_str("\nAnd that is the table."); out } /// 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 of them owes /// the same answer. fn finish_turn(sink: &EventSink, queued: &Mutex>, busy: &AtomicBool) { let held = std::mem::take(&mut *queued.lock().unwrap()); for (id, text, attachments) in held { // Announced before it is answered, in that order: a phone showing the // message as pending needs the signal that it has been read, and the // answer is meaningless above a message still drawn as waiting. let _ = sink.send(Event::MessageTaken { id: Some(id), text: text.clone(), attachments, }); let _ = sink.send(Event::AssistantText { delta: format!("\n(taken from the queue) You said: {text}"), }); } busy.store(false, Ordering::SeqCst); let _ = sink.send(Event::Status { state: SessionStatus::Idle, }); } impl Driver for EchoDriver { fn background_tasks(&self) -> Option { Some(*self.background_tasks.lock().unwrap()) } fn between_turns(&self) -> bool { !self.busy.load(Ordering::SeqCst) } /// Really droppable, which is what makes this the rig for the phone's side /// of it: the held message is this driver's own and nothing has been written /// anywhere, so a tap here exercises the whole path through to the bubble /// disappearing on every device. The Claude driver can only ever refuse. fn unqueue(&self, id: &str) -> Unqueued { let mut queued = self.queued.lock().unwrap(); let Some(at) = queued.iter().position(|(waiting, ..)| waiting == id) else { return Unqueued::Unknown; }; queued.remove(at); drop(queued); self.emit(Event::MessageDropped { id: id.to_string() }); Unqueued::Dropped } fn send_user_message(&self, text: String, attachments: Vec) { // Announced, because this is a message: every driver owes exactly one // `MessageTaken` per message, and one that quietly vanishes from the // transcript is the thing echo must not model. A command owes none. self.handle(text, attachments, true); } /// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` -- so /// this is the same path with the same parsing, and the fixture behaves like /// a real session driven the same way. fn run_command(&self, text: &str) { self.handle(text.to_string(), Vec::new(), false); } 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 { self.emit(Event::Error { message: format!("no question {id} is awaiting an answer"), }); return; }; let answered = pending.remove(at); // Whether anything on the same call is still unanswered: a tool that // asked four questions ends once, not four times. let waiting = answered .call .as_ref() .is_some_and(|call| pending.iter().any(|q| q.call.as_ref() == Some(call))); (answered, waiting) }; if waiting { return; } if let Some(call) = answered.call { self.emit(Event::ToolEnd { id: call, output: format!("answered: {answer}"), }); // The work carries on where it left off, which is what makes the // asked-here row a boundary with a group on each side rather than // the last thing in the turn. self.some_calls("after"); } else { self.emit(Event::AssistantText { delta: format!("You answered: {answer}"), }); } self.emit(Event::Status { state: SessionStatus::Idle, }); } fn interrupt(&self) { // Nothing real to stop; a pending question is abandoned so the session // isn't stuck awaiting input forever. self.pending_questions.lock().unwrap().clear(); self.emit(Event::Status { state: SessionStatus::Idle, }); } // Nothing to forward: this process has no notion of what the conversation // is called, and the rename has already happened where the name lives. fn set_title(&self, _title: &str) {} fn set_permission_mode(&self, mode: &str) { self.emit(Event::Error { message: format!("an echo session asks for nothing, so {mode} changes nothing"), }); } fn set_model(&self, model: &str) { self.emit(Event::Error { message: format!("echo sessions have no model to change to {model}"), }); } /// A compaction with nothing to compact. The counts are invented, like /// everything else this driver says -- what is real is the shape and the /// order: busy, a pause long enough to see, then the result. The only other /// way to reach those states is to fill a real session's context and spend /// two minutes of somebody's account getting it back. fn compact(&self) { let sink = self.sink.clone(); let queued = Arc::clone(&self.queued); let busy = Arc::clone(&self.busy); let context = Arc::clone(&self.context); busy.store(true, Ordering::SeqCst); tokio::spawn(async move { let _ = sink.send(Event::Status { state: SessionStatus::Compacting, }); tokio::time::sleep(COMPACT_TIME).await; // What it says it recovered is what the pretend context becomes, so // the figure on the status row and the one on the divider agree -- // two numbers about the same moment disagreeing is the thing this // rig exists to catch. context.store(9_617, Ordering::SeqCst); let _ = sink.send(Event::Compacted { pre_tokens: Some(128_402), post_tokens: Some(9_617), trigger: Some("manual".to_string()), }); finish_turn(&sink, &queued, &busy); }); } /// The same marker a real driver leaves, and nothing else -- there is /// no context here to drop. It exists so the phone's divider, its /// scroll behaviour and the transcript's shape can be exercised /// without spending a real session's context to produce one. fn clear(&self) { self.context.store(0, Ordering::SeqCst); let _ = self.sink.send(Event::Cleared); } /// Nothing to detach from and nothing to stop: the echo driver has no /// process, so both halves of the way out are already done. fn detach(&self) {} fn stop(&self) {} }