Files
ai-app/server/src/session/claude.rs
T

1215 lines
44 KiB
Rust

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>) -> 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<AttachmentRef>)>,
/// 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::<Vec<_>>()
.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<Mutex<Queue>>,
to_child: mpsc::UnboundedSender<String>,
state: Arc<Mutex<Translator>>,
session_dir: PathBuf,
reading: Arc<AtomicBool>,
}
impl ClaudeDriver {
pub fn launch(
meta: &SessionConfig,
provider: &ProviderConfig,
transport: &Transport,
session_dir: &Path,
sink: EventSink,
subagents: Arc<Subagents>,
) -> Result<Self> {
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::<String>();
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<process::Record> {
let mut args: Vec<String> = ["-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<Setting>) {
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<AttachmentRef>) {
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<Mutex<Translator>>,
sink: EventSink,
queue: Arc<Mutex<Queue>>,
reading: Arc<AtomicBool>,
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<Mutex<Translator>>,
sink: &EventSink,
queue: &Arc<Mutex<Queue>>,
) -> bool {
let Ok(message) = serde_json::from_str::<Value>(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<Mutex<Queue>>, sink: &EventSink) -> bool {
let taken: Vec<(String, String, Vec<AttachmentRef>)> = {
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<String> = text
.lines()
.rev()
.take(STDERR_LINES_KEPT)
.map(str::to_string)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
tail_of(&kept)
}
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())
.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::File> {
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<String> {
let text = std::fs::read_to_string(session_dir.join(RESUME_FILE)).ok()?;
serde_json::from_str::<Value>(&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<PathBuf> {
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<Value> {
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<Event> {
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::<Event>();
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<Mutex<Queue>>),
) -> Vec<Event> {
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::<Event>();
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<String> = 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<String> = ["", " ", ""].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<String> = 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);
}
}