A run of adjacent calls is drawn as one collapsed card, which hid the one thing worth seeing without opening anything: the command the session is running right now. It is a row of its own while it runs and folds back into the run when it ends. Grouping stays a display decision, so the pieces a running call cuts a run into are keyed there. The first piece keeps the run's name -- that name is what survives a page of history landing in front of it -- and later pieces take their own first call's id behind it, since the call a run was named after can itself be the one running. The echo rig's /tools gap now runs between a call's start and its end rather than between one call and the next, which is where a real session's time goes and what makes the running state observable at all. Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest (new ToolRowsTest) and lintDebug, cargo fmt/clippy/test, and on the emulator against the sandbox: "Called 2 tools" with the live Bash card beneath it.
1312 lines
55 KiB
Rust
1312 lines
55 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
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<AtomicBool>,
|
|
/// 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<Held>>>,
|
|
/// 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<Vec<PendingQuestion>>,
|
|
/// 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<AtomicU64>,
|
|
/// 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<Subagents>,
|
|
}
|
|
|
|
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::<Vec<_>>()}),
|
|
});
|
|
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<AttachmentRef>, 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::<f64>().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::<usize>().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::<u64>().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}"),
|
|
});
|
|
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 _ = 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 _ = sink.send(Event::Status {
|
|
state: SessionStatus::Idle,
|
|
});
|
|
});
|
|
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::<usize>().unwrap_or(400).clamp(1, 4000));
|
|
let mixed = text
|
|
.strip_prefix("/mixed")
|
|
.map(|rest| rest.trim().parse::<usize>().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::<usize>().unwrap_or(6).clamp(1, 12));
|
|
let linger = text.strip_prefix("/slow").map(|rest| {
|
|
Duration::from_secs(rest.trim().parse::<u64>().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"
|
|
.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}"),
|
|
});
|
|
}
|
|
|
|
for word in format!("You said: {text}").split_inclusive(' ') {
|
|
send(Event::AssistantText {
|
|
delta: word.to_string(),
|
|
});
|
|
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),
|
|
});
|
|
finish();
|
|
});
|
|
}
|
|
|
|
pub fn new(
|
|
sink: EventSink,
|
|
session_dir: PathBuf,
|
|
usage: crate::usage::Fixture,
|
|
subagents: Arc<Subagents>,
|
|
) -> Self {
|
|
let driver = Self {
|
|
sink,
|
|
pending_questions: Mutex::new(Vec::new()),
|
|
context: Arc::new(AtomicU64::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<Subagents>,
|
|
outstanding: Arc<AtomicU64>,
|
|
) {
|
|
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<AttachmentRef>);
|
|
|
|
/// 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<Item = &str>| {
|
|
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<Vec<Held>>, 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 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<AttachmentRef>) {
|
|
// 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) {}
|
|
}
|