Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 5428cd75c9
commit 25370731d0
193 files changed
+693 -16219

No files matched your search

-361
View File
@@ -1,51 +1,3 @@
//! The Claude Code driver: `claude -p` speaking stream-json on stdio,
//! translated into the common event model.
//!
//! This half owns the process -- starting it, *adopting one this server
//! left running*, writing lines to it, and ending it. Where it runs is
//! `session::transport`'s business, not this file's: this one emits a
//! `Launch` and never learns whether it became a local child or an ssh
//! invocation.
//!
//! The process is meant to outlive the server, so that restarting the
//! backend does not end a turn: its stdio lives in the session directory
//! (a fifo it holds open itself, plus logs read from a byte offset) and
//! `session::process` records what it is. Everything comes through
//! [`ClaudeDriver::launch`], which adopts if it can and starts if it
//! cannot -- `--resume` is reachable only on the second path, because two
//! CLIs on one session file duplicate the conversation into it.
//! Turning a line into [`Event`]s is [`translate`], which changes when the
//! CLI's wire format does rather than when any of the above does.
//!
//! The probing record below stays here, since it is the provenance for
//! both halves: the flags are this file's, the message catalogue is what
//! `translate` implements.
//!
//! Wire format pinned against CLI 2.1.237 by probing (2026-08-24; scripts
//! summarized here since they live outside the repo):
//!
//! - Outbound: `system/init` (carries `session_id`, the `--resume` token),
//! `stream_event` (raw API deltas; `text_delta` is the streaming text),
//! consolidated `assistant` messages (their `tool_use` blocks have the
//! complete input), `user` messages with `tool_result` blocks, a `result`
//! per turn (usage + cost), `control_request` for anything needing a
//! human, `control_response` answering ours.
//! - Permission prompts require the hidden `--permission-prompt-tool stdio`
//! flag; they arrive as `control_request{subtype:can_use_tool}` and are
//! answered with `{behavior:"allow",updatedInput}` or
//! `{behavior:"deny",message}`. `AskUserQuestion` uses the same shape,
//! with the chosen labels added to `updatedInput` as
//! `answers:{<question text>:<label>}`.
//! - Inbound `user` messages sent mid-turn are queued and injected at the
//! next tool boundary (verified live: the model acknowledged a steer
//! between two Bash calls) -- the behavior this app exists for.
//! - `control_request{subtype:set_model}` answers success;
//! `{subtype:interrupt}` stops the turn.
//! - `control_request{subtype:set_permission_mode}` answers success and
//! echoes the mode back (`{"response":{"mode":"acceptEdits"}}`), so the
//! mode is changeable mid-session rather than only at spawn. Probed the
//! same way as the rest, against 2.1.237 on 2026-08-29.
use std::collections::VecDeque;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
@@ -69,9 +21,6 @@ use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call};
/// because this is held per session for the life of the process.
const STDERR_LINES_KEPT: usize = 50;
/// The kept stderr as one block, with blank lines trimmed off both ends. The
/// trailing trim is the point: a shell's error ends with a blank line, so
/// anything reporting "the last line" reports nothing at all.
fn tail_of(kept: &VecDeque<String>) -> String {
let lines: Vec<&str> = kept.iter().map(String::as_str).collect();
let start = lines
@@ -86,41 +35,14 @@ fn tail_of(kept: &VecDeque<String>) -> String {
lines[start..end].join("\n")
}
/// Where the driver remembers its CLI session id between backend runs -- the
/// whole crash-recovery story, since respawning with `--resume <id>` picks the
/// conversation back up. Kept in the session directory rather than config.ron
/// so the shared schema stays free of per-driver state.
pub(super) mod translate;
const RESUME_FILE: &str = "claude-session.json";
/// Messages handed to the CLI that it has not visibly acted on yet.
///
/// The CLI *does* take a message written mid-turn: it goes into the next model
/// call, which is the next tool boundary, and steering a running turn is the
/// point of this app. An earlier version held every mid-turn message until the
/// turn ended, so a steer sent after the second tool call sat unread until all
/// the work it was meant to redirect had finished.
///
/// What the CLI does not do is say on stdout that it has read one. So the line
/// goes out immediately and the *announcement* waits here, until the CLI opens
/// the next model call -- see [`translate::starts_a_model_call`].
///
/// The proof has to be the model call and not the output. Assistant text and a
/// tool call both keep arriving from a message that was *already in flight*
/// when the steer was written, and that message saw none of it: a steer sent
/// while an answer was streaming was recorded in the middle of it, above tool
/// calls the model had already committed to.
#[derive(Default)]
struct Queue {
/// A turn is in flight, so a message sent now is a steer into it.
running: bool,
/// Written, not yet announced, oldest first, each with the id of the
/// `MessageQueued` that told the phone it was waiting -- so the
/// announcement can name which bubble it resolves.
awaiting: VecDeque<(String, String, Vec<AttachmentRef>)>,
/// The process is gone, so nothing can be taken up any more.
///
/// Needed because every other way out of a turn is an `Idle` this driver
/// sees, and an exit is the one that is not. Without it a process that
/// died mid-turn left `running` true for good, and since a message is only
@@ -177,37 +99,18 @@ const STDIN_FIFO: &str = "stdin.fifo";
const STDOUT_LOG: &str = "stdout.log";
const STDERR_LOG: &str = "stderr.log";
/// How often a reader with nothing to read looks again. A poll rather than a
/// watch: the alternative is an inotify dependency for one file per session,
/// and at this interval the streaming text already arrives faster than a phone
/// renders it.
const POLL: std::time::Duration = std::time::Duration::from_millis(50);
pub struct ClaudeDriver {
sink: EventSink,
queue: Arc<Mutex<Queue>>,
/// Lines for the process's stdin. Not closeable, unlike the pipe this used
/// to be: stdin is a fifo the process holds open itself, so closing this
/// end says nothing to it. Ending the process is [`Driver::stop`]'s job.
to_child: mpsc::UnboundedSender<String>,
state: Arc<Mutex<Translator>>,
session_dir: PathBuf,
/// Cleared to stop the reader without touching the process -- which is
/// exactly what detaching is.
reading: Arc<AtomicBool>,
}
impl ClaudeDriver {
/// Takes charge of this session's process: the one already running if
/// there is one, otherwise a new one.
///
/// One entry point rather than two, because the choice is not the caller's
/// and getting it wrong is the expensive bug. A second `--resume` against
/// a session file that is already open duplicates the whole conversation
/// into it and bills the reattached copy for re-reading it -- measured at
/// 65 MB and 154 screenshots on 2026-08-29. So `--resume` is reachable
/// only through the spawn half below, under a check that nothing is
/// running.
pub fn launch(
meta: &SessionConfig,
provider: &ProviderConfig,
@@ -223,13 +126,6 @@ impl ClaudeDriver {
let queue = Arc::new(Mutex::new(Queue::default()));
let reading = Arc::new(AtomicBool::new(true));
// Adopting is only possible for a process this server left behind on
// this machine: an ssh session's child died with the connection.
// Nothing was ever recorded for one, so this answers "no" without
// needing to know that.
//
// `started_here` is whether this launch *started* a process or picked
// one up; the two owe the session different things.
let started_here;
let record = match process::recorded(session_dir) {
// Still running, and ours. Pick it up where it was left -- the one
@@ -244,9 +140,6 @@ impl ClaudeDriver {
started_here = false;
record
}
// Recorded, and the machine will not say whether it is still
// there. Starting one anyway is the mistake this module is for, so
// nothing is started; `follow` keeps asking.
Some((record, process::Liveness::Unknown)) => {
tracing::warn!(
"session {} recorded pid {} but this machine won't say whether it is running; \
@@ -280,19 +173,11 @@ impl ClaudeDriver {
state: SessionStatus::Idle,
});
}
// Where reading of its output had reached. A process just started has
// said nothing, so its record says zero.
let resuming_from = match record.detail {
process::Detail::Stdio { stdout_read } => stdout_read,
// A record of the wrong shape belongs to a different driver; read
// its output from the start rather than trusting an offset into a
// file that means something else.
_ => 0,
};
// The writer end of the fifo. Opened write-only here: the process holds
// its own read-write handle, so this side coming and going across a
// restart is invisible to it.
let stdin = std::fs::OpenOptions::new()
.write(true)
.open(session_dir.join(STDIN_FIFO))
@@ -331,9 +216,6 @@ impl ClaudeDriver {
})
}
/// Starts a new CLI for this session, with its streams in the session
/// directory so the next run of this server can find them. The only path
/// that passes `--resume`, and it is reached only when nothing is running.
fn start(
meta: &SessionConfig,
provider: &ProviderConfig,
@@ -347,8 +229,6 @@ impl ClaudeDriver {
};
push("--input-format", "stream-json");
push("--output-format", "stream-json");
// Hidden but load-bearing: without it the CLI resolves permissions
// itself and nothing ever reaches the phone.
push("--permission-prompt-tool", "stdio");
if let Some(model) = &meta.model {
push("--model", model);
@@ -356,39 +236,14 @@ impl ClaudeDriver {
if let Some(mode) = &meta.permission_mode {
push("--permission-mode", mode);
}
// Launch-only: see `SessionConfig::effort`. Omitted entirely when
// unset, so the CLI's own default is what an unchosen session gets
// rather than a level this app decided to call the default.
if let Some(effort) = &meta.effort {
push("--effort", effort);
}
// Named at birth, so this session is the same session in the CLI's own
// picker and in what other agents see.
//
// Only when we are creating it. A resume is a session that already
// existed -- an import, or this server starting again -- and it already
// has whatever name it was given, quite possibly by the person typing
// in it. `Driver::set_title` is how it changes after this point, and
// that one is asked for.
match read_resume_token(session_dir) {
Some(resume) => push("--resume", &resume),
None => push("--name", &meta.title),
}
args.push("--include-partial-messages".to_string());
// Makes `bypassPermissions` *reachable* without selecting it: the
// session still starts in whatever mode was asked for above.
//
// The CLI is asymmetric about that mode. It will *launch* in
// `bypassPermissions` on `--permission-mode` alone, but refuses to
// *switch* into it later ("the session was not launched with
// --dangerously-skip-permissions"), so the phone's mode picker offered
// a mode that could not be picked on every session not given it at
// birth. Since the mode is already reachable at spawn, this grants
// nothing that was being withheld.
//
// Measured against 2.1.237 both ways round. Note it is the `--allow-`
// form; `--dangerously-skip-permissions` turns it on for everything,
// which would take the choice away from whoever holds the phone.
args.push("--allow-dangerously-skip-permissions".to_string());
// Fresh logs, because the offsets that index them start at zero and
@@ -435,21 +290,12 @@ impl ClaudeDriver {
let _ = self.to_child.send(line);
}
/// Writes one of the CLI's own commands into the session.
///
/// Slash commands ride the normal user-message channel -- there is no
/// control request for them, measured by asking. The turn they start is
/// marked here because they produce a `result` like any other, so a message
/// sent meanwhile belongs in the queue's "written, announce when read" path.
///
/// Nothing is emitted about the command itself: the manager has already
/// said it was sent, and the CLI announces what it does.
fn local_command(&self, text: String) {
let mut queue = self.queue.lock().unwrap();
// The same check `send_user_message` makes: a line written into a fifo
// nothing is reading goes nowhere and looks exactly like one that
// arrived. What this catches is the process going away between
// `Commands::submit`'s check and this write.
if queue.closed {
drop(queue);
let _ = self.sink.send(Event::Error {
@@ -474,19 +320,9 @@ impl ClaudeDriver {
);
}
/// Sends a control request, remembering what it asked for.
///
/// `confirms` is the setting this request will have made if the CLI answers
/// success -- see [`Translator::expect_setting`]. `None` for the ones that
/// change no setting, like an interrupt.
///
/// The id is random rather than the clock it used to be: two requests in
/// the same second shared an id.
fn send_control(&self, request: Value, confirms: Option<Setting>) {
let id = format!("req-{}", super::random_hex());
if let Some(setting) = confirms {
// Before the line goes out: the reader thread is already running,
// and a fast answer to a slow lock arrives first.
self.state
.lock()
.unwrap()
@@ -531,9 +367,6 @@ impl Driver for ClaudeDriver {
let line =
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
let mut queue = self.queue.lock().unwrap();
// Saying so beats writing into a fifo that nothing is reading, which is
// what this used to do -- the message went nowhere and looked exactly
// like one that had been delivered.
if queue.closed {
drop(queue);
let _ = self.sink.send(Event::Error {
@@ -543,13 +376,6 @@ impl Driver for ClaudeDriver {
return;
}
if queue.running {
// Into the running turn, now. Announced when the CLI shows it has
// been round the model again -- see `Queue`.
//
// The *waiting* is recorded here, which is the one thing that must
// not be left to the phone to remember: it drew the bubble from its
// own state, so leaving the session showed nothing pending while a
// message was still queued.
let id = super::random_hex();
queue
.awaiting
@@ -604,7 +430,6 @@ impl Driver for ClaudeDriver {
});
self.send_line(control_response.to_string());
}
// A multi-question AskUserQuestion still waiting on the rest.
AnswerOutcome::Pending => {}
AnswerOutcome::Unknown => {
let _ = self.sink.send(Event::Error {
@@ -618,8 +443,6 @@ impl Driver for ClaudeDriver {
/// deliberately, and dropping it would lose a message that never reached
/// the transcript.
fn interrupt(&self) {
// Recorded before the request goes out, so the result it produces reads
// as the stop somebody asked for rather than as a failure.
self.state.lock().unwrap().expect_interrupt();
self.send_control(json!({"subtype": "interrupt"}), None);
}
@@ -639,18 +462,10 @@ impl Driver for ClaudeDriver {
}
fn run_command(&self, text: &str) {
// Whatever the CLI's own vocabulary holds. It rides the same channel as
// `/compact` and starts a turn the same way, so the same bookkeeping
// applies; what it means is the CLI's business.
self.local_command(text.to_string());
}
fn set_title(&self, title: &str) {
// The CLI's own mechanism, and a local command rather than a control
// request -- `set_session_name` is not a subtype it knows, measured by
// asking. It answers this the way it answers `/compact`. A name with a
// newline would be two lines and the second would be a message, so it
// is refused rather than sent.
if title.contains('\n') {
let _ = self.sink.send(Event::Error {
message: "a session name cannot contain a line break".to_string(),
@@ -665,11 +480,6 @@ impl Driver for ClaudeDriver {
}
fn clear(&self) {
// Nothing is emitted here on purpose: the transcript should record a
// clear that happened, not one that was asked for. The CLI announces it
// with `conversation_reset`, which `translate.rs` turns into
// `Event::Cleared`, and follows it with a fresh `init` whose new
// `session_id` the reader persists as the resume token.
self.local_command("/clear".to_string());
}
@@ -679,9 +489,6 @@ impl Driver for ClaudeDriver {
}
fn detach(&self) {
// Stop reading and leave everything else exactly as it is. The process
// keeps its fifo, keeps writing its log, and keeps its record -- which
// is how the next run of this server finds it.
self.reading.store(false, Ordering::SeqCst);
}
@@ -694,14 +501,6 @@ impl Driver for ClaudeDriver {
}
}
/// Follows the process's stdout log, turning it into events, and is also what
/// decides whether the session is still running.
///
/// One loop rather than a reader plus a monitor. After a restart there is no
/// `Child` to wait on -- the process was reparented away -- so liveness has to
/// be a question asked of the record either way, and asking it in two places is
/// how the two answers come to disagree.
///
/// Reading is resumable because the position is written down with the process:
/// everything before it is already in the transcript, so a server coming back
/// picks up exactly where the last one stopped.
@@ -718,9 +517,6 @@ async fn follow(
) {
let stdout_path = session_dir.join(STDOUT_LOG);
let stderr_path = session_dir.join(STDERR_LOG);
// Whatever is already in the stderr log was logged by whichever run of this
// server was watching, so a reattach starts at the end of it. The tail is
// still read from the file if the process dies, which is when it matters.
let mut stderr_at = process::size_of(&stderr_path);
let mut said_unknown = false;
@@ -751,16 +547,6 @@ async fn follow(
return;
}
};
// Only whole lines, and the offset stops at the last newline -- so a
// line the process is halfway through writing is read again next pass.
// Deliberately *not* held in memory between passes: the offset would
// then have to point behind the bytes being held. It is also what makes
// the position crash-safe.
//
// Counted in bytes rather than on a decoded string: a read can cut a
// multi-byte character in half, and the replacement character is a
// different length from what it replaced -- which would slide the offset
// out of step with the file for the rest of the session.
let complete = complete_lines(&bytes);
for line in String::from_utf8_lossy(&bytes[..complete]).lines() {
@@ -779,9 +565,6 @@ async fn follow(
process::write(&session_dir, &record);
}
// Diagnostics only, and the tail of it is what an exit report carries --
// so it is read from the file rather than kept in memory, which means a
// reattached session can still explain a failure it did not witness.
if let Ok((bytes, at)) = process::read_from(&stderr_path, stderr_at)
&& at != stderr_at
{
@@ -795,11 +578,6 @@ async fn follow(
match record.liveness() {
process::Liveness::Alive => said_unknown = false,
// Drain whatever it wrote on the way out before saying so.
//
// Progress, not "there were bytes": a process that died mid-line
// leaves a partial one that is re-read every pass and never
// completes, so waiting on a non-empty read would wait for ever.
process::Liveness::Dead if complete > 0 => {}
process::Liveness::Dead => {
queue.lock().unwrap().close(&sink, "the session ended");
@@ -815,10 +593,6 @@ async fn follow(
process::clear(&session_dir);
return;
}
// The record is there and the machine will not say whether the
// process behind it is. Reported rather than guessed: calling it
// exited would invite starting a second one against the same
// conversation. Kept polling, so it resolves itself.
process::Liveness::Unknown => {
if !said_unknown {
said_unknown = true;
@@ -832,10 +606,6 @@ async fn follow(
}
}
/// How many leading bytes of `bytes` form complete lines. The offset only ever
/// advances by this, which is what lets a read land anywhere -- mid-line,
/// mid-character -- without the reader losing its place.
///
/// A line ends at `\n` and at nothing else, deliberately: this stream is JSONL,
/// so something terminated by a bare `\r` is not a record and treating one as a
/// line would hand `serde_json` a fragment. The accepted consequence is that
@@ -850,8 +620,6 @@ fn complete_lines(bytes: &[u8]) -> usize {
.unwrap_or(0)
}
/// One line of the CLI's output as events on the sink. `false` when the
/// session has been torn down and there is nothing left to send to.
fn translate_line(
line: &str,
session_dir: &Path,
@@ -878,20 +646,6 @@ fn translate_line(
if let Some(session_id) = new_session_id {
write_resume_token(session_dir, &session_id);
}
// A turn the CLI began by itself, said one line earlier than anything else
// could say it.
//
// The CLI picks the conversation back up with nothing written to it --
// measured: a backgrounded `sleep` finished nine seconds after the turn's
// result and it started again unprompted. It announces that with an `init`,
// and the first assistant text follows about a second and a half later;
// until this, that read as idle, which is long enough to send a command into
// and have it read as text.
//
// `before.is_some()` separates this from the `init` at startup. Our own
// `/clear` also produces one, and is excluded by `running` already being
// true.
// `local_command` set it before the line went out.
if opens_a_turn_by_itself(&message, before.is_some()) {
let started = {
let mut queue = queue.lock().unwrap();
@@ -911,16 +665,10 @@ fn translate_line(
return false;
}
}
// The steer is announced where the CLI opens the model call that read it,
// and *before* that call's output, so the message sits above what it
// produced and below what it did not. This line carries no events of its
// own, which is what makes it the right place.
if opens_a_model_call && !announce_steers(queue, sink) {
return false;
}
for event in events {
// A turn nobody here started -- see `proves_a_turn`. Said before the
// event that proves it, for the same reason a steer is.
let started = {
let mut queue = queue.lock().unwrap();
let started = proves_a_turn(&event) && !queue.running && !queue.closed;
@@ -960,30 +708,12 @@ fn translate_line(
true
}
/// Whether this line is the CLI announcing work it started on its own.
///
/// `system/init` is how it says a conversation is beginning, and it sends one
/// at startup, after a `/clear`, and when it picks the conversation back up by
/// itself. Only the third is a turn nobody here asked for: `already_started`
/// rules out the first, and the caller's `running` check rules out the second.
fn opens_a_turn_by_itself(message: &Value, already_started: bool) -> bool {
already_started
&& message.get("type").and_then(Value::as_str) == Some("system")
&& message.get("subtype").and_then(Value::as_str) == Some("init")
}
/// Whether this event could only have come from a turn in flight.
///
/// The turn this side starts is announced where it is started, and that covers
/// the common case and nothing else. Everything below happens without a phone
/// asking: a compaction the CLI decided on itself, a session adopted mid-turn,
/// a message that reached the conversation by another route. In all of them the
/// CLI is plainly working and the only thing that would have said so is a
/// `Running` nobody sent, so the session reads as idle until the turn ends.
///
/// Deliberately a wider set than what announces a steer: any sign of work
/// proves a turn is running, while only a `message_start` proves a line written
/// a moment ago has been read.
fn proves_a_turn(event: &Event) -> bool {
matches!(
event,
@@ -999,12 +729,6 @@ fn proves_a_turn(event: &Event) -> bool {
)
}
/// Records every message written since the last announcement, in the order it
/// was written. False means the session has been torn down.
///
/// Called from the two places that prove the CLI has consumed them: the start
/// of a new model call, and the end of the turn. The pair is the whole of the
/// rule -- a steer announced anywhere else lands above output that predates it.
fn announce_steers(queue: &Arc<Mutex<Queue>>, sink: &EventSink) -> bool {
let taken: Vec<(String, String, Vec<AttachmentRef>)> = {
let mut queue = queue.lock().unwrap();
@@ -1045,13 +769,6 @@ fn stderr_tail(path: &Path) -> String {
tail_of(&kept)
}
/// Creates the stdin fifo if it is not already there, and opens it read-write
/// for the process to inherit.
///
/// Read-write is the whole trick: a fifo opened read-only delivers EOF as soon
/// as the last writer closes, so the process would exit the moment this server
/// did -- exactly what leaving it running has to prevent. Holding it open for
/// writing means the process is its own last writer.
fn make_fifo(path: &Path) -> Result<std::fs::File> {
if !path.exists() {
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())
@@ -1072,7 +789,6 @@ fn make_fifo(path: &Path) -> Result<std::fs::File> {
.with_context(|| format!("opening the fifo {}", path.display()))
}
/// A fresh, empty, owner-only log for one of the process's output streams.
fn create_log(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
@@ -1099,8 +815,6 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
}
}
/// Where an uploaded attachment is, as a path the CLI can be told.
///
/// Absolute, because the CLI's working directory is the session's and the
/// attachments are not in it. Refused rather than resolved when the id is not
/// one this server would have written, so a crafted id cannot name a file
@@ -1124,7 +838,6 @@ fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
std::fs::canonicalize(&path).with_context(|| format!("find {}", path.display()))
}
/// Reads an uploaded image into an API image content block.
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
let path = attachment_path(session_dir, id)?;
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
@@ -1133,8 +846,6 @@ fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
"type": "image",
"source": {
"type": "base64",
// Ids carry the extension the upload was stored under, so an
// unrecognized one means a name this server didn't write.
"media_type": crate::media::media_type_for(id).unwrap_or("image/jpeg"),
"data": base64::engine::general_purpose::STANDARD.encode(bytes),
}
@@ -1155,7 +866,6 @@ mod tests {
attachment_path(dir.path(), "ab12-x.bin").unwrap(),
attachments.join("ab12-x.bin").canonicalize().unwrap()
);
// Shipped to the session's machine: the path there, not here.
std::fs::write(
attachments.join("ab12-x.bin.remote"),
"/home/t/in/ab12-x.bin\n",
@@ -1169,9 +879,6 @@ mod tests {
assert!(attachment_path(dir.path(), "missing.bin").is_err());
}
/// Drives real CLI output lines through the reader and collects what came
/// out, which is the only way to check the wiring between "the CLI said
/// this" and "the transcript records that".
fn events_from_lines(lines: &[&str]) -> Vec<Event> {
let dir = tempfile::tempdir().expect("temp dir");
let state = Arc::new(Mutex::new(Translator::new(
@@ -1191,10 +898,6 @@ mod tests {
events
}
/// Feeds lines through the reader, running `interject` between two of them,
/// and returns what came out. The hook is what makes a steer testable at
/// all: what matters is not which events a line produces but *where* a
/// message written part-way through the stream ends up among them.
fn events_with_interjection(
lines: &[&str],
after: usize,
@@ -1222,10 +925,6 @@ mod tests {
events
}
/// One assistant message, streamed: two text deltas, then the `tool_use` it
/// ends with, then that call's result. Written out rather than shortened
/// because the point of both tests below is the *order*, and the shape of a
/// real turn is what makes the order mean anything. Recorded from 2.1.237.
const STREAMED_CALL: &[&str] = &[
r#"{"type":"stream_event","event":{"type":"message_start"},"session_id":"s","parent_tool_use_id":null}"#,
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me "}},"session_id":"s","parent_tool_use_id":null}"#,
@@ -1234,13 +933,6 @@ mod tests {
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01","type":"tool_result","content":"one","is_error":false}]},"parent_tool_use_id":null}"#,
];
/// A steer typed while an answer is streaming is recorded below that
/// answer's tool call and its result, not among them.
///
/// The message reaches the CLI immediately; what waits is saying so.
/// Everything emitted after it was typed still belongs to a model call that
/// had not read it. `message_start` is the first line that proves the next
/// call has it, so that is where the announcement goes.
#[test]
fn a_steer_is_recorded_below_the_call_that_had_not_read_it() {
let mut lines = STREAMED_CALL.to_vec();
@@ -1250,7 +942,6 @@ mod tests {
lines.push(
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Doing that instead."}},"session_id":"s","parent_tool_use_id":null}"#,
);
// Typed after the first delta, with the answer still arriving.
let events = events_with_interjection(&lines, 1, |queue| {
queue.lock().unwrap().awaiting.push_back((
"q1".into(),
@@ -1266,9 +957,6 @@ mod tests {
.unwrap_or_else(|| panic!("nothing matched in {events:?}"))
};
let taken = at(|e| matches!(e, Event::MessageTaken { .. }));
// Named, not just announced: the phone has a waiting bubble for this
// message and clears the one with this id. Matching on the text would
// clear the wrong bubble whenever the same thing was sent twice.
assert!(
matches!(
&events[taken],
@@ -1294,17 +982,12 @@ mod tests {
);
}
/// A steer written after the turn's last model call is still recorded.
/// Nothing further is coming, so no `message_start` will ever prove it was
/// read -- and a message only recorded when announced would vanish, leaving
/// a phone drawing it as waiting forever.
#[test]
fn a_steer_with_no_model_call_left_is_recorded_at_the_end_of_the_turn() {
let mut lines = STREAMED_CALL.to_vec();
lines.push(
r#"{"type":"result","subtype":"success","usage":{"input_tokens":1,"output_tokens":1}}"#,
);
// Typed after the tool result, with only the turn's end to come.
let events = events_with_interjection(&lines, 4, |queue| {
queue
.lock()
@@ -1334,12 +1017,6 @@ mod tests {
);
}
/// The divider comes from the CLI announcing the reset, not from an `init`
/// arriving. Measured against 2.1.237: `/clear` emits `conversation_reset`,
/// then a fresh `init` carrying a new session id. Watching the id be
/// replaced would work, but it reads the event through a side effect; the
/// announcement says so directly and arrives first, so the divider lands
/// above the new conversation.
#[test]
fn a_conversation_reset_is_what_records_a_clear() {
let events = events_from_lines(&[
@@ -1354,11 +1031,6 @@ mod tests {
);
}
/// An `init` on its own never records a clear, whatever id it carries.
/// Three ways one arrives and none is a cleared conversation: the first
/// init of a session, the one a compaction re-announces carrying the *same*
/// id, and the one that follows a resume. Reading any as a clear would tell
/// the reader a conversation had been dropped when it had been summarised.
#[test]
fn an_init_alone_is_never_a_clear() {
for ids in [["first", "first"], ["first", "second"]] {
@@ -1376,9 +1048,6 @@ mod tests {
}
}
/// The failure this exists for: a shell's complaint ends with a blank line,
/// so reporting "the last line of stderr" reported nothing, and the phone
/// showed a bare exit status while the reason sat in the server's log.
#[test]
fn the_report_keeps_the_message_and_not_the_blank_line_after_it() {
let fish_cd_failure = [
@@ -1401,12 +1070,9 @@ mod tests {
report.ends_with("'~/repos/ai-app'"),
"the trailing blank is trimmed: {report:?}",
);
// The blank *between* lines is part of the message and stays.
assert!(report.contains("does not exist\n\nembedded:"), "{report:?}");
}
/// Nothing to say is said as nothing, so the caller can tell the two
/// apart and print just the exit status.
#[test]
fn stderr_that_is_only_blank_lines_reports_as_empty() {
let kept: VecDeque<String> = ["", " ", ""].iter().map(|l| l.to_string()).collect();
@@ -1416,10 +1082,6 @@ mod tests {
#[test]
fn a_stream_read_in_arbitrary_chunks_yields_each_line_once() {
// The property the reading position has to have: however a write is
// split -- mid-line, or mid-character -- every line comes out exactly
// once and in order. Chunked at every prime-ish size so the cuts land in
// different places, including inside the multi-byte character.
let stream = "{\"a\":1}\n{\"b\":\"caf\u{e9}\"}\n{\"c\":3}\n";
for chunk in [1usize, 2, 3, 5, 7, 11, 1000] {
let mut offset = 0usize;
@@ -1428,8 +1090,6 @@ mod tests {
let mut available = 0usize;
while available < bytes.len() {
available = (available + chunk).min(bytes.len());
// What a read from the recorded offset returns: the file as far
// as it has been written, from where we left off.
let unread = &bytes[offset..available];
let complete = complete_lines(unread);
for line in String::from_utf8_lossy(&unread[..complete]).lines() {
@@ -1448,12 +1108,8 @@ mod tests {
#[test]
fn an_incomplete_line_advances_nothing() {
// Nothing to do yet, and crucially the position does not move -- so a
// crash here re-reads the line rather than skipping it.
assert_eq!(complete_lines(b"{\"partial\": tru"), 0);
assert_eq!(complete_lines(b""), 0);
// And a complete line followed by a partial one advances only past
// the complete one.
assert_eq!(complete_lines(b"done\nhalf"), 5);
}
@@ -1472,8 +1128,6 @@ mod tests {
.push_back(("q2".into(), "second".into(), Vec::new()));
queue.close(&sink, "the session ended");
// Named rather than counted, because these never reached the transcript:
// this message is the only record they existed.
let Some(Event::Error { message }) = received.try_recv().ok() else {
panic!("closing a queue holding messages must report them");
};
@@ -1484,18 +1138,12 @@ mod tests {
"{message}"
);
// And the flag is cleared, so a later message is refused with a reason
// rather than queued behind a turn that will never end.
assert!(!queue.running);
assert!(queue.closed);
}
#[test]
fn a_turn_this_side_did_not_start_still_reports_as_running() {
// The case: a session picked up while it was already working, or one
// another agent wrote to. Nothing called `send_user_message`, so the
// only thing that can say the session is busy is what it is observed
// doing.
let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(
@@ -1518,16 +1166,12 @@ mod tests {
Some(Event::AssistantText { .. })
));
// Once only: the turn is known to be running now, and a status per delta
// would be a status per word.
assert!(translate_line(text, dir.path(), &state, &sink, &queue));
assert!(matches!(
received.try_recv().ok(),
Some(Event::AssistantText { .. })
));
// And the end of the turn puts it back, so the next one is
// reported the same way.
let done = r#"{"type":"result","subtype":"success","is_error":false,"usage":{}}"#;
assert!(translate_line(done, dir.path(), &state, &sink, &queue));
assert_eq!(
@@ -1541,9 +1185,6 @@ mod tests {
#[test]
fn output_from_a_process_that_has_gone_does_not_revive_the_turn() {
// `close` is what says the process is gone and reports the messages that
// died with it. Anything still in the pipe after that must not put the
// session back to work.
let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(
@@ -1567,8 +1208,6 @@ mod tests {
let (sink, mut received) = mpsc::unbounded_channel();
let mut queue = Queue::default();
queue.close(&sink, "the session ended");
// A session that exits with nothing held has lost nothing, and an error
// saying so would be noise on every ordinary exit.
assert!(received.try_recv().is_err());
assert!(queue.closed);
}