Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts: # AGENTS.md # PLAN.md # app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt # app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt # server/src/config.rs # server/src/main.rs # server/src/routes.rs # server/src/session/echo.rs # server/src/session/llama.rs # server/src/session/transport.rs # server/src/ssh.rs # server/src/usage.rs
This commit is contained in:
commit
3c0214ece8
94 files changed
+8299
-9454
No files matched your search
+190
-255
@@ -1,63 +1,51 @@
|
||||
//! The phase-1 fake driver: no child process, just events. It exists to
|
||||
//! prove the whole pipe -- spawn, transcript, SSE cursors, questions,
|
||||
//! interrupts, compaction -- before any AI is involved, and stays useful afterwards as
|
||||
//! a connectivity check that costs no tokens.
|
||||
//! 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.
|
||||
//!
|
||||
//! Behavior: every message is echoed back as a few streamed text deltas.
|
||||
//! A leading word asks for something more specific:
|
||||
//! 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.
|
||||
//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them
|
||||
//! looks like when a screen groups them. `gap` is seconds between one
|
||||
//! call and the next, default none: it is what makes a run *grow* while
|
||||
//! somebody is looking at it, which is 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 can also be reached with an image
|
||||
//! open full screen -- which is where it used to close itself.
|
||||
//! - `/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 and the screen has to keep it out of
|
||||
//! the collapsed group around it.
|
||||
//! - `/slow [seconds]` -- a turn that stays running (default 30), so states that only
|
||||
//! exist *while* something is happening can be looked at.
|
||||
//! - `/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]` -- a message from another agent, which otherwise takes
|
||||
//! two live sessions and one of them deciding to write.
|
||||
//! - `/usage [what]` -- puts up an invented rate-limit answer, or takes
|
||||
//! it away again (`/usage off`). An echo session meters nothing, so it
|
||||
//! draws no usage bar at all until this is set; what it exists for is
|
||||
//! the states the 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, which is where the states live.
|
||||
//! - `/compact` -- a compaction, start to finish. Typed rather than
|
||||
//! pressed, because the real dialects take it as a typed command too and
|
||||
//! the phone no longer has a button for it.
|
||||
//! - `/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.
|
||||
//! - `/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.
|
||||
//!
|
||||
//! This is exactly the event vocabulary the real drivers produce, so a UI
|
||||
//! that renders echo sessions correctly renders the real thing.
|
||||
//!
|
||||
//! - `/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: paragraphs of
|
||||
//! different lengths, single tool calls, runs of adjacent ones, attachments
|
||||
//! 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
|
||||
//! work. It declines, reasonably, and answers instantly instead, so the
|
||||
//! state never arrives and the attempt still costs a turn on somebody's
|
||||
//! account. A driver that can be *told* to take its time costs nothing and
|
||||
//! is the same every run.
|
||||
//! `/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};
|
||||
@@ -69,24 +57,18 @@ use super::driver::{
|
||||
};
|
||||
|
||||
/// Delay between streamed deltas -- long enough that streaming is visibly
|
||||
/// streaming in the UI, short enough that tests waiting on a full turn
|
||||
/// stay fast.
|
||||
/// 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, and a large one
|
||||
/// takes minutes. Three seconds -- what this was -- is too short to look
|
||||
/// at the row that only exists while a compaction is running, and too
|
||||
/// short to watch its elapsed count reach two digits.
|
||||
/// 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 and the call ends when the last of them is answered.
|
||||
/// 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>,
|
||||
@@ -96,42 +78,35 @@ 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 -- the status
|
||||
/// dropped to idle immediately, so a phone had nothing to show as
|
||||
/// pending. Holding it here is what makes echo able to stand in.
|
||||
/// 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.
|
||||
/// 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 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
|
||||
/// them is answered rather than the first.
|
||||
/// 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`].
|
||||
/// 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. The
|
||||
/// numbers are invented like everything else here; what is real is
|
||||
/// which way they move.
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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 -- and because the point of the
|
||||
/// fixture is what a question looks like with work around it.
|
||||
/// 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());
|
||||
@@ -149,16 +124,15 @@ impl EchoDriver {
|
||||
|
||||
/// 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: one question with four options
|
||||
/// reads fine even when the options are laid out badly. 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.
|
||||
/// 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.
|
||||
// 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",
|
||||
@@ -248,8 +222,7 @@ impl EchoDriver {
|
||||
header: Some(header.to_string()),
|
||||
options,
|
||||
multi_select: multi,
|
||||
// The call that asked, so all of it draws as one thing --
|
||||
// which is the whole point of the fixture.
|
||||
// The call that asked, so all of it draws as one thing.
|
||||
about: Some(call.clone()),
|
||||
});
|
||||
}
|
||||
@@ -260,23 +233,21 @@ impl EchoDriver {
|
||||
|
||||
/// One typed line, whether it arrived as a message or as a command.
|
||||
///
|
||||
/// `announce` is the difference and it is the whole of it: 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.
|
||||
/// `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.
|
||||
// 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 or the states
|
||||
// it exists to exercise are not the app's real ones.
|
||||
// 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()
|
||||
@@ -292,17 +263,11 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
// Answered on the spot rather than in the turn below, because a
|
||||
// peer message is not a turn: it is something that arrives, and
|
||||
// what is being exercised is the row it becomes. The message that
|
||||
// asked for it is still announced -- every driver owes exactly one
|
||||
// `MessageTaken` per message, and a command that quietly vanishes
|
||||
// from the transcript is the one thing echo must not model.
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -357,11 +322,10 @@ impl EchoDriver {
|
||||
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, all of which read the usage
|
||||
// route rather than this transcript.
|
||||
// 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 {
|
||||
@@ -380,9 +344,9 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
// The same word the real CLI takes, so a phone drives both the same
|
||||
// way. `Driver::compact` is what the manager's own route calls;
|
||||
// this is the typed path onto it.
|
||||
// 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 {
|
||||
@@ -438,23 +402,20 @@ impl EchoDriver {
|
||||
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".
|
||||
// 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 and this
|
||||
// exists to produce a run.
|
||||
// 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 to wait between calls, default none. A run that
|
||||
// arrives all at once cannot exercise anything about 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, and 50ms apart is faster than anybody can open one.
|
||||
// How long to wait between calls, 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.
|
||||
let gap = Duration::from_secs(
|
||||
words
|
||||
.next()
|
||||
@@ -473,21 +434,19 @@ impl EchoDriver {
|
||||
let run_bash = text
|
||||
.strip_prefix("/bash")
|
||||
.map(|rest| rest.trim().to_string());
|
||||
// Seconds to stay running before answering, default 30. Clamped
|
||||
// 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.
|
||||
// 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 the thing the phone
|
||||
// has to react to: a narrow table lays itself out across the
|
||||
// screen and a wide one has to start scrolling sideways, and the
|
||||
// boundary between the two is where the layout is wrong.
|
||||
// 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));
|
||||
@@ -507,10 +466,9 @@ impl EchoDriver {
|
||||
let _ = sink.send(event);
|
||||
};
|
||||
let finish = || finish_turn(&sink, &queued, &busy);
|
||||
// Echo takes a message the instant it gets one, but it says so
|
||||
// anyway: a driver that skips this leaves the phone holding a
|
||||
// message it thinks is still queued, and the point of an echo
|
||||
// provider is that it behaves like the real ones.
|
||||
// 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,
|
||||
@@ -523,8 +481,7 @@ impl EchoDriver {
|
||||
});
|
||||
|
||||
if let Some(linger) = linger {
|
||||
// A delta a second: visibly alive rather than merely slow,
|
||||
// which is what the states being looked at accompany.
|
||||
// A delta a second: visibly alive rather than merely slow.
|
||||
let seconds = linger.as_secs();
|
||||
for remaining in (1..=seconds).rev() {
|
||||
send(Event::AssistantText {
|
||||
@@ -567,16 +524,14 @@ impl EchoDriver {
|
||||
"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 actually about: an image opened full
|
||||
// screen from a call that is alone, and then a second
|
||||
// call arriving and 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. Any of the calls would do; the
|
||||
// first is the one that is on its own for a whole
|
||||
// `gap`, which is the window somebody can open it in.
|
||||
// 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}
|
||||
@@ -600,9 +555,9 @@ impl EchoDriver {
|
||||
|
||||
// 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.
|
||||
// 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;
|
||||
@@ -667,7 +622,6 @@ impl EchoDriver {
|
||||
});
|
||||
}
|
||||
|
||||
// Word-at-a-time so streaming is visibly streaming.
|
||||
for word in format!("You said: {text}").split_inclusive(' ') {
|
||||
send(Event::AssistantText {
|
||||
delta: word.to_string(),
|
||||
@@ -675,8 +629,7 @@ impl EchoDriver {
|
||||
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,
|
||||
// which is enough to watch it climb between compactions.
|
||||
// roughly a hundred tokens a turn plus the words themselves.
|
||||
let spent = text.split_whitespace().count() as u64;
|
||||
send(Event::UsageDelta {
|
||||
tokens: spent,
|
||||
@@ -703,36 +656,32 @@ impl EchoDriver {
|
||||
}
|
||||
|
||||
/// Sends are infallible from the driver's point of view: a closed sink
|
||||
/// means the session is being torn down, and there is nobody left to
|
||||
/// report to.
|
||||
/// means the session is being torn down.
|
||||
fn emit(&self, event: Event) {
|
||||
let _ = self.sink.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// 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.
|
||||
// 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,
|
||||
@@ -741,9 +690,8 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
};
|
||||
// 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.
|
||||
// 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;
|
||||
@@ -767,8 +715,8 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
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.
|
||||
// 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());
|
||||
@@ -815,30 +763,27 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// 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 to
|
||||
/// it. All three, because all three are what the `MessageTaken` at the other
|
||||
/// end owes -- named rather than written out at each of the four places that
|
||||
/// mention it.
|
||||
/// `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 of that 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 is what decides whether
|
||||
/// the table fits the screen or has to scroll sideways.
|
||||
/// 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.
|
||||
/// 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",
|
||||
@@ -890,17 +835,16 @@ fn markdown_table(columns: usize) -> String {
|
||||
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.
|
||||
/// 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.
|
||||
// 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(),
|
||||
@@ -921,12 +865,10 @@ impl Driver for EchoDriver {
|
||||
!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 -- see its own `unqueue` -- so it
|
||||
/// cannot exercise the case where the drop succeeds.
|
||||
/// 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 {
|
||||
@@ -939,18 +881,15 @@ impl Driver for EchoDriver {
|
||||
}
|
||||
|
||||
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 -- the manager has already recorded that it was sent,
|
||||
// and announcing it again drew the same line twice, once in each
|
||||
// colour.
|
||||
// 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.
|
||||
/// 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);
|
||||
}
|
||||
@@ -966,8 +905,8 @@ impl Driver for EchoDriver {
|
||||
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.
|
||||
// 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()
|
||||
@@ -982,9 +921,9 @@ impl Driver for EchoDriver {
|
||||
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.
|
||||
// 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 {
|
||||
@@ -997,17 +936,16 @@ impl Driver for EchoDriver {
|
||||
}
|
||||
|
||||
fn interrupt(&self) {
|
||||
// Nothing real to stop; a pending question is abandoned so the
|
||||
// session isn't stuck awaiting input forever.
|
||||
// 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 it belongs to has already
|
||||
// happened where the name lives. See `Driver::set_title`.
|
||||
// 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) {
|
||||
@@ -1022,14 +960,11 @@ impl Driver for EchoDriver {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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. `Compacting` and `Compacted` are states a
|
||||
/// screen has to draw, and the only other way to reach them is to fill
|
||||
/// a real session's context and spend two minutes of somebody's
|
||||
/// account getting it back.
|
||||
/// 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);
|
||||
@@ -1041,10 +976,10 @@ impl Driver for EchoDriver {
|
||||
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.
|
||||
// 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),
|
||||
|
||||
Reference in new issue
Block a user