use std::collections::VecDeque; use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; use serde_json::{Value, json}; use tokio::io::AsyncWriteExt; use tokio::sync::mpsc; use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued}; use super::process; use super::subagent::Subagents; use super::transport::{Launch, Streams, Transport}; use crate::config::{ProviderConfig, SessionConfig}; use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call}; /// How much of a failing process's stderr the exit report carries. Enough /// for a shell's complaint plus the context it prints around it, and bounded /// because this is held per session for the life of the process. const STDERR_LINES_KEPT: usize = 50; fn tail_of(kept: &VecDeque) -> String { let lines: Vec<&str> = kept.iter().map(String::as_str).collect(); let start = lines .iter() .position(|line| !line.trim().is_empty()) .unwrap_or(lines.len()); let end = lines .iter() .rposition(|line| !line.trim().is_empty()) .map(|last| last + 1) .unwrap_or(start); lines[start..end].join("\n") } pub(super) mod translate; const RESUME_FILE: &str = "claude-session.json"; #[derive(Default)] struct Queue { running: bool, awaiting: VecDeque<(String, String, Vec)>, /// 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 /// recorded when *announced*, each later one vanished silently. closed: bool, } impl Queue { /// Gives up on everything held, because the process is gone. /// /// Reported rather than dropped: these are messages somebody typed that /// never reached the session and never reached the transcript, so this is /// the only place they can be mentioned at all. /// /// Each is also *resolved*, with the same `MessageDropped` that tapping the /// bubble produces -- otherwise the bubble sat there for good, waiting on a /// `UserMessage` that is exactly what is not coming. fn close(&mut self, sink: &EventSink, why: &str) { self.closed = true; self.running = false; let lost: Vec<(String, String)> = self .awaiting .drain(..) .map(|(id, text, _)| (id, text)) .collect(); if lost.is_empty() { return; } let _ = sink.send(Event::Error { message: format!( "{why} before it read {}: {}", if lost.len() == 1 { "this message".to_string() } else { format!("{} queued messages", lost.len()) }, lost.iter() .map(|(_, text)| text.as_str()) .collect::>() .join(" / ") ), }); for (id, _) in lost { let _ = sink.send(Event::MessageDropped { id }); } } } /// The session directory's copies of the process's standard streams. Named /// once rather than built at each use, because the spawn path and the attach /// path must agree about which file is which; if they drift, a reattached /// session reads a file nothing is writing and looks idle forever. const STDIN_FIFO: &str = "stdin.fifo"; const STDOUT_LOG: &str = "stdout.log"; const STDERR_LOG: &str = "stderr.log"; const POLL: std::time::Duration = std::time::Duration::from_millis(50); pub struct ClaudeDriver { sink: EventSink, queue: Arc>, to_child: mpsc::UnboundedSender, state: Arc>, session_dir: PathBuf, reading: Arc, } impl ClaudeDriver { pub fn launch( meta: &SessionConfig, provider: &ProviderConfig, transport: &Transport, session_dir: &Path, sink: EventSink, subagents: Arc, ) -> Result { let state = Arc::new(Mutex::new(Translator::new( session_dir.to_path_buf(), subagents, ))); let queue = Arc::new(Mutex::new(Queue::default())); let reading = Arc::new(AtomicBool::new(true)); let started_here; let record = match process::recorded(session_dir) { // Still running, and ours. Pick it up where it was left -- the one // path that must not pass `--resume`. Some((record, process::Liveness::Alive)) => { tracing::info!( "session {} reattaching to the {} it left running (pid {})", meta.id, provider.name, record.pid ); started_here = false; record } Some((record, process::Liveness::Unknown)) => { tracing::warn!( "session {} recorded pid {} but this machine won't say whether it is running; \ not starting a second one", meta.id, record.pid ); started_here = false; record } Some((_, process::Liveness::Dead)) | None => { started_here = true; Self::start(meta, provider, transport, session_dir)? } }; // A process this driver has just started has been asked for nothing, // which is what idle means. Said here because nothing else will: the // CLI writes not one line until it is given work, so a session whose // transcript last recorded `Exited` would keep that word -- and // `Exited` refuses every command and invites starting a second CLI // against a conversation that already has one. // // From the driver rather than the manager, and before `follow` is // spawned, so it cannot overtake the exit `follow` reports for a // process that dies immediately. Adopting says nothing, because a // process already running may be mid-turn and the transcript's last // word is the better answer until its output says otherwise. if started_here { let _ = sink.send(Event::Status { state: SessionStatus::Idle, }); } let resuming_from = match record.detail { process::Detail::Stdio { stdout_read } => stdout_read, _ => 0, }; let stdin = std::fs::OpenOptions::new() .write(true) .open(session_dir.join(STDIN_FIFO)) .with_context(|| format!("opening {STDIN_FIFO} for session {}", meta.id))?; let (to_child, mut from_driver) = mpsc::unbounded_channel::(); tokio::spawn(async move { let mut stdin = tokio::fs::File::from_std(stdin); while let Some(line) = from_driver.recv().await { if stdin.write_all(line.as_bytes()).await.is_err() || stdin.write_all(b"\n").await.is_err() || stdin.flush().await.is_err() { break; } } }); tokio::spawn(follow( session_dir.to_path_buf(), record, resuming_from, Arc::clone(&state), sink.clone(), Arc::clone(&queue), Arc::clone(&reading), format!("{} {}", provider.name, transport.describe()), )); Ok(Self { sink, queue, to_child, state, session_dir: session_dir.to_path_buf(), reading, }) } fn start( meta: &SessionConfig, provider: &ProviderConfig, transport: &Transport, session_dir: &Path, ) -> Result { let mut args: Vec = ["-p", "--verbose"].iter().map(|a| a.to_string()).collect(); let mut push = |flag: &str, value: &str| { args.push(flag.to_string()); args.push(value.to_string()); }; push("--input-format", "stream-json"); push("--output-format", "stream-json"); push("--permission-prompt-tool", "stdio"); if let Some(model) = &meta.model { push("--model", model); } if let Some(mode) = &meta.permission_mode { push("--permission-mode", mode); } if let Some(effort) = &meta.effort { push("--effort", effort); } match read_resume_token(session_dir) { Some(resume) => push("--resume", &resume), None => push("--name", &meta.title), } args.push("--include-partial-messages".to_string()); args.push("--allow-dangerously-skip-permissions".to_string()); // Fresh logs, because the offsets that index them start at zero and // everything the previous process said is already in the transcript. let stdin = make_fifo(&session_dir.join(STDIN_FIFO))?; let stdout = create_log(&session_dir.join(STDOUT_LOG))?; let stderr = create_log(&session_dir.join(STDERR_LOG))?; let program = provider.program(); let launch = Launch::new(program, args, meta.cwd.as_deref()); let child = transport.spawn( &launch, Streams::Detached { stdin: stdin.into(), stdout: stdout.into(), stderr: stderr.into(), }, )?; let pid = child .id() .context("the process exited before it could be recorded")?; tracing::info!( "session {} running {program} {} as pid {pid}", meta.id, transport.describe() ); // Reaped rather than waited on. This server is the parent, so something // has to collect the exit status or the process becomes a zombie -- but // `follow` decides what the session is doing, because after a restart // there is no `Child` to wait on. tokio::spawn(async move { let mut child = child; let _ = child.wait().await; }); let record = process::Record::of(pid, process::Detail::Stdio { stdout_read: 0 }) .context("the process was gone before its start time could be read")?; process::write(session_dir, &record); Ok(record) } fn send_line(&self, line: String) { let _ = self.to_child.send(line); } /// 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. fn local_command(&self, text: String) { let mut queue = self.queue.lock().unwrap(); if queue.closed { drop(queue); let _ = self.sink.send(Event::Error { message: format!("this session's process has exited, so it can't run {text}"), }); return; } queue.running = true; drop(queue); // The session is working from this moment, and until now nothing said // so: a command's reply carries no assistant text, so `proves_a_turn` // never saw it and the recorded status stayed idle for the whole round // trip -- which meant the *next* idle was not a change. let _ = self.sink.send(Event::Status { state: SessionStatus::Running, }); self.send_line( json!({"type": "user", "message": {"role": "user", "content": [ {"type": "text", "text": text} ]}}) .to_string(), ); } fn send_control(&self, request: Value, confirms: Option) { let id = format!("req-{}", super::random_hex()); if let Some(setting) = confirms { self.state .lock() .unwrap() .expect_setting(id.clone(), setting); } self.send_line( json!({"type": "control_request", "request_id": id, "request": request}).to_string(), ); } } impl Driver for ClaudeDriver { fn send_user_message(&self, text: String, attachments: Vec) { let mut content = Vec::new(); // An image goes into the message itself; the model looks at it. Any // other file stays where the upload put it and the message says where, // because the CLI can read a file by path and a model cannot be handed // a trace any other way. Named after the text, so the words come first. let mut files = Vec::new(); for id in &attachments { let sent = if crate::media::media_type_for(id).is_some() { attachment_block(&self.session_dir, id).map(|block| content.push(block)) } else { attachment_path(&self.session_dir, id).map(|path| files.push(path)) }; if let Err(err) = sent { let _ = self.sink.send(Event::Error { message: format!("attachment {id} couldn't be sent: {err:#}"), }); } } let mut body = text.clone(); for path in files { if !body.is_empty() { body.push_str("\n\n"); } body.push_str(&format!("Attached file: {}", path.display())); } if !body.is_empty() { content.push(json!({"type": "text", "text": body})); } let line = json!({"type": "user", "message": {"role": "user", "content": content}}).to_string(); let mut queue = self.queue.lock().unwrap(); if queue.closed { drop(queue); let _ = self.sink.send(Event::Error { message: "this session's process has exited, so it can't be sent anything" .to_string(), }); return; } if queue.running { let id = super::random_hex(); queue .awaiting .push_back((id.clone(), text.clone(), attachments.clone())); drop(queue); let _ = self.sink.send(Event::MessageQueued { id, text, attachments, }); self.send_line(line); return; } queue.running = true; drop(queue); // Nothing is in flight, so there is nothing to wait for: this message // *is* the turn about to start, and it never had a `MessageQueued`. let _ = self.sink.send(Event::MessageTaken { id: None, text, attachments, }); let _ = self.sink.send(Event::Status { state: SessionStatus::Running, }); self.send_line(line); } /// Never droppable, and that is a property of the design rather than an /// omission. A message queued here has already been written to the CLI's /// stdin -- see [`Queue`], where only the *announcement* waits -- because /// that is what makes a steer reach the model at the next tool boundary. /// A line in the fifo cannot be recalled. fn unqueue(&self, id: &str) -> Unqueued { let queue = self.queue.lock().unwrap(); if queue.awaiting.iter().any(|(waiting, ..)| waiting == id) { Unqueued::AlreadySent } else { Unqueued::Unknown } } fn answer_question(&self, id: &str, answers: &[String]) { let response = { let mut state = self.state.lock().unwrap(); state.answer(id, answers) }; match response { AnswerOutcome::Respond(control_response) => { let _ = self.sink.send(Event::Status { state: SessionStatus::Running, }); self.send_line(control_response.to_string()); } AnswerOutcome::Pending => {} AnswerOutcome::Unknown => { let _ = self.sink.send(Event::Error { message: format!("no question {id} is awaiting an answer"), }); } } } /// Anything queued behind the interrupted turn still goes: it was typed /// deliberately, and dropping it would lose a message that never reached /// the transcript. fn interrupt(&self) { self.state.lock().unwrap().expect_interrupt(); self.send_control(json!({"subtype": "interrupt"}), None); } fn set_permission_mode(&self, mode: &str) { self.send_control( json!({"subtype": "set_permission_mode", "mode": mode}), Some(Setting::PermissionMode(mode.to_string())), ); } fn set_model(&self, model: &str) { self.send_control( json!({"subtype": "set_model", "model": model}), Some(Setting::Model(model.to_string())), ); } fn run_command(&self, text: &str) { self.local_command(text.to_string()); } fn set_title(&self, title: &str) { if title.contains('\n') { let _ = self.sink.send(Event::Error { message: "a session name cannot contain a line break".to_string(), }); return; } self.local_command(format!("/rename {title}")); } fn compact(&self) { self.local_command("/compact".to_string()); } fn clear(&self) { self.local_command("/clear".to_string()); } fn between_turns(&self) -> bool { let queue = self.queue.lock().unwrap(); !queue.running && !queue.closed } fn detach(&self) { self.reading.store(false, Ordering::SeqCst); } fn stop(&self) { self.reading.store(false, Ordering::SeqCst); if let Some(record) = process::live(&self.session_dir) { process::stop(&record, process::STOP_GRACE); } process::clear(&self.session_dir); } } /// 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. #[allow(clippy::too_many_arguments)] async fn follow( session_dir: PathBuf, mut record: process::Record, mut offset: u64, state: Arc>, sink: EventSink, queue: Arc>, reading: Arc, label: String, ) { let stdout_path = session_dir.join(STDOUT_LOG); let stderr_path = session_dir.join(STDERR_LOG); let mut stderr_at = process::size_of(&stderr_path); let mut said_unknown = false; while reading.load(Ordering::SeqCst) { let (bytes, _) = match process::read_from(&stdout_path, offset) { Ok(found) => found, Err(err) => { // Reported, not only logged. This is the end of the session's // output as far as anyone watching is concerned, and a phone // told nothing shows a session that is merely quiet. The status // is `Unknown` rather than `Exited` because the process may well // still be running; what has failed is hearing it. tracing::error!("couldn't read {}: {err:#}", stdout_path.display()); let _ = sink.send(Event::Error { message: format!( "lost track of {label}: its output can't be read ({err:#}). The process \ may still be running; restarting the backend will try to pick it up \ again." ), }); queue .lock() .unwrap() .close(&sink, "this server lost track of the session"); let _ = sink.send(Event::Status { state: SessionStatus::Unknown, }); return; } }; let complete = complete_lines(&bytes); for line in String::from_utf8_lossy(&bytes[..complete]).lines() { if line.trim().is_empty() { continue; } if !translate_line(line, &session_dir, &state, &sink, &queue) { return; // session torn down } } if complete > 0 { offset += complete as u64; record.detail = process::Detail::Stdio { stdout_read: offset, }; process::write(&session_dir, &record); } if let Ok((bytes, at)) = process::read_from(&stderr_path, stderr_at) && at != stderr_at { stderr_at = at; for line in String::from_utf8_lossy(&bytes).lines() { if !line.trim().is_empty() { tracing::warn!("{label} stderr: {line}"); } } } match record.liveness() { process::Liveness::Alive => said_unknown = false, process::Liveness::Dead if complete > 0 => {} process::Liveness::Dead => { queue.lock().unwrap().close(&sink, "the session ended"); let detail = stderr_tail(&stderr_path); if !detail.is_empty() { let _ = sink.send(Event::Error { message: format!("{label} exited:\n{detail}"), }); } let _ = sink.send(Event::Status { state: SessionStatus::Exited, }); process::clear(&session_dir); return; } process::Liveness::Unknown => { if !said_unknown { said_unknown = true; let _ = sink.send(Event::Status { state: SessionStatus::Unknown, }); } } } tokio::time::sleep(POLL).await; } } /// 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 /// such a line is held here forever, and it is worth knowing what that looks /// like, because it looks like nothing: the session goes quiet with the process /// healthy and no error anywhere. The CLI has never written one here. fn complete_lines(bytes: &[u8]) -> usize { bytes .iter() .rposition(|byte| *byte == b'\n') .map(|at| at + 1) .unwrap_or(0) } fn translate_line( line: &str, session_dir: &Path, state: &Arc>, sink: &EventSink, queue: &Arc>, ) -> bool { let Ok(message) = serde_json::from_str::(line) else { // By characters, not bytes: the CLI emits plenty of non-ASCII, and a // byte slice that lands mid-character panics -- inside `follow`, so the // session would go permanently deaf with nothing on screen to say so. let shown: String = line.chars().take(200).collect(); tracing::warn!("unparseable claude output line: {shown}"); return true; }; let opens_a_model_call = starts_a_model_call(&message); let (events, new_session_id, before) = { let mut state = state.lock().unwrap(); let before = state.session_id.clone(); let events = state.translate(&message); let after = state.session_id.clone(); (events, if before != after { after } else { None }, before) }; if let Some(session_id) = new_session_id { write_resume_token(session_dir, &session_id); } if opens_a_turn_by_itself(&message, before.is_some()) { let started = { let mut queue = queue.lock().unwrap(); let started = !queue.running && !queue.closed; if started { queue.running = true; } started }; if started && sink .send(Event::Status { state: SessionStatus::Running, }) .is_err() { return false; } } if opens_a_model_call && !announce_steers(queue, sink) { return false; } for event in events { let started = { let mut queue = queue.lock().unwrap(); let started = proves_a_turn(&event) && !queue.running && !queue.closed; if started { queue.running = true; } started }; if started && sink .send(Event::Status { state: SessionStatus::Running, }) .is_err() { return false; } if matches!( event, Event::Status { state: SessionStatus::Idle } ) { // The case that must not be missed: a message written after the // final model call of a turn has no later `message_start` to prove // anything, so without this it would never be announced at all. The // end of the turn is where it belongs anyway. if !announce_steers(queue, sink) { return false; } queue.lock().unwrap().running = false; } if sink.send(event).is_err() { return false; // session torn down } } true } 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") } fn proves_a_turn(event: &Event) -> bool { matches!( event, Event::AssistantText { .. } | Event::ToolStart { .. } | Event::ToolUpdate { .. } | Event::ToolEnd { .. } | Event::Question { .. } | Event::Compacted { .. } | Event::Status { state: SessionStatus::Compacting | SessionStatus::AwaitingInput } ) } fn announce_steers(queue: &Arc>, sink: &EventSink) -> bool { let taken: Vec<(String, String, Vec)> = { let mut queue = queue.lock().unwrap(); queue.awaiting.drain(..).collect() }; for (id, text, attachments) in taken { if sink .send(Event::MessageTaken { id: Some(id), text, attachments, }) .is_err() { return false; } } true } /// The end of the stderr log, for an exit report a person reads. Bounded /// because this is held in a message; trimmed of blank lines at both ends /// because a shell's error ends with one, so anything reporting "the last /// line" reports nothing at all. A failing `cd` cost an evening to that. fn stderr_tail(path: &Path) -> String { let Ok(text) = std::fs::read_to_string(path) else { return String::new(); }; let kept: VecDeque = text .lines() .rev() .take(STDERR_LINES_KEPT) .map(str::to_string) .collect::>() .into_iter() .rev() .collect(); tail_of(&kept) } fn make_fifo(path: &Path) -> Result { if !path.exists() { let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) .with_context(|| format!("{} is not a usable path", path.display()))?; // SAFETY: a nul-terminated path this call only reads, and a mode with // no bits the kernel can object to. Owner-only, like everything else in // a session directory: this carries what the person typed. let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }; if made != 0 { return Err(std::io::Error::last_os_error()) .with_context(|| format!("creating the fifo {}", path.display())); } } std::fs::OpenOptions::new() .read(true) .write(true) .open(path) .with_context(|| format!("opening the fifo {}", path.display())) } fn create_log(path: &Path) -> Result { std::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .mode(0o600) .open(path) .with_context(|| format!("creating {}", path.display())) } pub(super) fn read_resume_token(session_dir: &Path) -> Option { let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?; serde_json::from_str::(&text) .ok()? .get("sessionId")? .as_str() .map(String::from) } pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) { let path = session_dir.join(RESUME_FILE); if let Err(err) = std::fs::write(&path, json!({"sessionId": session_id}).to_string()) { tracing::error!("couldn't persist resume token to {}: {err}", path.display()); } } /// 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 /// outside the session. fn attachment_path(session_dir: &Path, id: &str) -> Result { if !id .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') || id.contains("..") { anyhow::bail!("invalid attachment id"); } let path = session_dir.join("attachments").join(id); // A file copied to the session's own machine is named where it landed there // -- `routes::upload_attachment` writes that down beside it -- because the // path has to be one the CLI can open, not one this server can. let shipped = path.with_file_name(format!("{id}.remote")); if let Ok(remote) = std::fs::read_to_string(&shipped) { return Ok(PathBuf::from(remote.trim())); } std::fs::canonicalize(&path).with_context(|| format!("find {}", path.display())) } fn attachment_block(session_dir: &Path, id: &str) -> Result { let path = attachment_path(session_dir, id)?; let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; use base64::Engine; Ok(json!({ "type": "image", "source": { "type": "base64", "media_type": crate::media::media_type_for(id).unwrap_or("image/jpeg"), "data": base64::engine::general_purpose::STANDARD.encode(bytes), } })) } #[cfg(test)] mod tests { use super::*; #[test] fn an_attachment_is_named_where_the_cli_can_open_it() { let dir = tempfile::tempdir().expect("temp dir"); let attachments = dir.path().join("attachments"); std::fs::create_dir(&attachments).unwrap(); std::fs::write(attachments.join("ab12-x.bin"), b"x").unwrap(); assert_eq!( attachment_path(dir.path(), "ab12-x.bin").unwrap(), attachments.join("ab12-x.bin").canonicalize().unwrap() ); std::fs::write( attachments.join("ab12-x.bin.remote"), "/home/t/in/ab12-x.bin\n", ) .unwrap(); assert_eq!( attachment_path(dir.path(), "ab12-x.bin").unwrap(), PathBuf::from("/home/t/in/ab12-x.bin") ); assert!(attachment_path(dir.path(), "../config.ron").is_err()); assert!(attachment_path(dir.path(), "missing.bin").is_err()); } fn events_from_lines(lines: &[&str]) -> Vec { let dir = tempfile::tempdir().expect("temp dir"); let state = Arc::new(Mutex::new(Translator::new( dir.path().to_path_buf(), Arc::new(Subagents::new(dir.path().to_path_buf())), ))); let queue = Arc::new(Mutex::new(Queue::default())); let (sink, mut out) = mpsc::unbounded_channel::(); for line in lines { assert!(translate_line(line, dir.path(), &state, &sink, &queue)); } drop(sink); let mut events = Vec::new(); while let Ok(event) = out.try_recv() { events.push(event); } events } fn events_with_interjection( lines: &[&str], after: usize, interject: impl FnOnce(&Arc>), ) -> Vec { let dir = tempfile::tempdir().expect("temp dir"); let state = Arc::new(Mutex::new(Translator::new( dir.path().to_path_buf(), Arc::new(Subagents::new(dir.path().to_path_buf())), ))); let queue = Arc::new(Mutex::new(Queue::default())); let (sink, mut out) = mpsc::unbounded_channel::(); let mut interject = Some(interject); for (i, line) in lines.iter().enumerate() { assert!(translate_line(line, dir.path(), &state, &sink, &queue)); if i == after { interject.take().expect("one interjection")(&queue); } } drop(sink); let mut events = Vec::new(); while let Ok(event) = out.try_recv() { events.push(event); } events } 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}"#, r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"check that."}},"session_id":"s","parent_tool_use_id":null}"#, r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"echo one"}}]},"parent_tool_use_id":null}"#, 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}"#, ]; #[test] fn a_steer_is_recorded_below_the_call_that_had_not_read_it() { let mut lines = STREAMED_CALL.to_vec(); lines.push( r#"{"type":"stream_event","event":{"type":"message_start"},"session_id":"s","parent_tool_use_id":null}"#, ); 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}"#, ); let events = events_with_interjection(&lines, 1, |queue| { queue.lock().unwrap().awaiting.push_back(( "q1".into(), "do the other one instead".into(), Vec::new(), )) }); let at = |find: fn(&Event) -> bool| { events .iter() .position(find) .unwrap_or_else(|| panic!("nothing matched in {events:?}")) }; let taken = at(|e| matches!(e, Event::MessageTaken { .. })); assert!( matches!( &events[taken], Event::MessageTaken { id: Some(id), .. } if id == "q1" ), "an announcement must name the queue entry it resolves: {events:?}" ); assert!( taken > at(|e| matches!(e, Event::ToolStart { .. })), "a steer must not sit above a call the model had already made: {events:?}" ); assert!( taken > at(|e| matches!(e, Event::ToolEnd { .. })), "a steer must not sit above the result of that call: {events:?}" ); assert_eq!( events .iter() .filter(|e| matches!(e, Event::AssistantText { .. })) .count(), 3, "the streamed answer must stay whole: {events:?}" ); } #[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}}"#, ); let events = events_with_interjection(&lines, 4, |queue| { queue .lock() .unwrap() .awaiting .push_back(("q2".into(), "never mind".into(), Vec::new())) }); let taken = events .iter() .position(|e| matches!(e, Event::MessageTaken { .. })) .unwrap_or_else(|| panic!("a steer must never be dropped: {events:?}")); let idle = events .iter() .position(|e| { matches!( e, Event::Status { state: SessionStatus::Idle } ) }) .unwrap_or_else(|| panic!("expected the turn to end: {events:?}")); assert!( taken < idle, "the steer belongs inside the turn it was typed into: {events:?}" ); } #[test] fn a_conversation_reset_is_what_records_a_clear() { let events = events_from_lines(&[ r#"{"type":"system","subtype":"init","session_id":"first","tools":[],"model":"claude-haiku-4-5-20251001"}"#, r#"{"type":"conversation_reset","session_id":"first"}"#, r#"{"type":"system","subtype":"init","session_id":"second","tools":[],"model":"claude-haiku-4-5-20251001"}"#, ]); assert_eq!( events.iter().filter(|e| **e == Event::Cleared).count(), 1, "got {events:?}" ); } #[test] fn an_init_alone_is_never_a_clear() { for ids in [["first", "first"], ["first", "second"]] { let events = events_from_lines(&[ &format!( r#"{{"type":"system","subtype":"init","session_id":"{}","tools":[],"model":"claude-haiku-4-5-20251001"}}"#, ids[0] ), &format!( r#"{{"type":"system","subtype":"init","session_id":"{}","tools":[],"model":"claude-haiku-4-5-20251001"}}"#, ids[1] ), ]); assert!(!events.contains(&Event::Cleared), "{ids:?} gave {events:?}"); } } #[test] fn the_report_keeps_the_message_and_not_the_blank_line_after_it() { let fish_cd_failure = [ "cd: The directory '~/repos/ai-app' does not exist", "", "embedded:functions/cd.fish (line 26): ", " builtin cd $argv", " ^", "in function 'cd' with arguments '~/repos/ai-app'", "", ]; let kept: VecDeque = fish_cd_failure.iter().map(|l| l.to_string()).collect(); let report = tail_of(&kept); assert!( report.starts_with("cd: The directory"), "the complaint leads: {report}", ); assert!( report.ends_with("'~/repos/ai-app'"), "the trailing blank is trimmed: {report:?}", ); assert!(report.contains("does not exist\n\nembedded:"), "{report:?}"); } #[test] fn stderr_that_is_only_blank_lines_reports_as_empty() { let kept: VecDeque = ["", " ", ""].iter().map(|l| l.to_string()).collect(); assert_eq!(tail_of(&kept), ""); assert_eq!(tail_of(&VecDeque::new()), ""); } #[test] fn a_stream_read_in_arbitrary_chunks_yields_each_line_once() { 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; let mut lines: Vec = Vec::new(); let bytes = stream.as_bytes(); let mut available = 0usize; while available < bytes.len() { available = (available + chunk).min(bytes.len()); let unread = &bytes[offset..available]; let complete = complete_lines(unread); for line in String::from_utf8_lossy(&unread[..complete]).lines() { lines.push(line.to_string()); } offset += complete; } assert_eq!(offset, bytes.len(), "chunk {chunk} left bytes unread"); assert_eq!( lines, vec!["{\"a\":1}", "{\"b\":\"caf\u{e9}\"}", "{\"c\":3}"], "chunk {chunk}" ); } } #[test] fn an_incomplete_line_advances_nothing() { assert_eq!(complete_lines(b"{\"partial\": tru"), 0); assert_eq!(complete_lines(b""), 0); assert_eq!(complete_lines(b"done\nhalf"), 5); } #[test] fn closing_the_queue_reports_what_was_never_read() { let (sink, mut received) = mpsc::unbounded_channel(); let mut queue = Queue { running: true, ..Queue::default() }; queue .awaiting .push_back(("q1".into(), "first".into(), Vec::new())); queue .awaiting .push_back(("q2".into(), "second".into(), Vec::new())); queue.close(&sink, "the session ended"); let Some(Event::Error { message }) = received.try_recv().ok() else { panic!("closing a queue holding messages must report them"); }; assert!(message.contains("2 queued messages"), "{message}"); assert!(message.starts_with("the session ended"), "{message}"); assert!( message.contains("first") && message.contains("second"), "{message}" ); assert!(!queue.running); assert!(queue.closed); } #[test] fn a_turn_this_side_did_not_start_still_reports_as_running() { let dir = tempfile::tempdir().expect("tempdir"); let (sink, mut received) = mpsc::unbounded_channel(); let state = Arc::new(Mutex::new(Translator::new( dir.path().to_path_buf(), Arc::new(Subagents::new(dir.path().to_path_buf())), ))); let queue = Arc::new(Mutex::new(Queue::default())); let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"working"}},"parent_tool_use_id":null}"#; assert!(translate_line(text, dir.path(), &state, &sink, &queue)); assert_eq!( received.try_recv().ok(), Some(Event::Status { state: SessionStatus::Running }), "a turn in flight has to be reported before the output proving it" ); assert!(matches!( received.try_recv().ok(), Some(Event::AssistantText { .. }) )); assert!(translate_line(text, dir.path(), &state, &sink, &queue)); assert!(matches!( received.try_recv().ok(), Some(Event::AssistantText { .. }) )); let done = r#"{"type":"result","subtype":"success","is_error":false,"usage":{}}"#; assert!(translate_line(done, dir.path(), &state, &sink, &queue)); assert_eq!( received.try_recv().ok(), Some(Event::Status { state: SessionStatus::Idle }) ); assert!(!queue.lock().unwrap().running); } #[test] fn output_from_a_process_that_has_gone_does_not_revive_the_turn() { let dir = tempfile::tempdir().expect("tempdir"); let (sink, mut received) = mpsc::unbounded_channel(); let state = Arc::new(Mutex::new(Translator::new( dir.path().to_path_buf(), Arc::new(Subagents::new(dir.path().to_path_buf())), ))); let queue = Arc::new(Mutex::new(Queue::default())); queue.lock().unwrap().close(&sink, "the session ended"); let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"late"}},"parent_tool_use_id":null}"#; assert!(translate_line(text, dir.path(), &state, &sink, &queue)); assert!(matches!( received.try_recv().ok(), Some(Event::AssistantText { .. }) )); assert!(!queue.lock().unwrap().running); } #[test] fn closing_an_empty_queue_says_nothing() { let (sink, mut received) = mpsc::unbounded_channel(); let mut queue = Queue::default(); queue.close(&sink, "the session ended"); assert!(received.try_recv().is_err()); assert!(queue.closed); } }