use std::collections::HashMap; use anyhow::{Context, Result}; use serde::Serialize; use serde_json::Value; use super::driver::{self, Event}; use super::transport::{Launch, Transport}; /// The imported conversation is for reading; *continuing* it is the CLI's job /// through `--resume`, and it reads the whole file itself. So this is a /// display budget, and it needs to be one: these files reach tens of megabytes /// and every line would otherwise cross a WireGuard link to a phone. const REPLAY_LINES: usize = 2000; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum InUse { No, Yes, Unknown, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Importable { pub id: String, pub cwd: String, pub title: String, /// Epoch seconds, for ordering by "what I was last doing". pub modified: f64, pub lines: usize, /// `None` when no assistant turn has recorded usage yet -- which is not /// zero, and is why this is an option. pub context_tokens: Option, pub bytes: u64, pub named: bool, pub in_use: InUse, /// Where it lives. Not serialized: the phone chooses by id and the server /// resolves the path, so a path never crosses the wire either direction. #[serde(skip)] pub path: String, } pub async fn list(transport: &Transport) -> Result> { // Claude Code writes a descriptor per live session at // `~/.claude/sessions/.json`, and records `procStart` -- the kernel's // start time for that pid -- for the same reason `session::process` does: a // pid on its own is reused, so a descriptor left by a crashed CLI would // otherwise mark a session as open for as long as something else held its // number. Checking both is what makes this a measurement. // // Then two questions per file, both answered from the end of it. A rename // if there was one, grepped over the whole file rather than its tail // because a session can be named early and talked in for hours after. Then // the last several things a person said -- the *last*, because the question // this answers is "which one was I just in", and several because the final // ones are often the CLI's own. let script = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#); let launch = Launch::new("sh", vec!["-c".to_string(), script], None); parse_listing(&transport.capture(&launch).await?) } pub async fn find(transport: &Transport, id: &str) -> Result> { if !is_session_id(id) { return Ok(None); } let script = listing_script(r#""$HOME"/.claude/projects/*/"$1".jsonl"#); let launch = Launch::new( "sh", vec!["-c".to_string(), script, "sh".to_string(), id.to_string()], None, ); Ok(parse_listing(&transport.capture(&launch).await?)? .into_iter() .find(|candidate| candidate.id == id)) } fn listing_script(glob: &str) -> String { // `replace` rather than `format!`: this is shell, so it is full of braces, // and every one would have to be doubled to survive a format string -- // exactly the kind of edit that looks right and changes what the shell runs. SCRIPT.replace("{glob}", glob) } const SCRIPT: &str = r#" if [ -d "$HOME/.claude/sessions" ]; then printf 'LIVEKNOWN\n' for s in "$HOME"/.claude/sessions/*.json; do [ -f "$s" ] || continue pid=${s##*/}; pid=${pid%.json} [ -d "/proc/$pid" ] || continue start=$(awk '{ n=index($0,") "); $0=substr($0,n+2); print $20 }' "/proc/$pid/stat" 2>/dev/null) [ -n "$start" ] || continue grep -q "\"procStart\":\"$start\"" "$s" || continue sid=$(grep -o '"sessionId":"[^"]*"' "$s" | head -1 | cut -d'"' -f4) [ -n "$sid" ] && printf 'LIVE\t%s\n' "$sid" done fi for f in {glob}; do [ -f "$f" ] || continue printf '%s\t%s\t%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" \ "$(wc -l < "$f")" "$(stat -c %s "$f" 2>/dev/null || echo 0)" \ "$(grep -o '"usage":{[^}]*' "$f" 2>/dev/null | tail -1)" "$f" grep '"type":"custom-title"' "$f" 2>/dev/null | tail -1 | tr '\n' '\037' grep '"type":"user"' "$f" 2>/dev/null | grep -v '"tool_use_id"' | tail -12 | tr '\n' '\037' printf '\n' done "#; fn parse_listing(found: &str) -> Result> { let mut live = std::collections::HashSet::new(); let mut checkable = false; for line in found.lines() { if line.trim() == "LIVEKNOWN" { checkable = true; } else if let Some(id) = line.strip_prefix("LIVE\t") { live.insert(id.trim().to_string()); } } let mut sessions: Vec = found.lines().filter_map(parse_row).collect(); for session in &mut sessions { session.in_use = match (checkable, live.contains(&session.id)) { (_, true) => InUse::Yes, (true, false) => InUse::No, (false, false) => InUse::Unknown, }; } // One row per session id, because the id is what everything downstream // addresses: `--resume` takes it, deleting globs for it, the in-flight // registry is keyed on it, and the phone keys its list on it -- which // turned two rows sharing an id into a crash rather than a confusion. sessions.sort_by(|a, b| { b.lines .cmp(&a.lines) .then_with(|| b.modified.total_cmp(&a.modified)) }); let mut seen = std::collections::HashSet::new(); sessions.retain(|session| seen.insert(session.id.clone())); sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified)); Ok(sessions) } fn parse_row(line: &str) -> Option { let mut fields = line.splitn(6, '\t'); let modified: f64 = fields.next()?.trim().parse().ok()?; let lines: usize = fields.next()?.trim().parse().ok()?; let bytes: u64 = fields.next()?.trim().parse().ok()?; let context_tokens = context_tokens(fields.next()?); let path = fields.next()?.to_string(); let id = path.rsplit('/').next()?.strip_suffix(".jsonl")?.to_string(); let mut named = None; let mut said = None; let mut cwd = None; for record in fields .next() .unwrap_or("") .split('\u{1f}') .filter_map(|record| serde_json::from_str::(record).ok()) { if cwd.is_none() { cwd = record.get("cwd").and_then(Value::as_str).map(String::from); } if let Some(custom) = record.get("customTitle").and_then(Value::as_str) { named = Some(custom.to_string()); continue; } if !is_hidden(&record) && let Some(text) = first_line_of(&record) { said = Some(text); } } Some(Importable { id, in_use: InUse::Unknown, cwd: cwd.unwrap_or_default(), // A name somebody typed outranks anything read out of the conversation, // because they chose it to answer this exact question. named: named.is_some(), title: named .or(said) .unwrap_or_else(|| "(no messages)".to_string()), modified, lines, bytes, context_tokens, path, }) } /// The input tokens named in one `usage` object, added up: prompt plus cache /// creation plus cache read, all three being context the model was given. The /// definition is [`driver::context_tokens`]; this is the same three figures dug /// out of a raw line rather than a parsed one, because these files reach tens /// of megabytes. /// /// `None` for an empty blob, meaning no assistant turn has recorded usage. /// Missing fields count as zero, which is what an absent category means. fn context_tokens(usage: &str) -> Option { if usage.trim().is_empty() { return None; } let field = |name: &str| -> u64 { usage .split_once(&format!("\"{name}\":")) .map(|(_, rest)| rest.trim_start()) .and_then(|rest| { let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); digits.parse().ok() }) .unwrap_or(0) }; Some(driver::context_tokens( field("input_tokens"), field("cache_creation_input_tokens"), field("cache_read_input_tokens"), )) } fn first_line_of(record: &Value) -> Option { let text = text_of(record.get("message")?.get("content")?); let first = text.lines().find(|line| !line.trim().is_empty())?.trim(); if first.starts_with('<') { return None; } let trimmed: String = first.chars().take(90).collect(); (!trimmed.is_empty()).then_some(trimmed) } fn is_hidden(record: &Value) -> bool { record.get("isSidechain").and_then(Value::as_bool) == Some(true) || record.get("isMeta").and_then(Value::as_bool) == Some(true) } /// One reader for the field rather than one per caller: the live /// translator (`translate.rs`) and this replay of the CLI's own file look /// at the same block shape, and a call drawn as failed in one and as /// succeeded in the other would be the same conversation disagreeing with /// itself. Absent means "not reported to have failed" -- which is what the /// CLI writes for a call that went fine, and also what every transcript /// written before this field was read says. pub(crate) fn tool_result_is_error(block: &Value) -> bool { block .get("is_error") .and_then(Value::as_bool) .unwrap_or(false) } fn text_of(content: &Value) -> String { match content { Value::String(text) => text.clone(), Value::Array(blocks) => blocks .iter() .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) .filter_map(|block| block.get("text").and_then(Value::as_str)) .collect::>() .join("\n"), _ => String::new(), } } pub async fn directory_exists(transport: &Transport, path: &str) -> bool { if path.is_empty() { return false; } // Asked by *entering* it rather than by `test -d `, because the // question this stands in for is "can a session start here" and because a // path is only expanded where it is a working directory -- `~/repos/ai-app` // as an argument stays literal on both transports, so the old form answered // "no such directory" about every home-relative path somebody typed. let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path))); transport.capture(&launch).await.is_ok() } /// `tail` rather than the whole file, and as [`Launch`] arguments rather than a /// shell string, so the path is an argument and never syntax. /// /// Returns text rather than events because turning records into events has a /// side effect -- writing out the images they carry -- and that needs the /// session directory, which does not exist until after this runs. pub async fn read_tail(transport: &Transport, path: &str) -> Result { let launch = Launch::new( "tail", vec!["-n".to_string(), REPLAY_LINES.to_string(), path.to_string()], None, ); transport .capture(&launch) .await .with_context(|| format!("reading {path}")) } pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec { let mut events = Vec::new(); // What the newest record that had an opinion says the session is doing. // Kept to the end rather than pushed as it is found, because the answer is // the last one and everything before it is history. let mut state = None; for line in text.lines() { let Ok(record) = serde_json::from_str::(line) else { continue; }; if let Some(peer) = peer_message(&record) { // Before `is_hidden`, which these records are: the CLI marks them // meta because they are not the user's own words, and that is the // reason to draw them differently rather than to drop them. A // session working on something a phone never asked for is otherwise // unexplainable from the phone. state = turn_state(&record).or(state); events.push(peer); continue; } if is_hidden(&record) { continue; } state = turn_state(&record).or(state); let Some(message) = record.get("message") else { continue; }; let Some(content) = message.get("content") else { continue; }; match record.get("type").and_then(Value::as_str) { Some("user") => push_user(&mut events, content, session_dir), Some("assistant") => push_assistant(&mut events, content), _ => {} } } if let Some(state) = state { events.push(Event::Status { state }); } events } /// Shared with the live driver, which finds the same `origin` object on a /// different record -- so this reads the object and not the record around it. /// One function because it is one wire format: two copies would drift the first /// time the CLI renames a field, and the half that drifted would produce /// nothing at all, which is indistinguishable from nobody having sent /// anything. pub(in crate::session) fn peer_message(record: &Value) -> Option { let origin = record.get("origin")?; if origin.get("kind").and_then(Value::as_str) != Some("peer") { return None; } Some(Event::PeerMessage { from: origin .get("name") .and_then(Value::as_str) .unwrap_or("another session") .to_string(), text: origin.get("body").and_then(Value::as_str)?.to_string(), turn_start: None, }) } fn turn_state(record: &Value) -> Option { use super::driver::SessionStatus; match record.get("type").and_then(Value::as_str)? { "user" => Some(SessionStatus::Running), "assistant" => match record["message"] .get("stop_reason") .and_then(Value::as_str)? { "tool_use" => Some(SessionStatus::Running), _ => Some(SessionStatus::Idle), }, _ => None, } } fn push_user(events: &mut Vec, content: &Value, session_dir: &std::path::Path) { // A tool result arrives as a user record, because that is how the API // models it -- but it is the other half of a tool call, and showing it as a // message would put the reader's own words and a command's output in the // same voice. if let Value::Array(blocks) = content { for block in blocks { push_images(events, std::slice::from_ref(block), session_dir, None); if block.get("type").and_then(Value::as_str) == Some("tool_result") && let Some(id) = block.get("tool_use_id").and_then(Value::as_str) { if let Some(Value::Array(parts)) = block.get("content") { push_images(events, parts, session_dir, Some(id)); } events.push(Event::ToolEnd { id: id.to_string(), output: text_of(block.get("content").unwrap_or(&Value::Null)), is_error: tool_result_is_error(block), }); } } } let text = text_of(content); if !text.trim().is_empty() { // Replayed from the CLI's own file: it was read long ago, so there is // no waiting bubble for it to resolve. Its images are saved and // referenced separately just above, because they came out of somebody // else's file rather than this app's composer. events.push(Event::UserMessage { id: None, text, attachments: Vec::new(), }); } } fn push_images( events: &mut Vec, parts: &[Value], session_dir: &std::path::Path, about: Option<&str>, ) { for part in parts { if part.get("type").and_then(Value::as_str) == Some("image") && let Some(name) = super::claude::translate::save_image(session_dir, part) { events.push(Event::Image { image: name, about: about.map(String::from), }); } } } fn push_assistant(events: &mut Vec, content: &Value) { let Value::Array(blocks) = content else { return; }; for block in blocks { match block.get("type").and_then(Value::as_str) { Some("text") => { if let Some(text) = block.get("text").and_then(Value::as_str) && !text.is_empty() { events.push(Event::AssistantText { delta: text.to_string(), }); } } Some("tool_use") => { if let (Some(id), Some(name)) = ( block.get("id").and_then(Value::as_str), block.get("name").and_then(Value::as_str), ) { events.push(Event::ToolStart { id: id.to_string(), tool: name.to_string(), input: block.get("input").cloned().unwrap_or(Value::Null), }); } } _ => {} } } } const DELETE_SCRIPT: &str = r#" for id do state=missing for f in "$HOME"/.claude/projects/*/"$id".jsonl; do [ -f "$f" ] || continue if rm -f "$f"; then [ "$state" = failed ] || state=deleted else state=failed fi done printf '%s\t%s\n' "$id" "$state" done "#; /// By id, resolved on the machine against what it actually has, so the caller /// never names a file -- the same rule importing follows, and it matters more /// here: this one removes something. /// /// Irreversible, and the caller is expected to have said so. Claude Code keeps /// no copy: the JSONL *is* the session. /// /// The whole batch in one invocation, which over ssh is the difference between /// one connection and one per session. Six deletes started in the same tick /// were six `ssh` processes racing to authenticate, and a batch past the remote /// sshd's `MaxStartups` had rows come back as `Connection closed by …` -- a row /// reporting a delete that never ran, for a reason nothing to do with the /// session. /// /// Still one outcome per id, because a batch is not a transaction. Every /// requested id gets an entry, so an id the machine said nothing about is /// reported as such rather than defaulting to either answer. pub async fn delete( transport: &Transport, ids: &[String], ) -> Result>> { let (safe, mut outcomes): (Vec<&String>, HashMap>) = ids.iter().fold( (Vec::new(), HashMap::new()), |(mut safe, mut outcomes), id| { if is_session_id(id) { safe.push(id); } else { outcomes.insert( id.clone(), Err(format!("not a Claude Code session id: {id}")), ); } (safe, outcomes) }, ); if safe.is_empty() { return Ok(outcomes); } let mut args = vec![ "-c".to_string(), DELETE_SCRIPT.to_string(), "sh".to_string(), ]; args.extend(safe.iter().map(|id| (*id).clone())); let launch = Launch::new("sh", args, None); let reported = transport .capture(&launch) .await .with_context(|| format!("deleting {} Claude Code sessions", safe.len()))?; for line in reported.lines() { let Some((id, state)) = line.trim().split_once('\t') else { continue; }; outcomes.insert( id.to_string(), match state { "deleted" => Ok(()), "missing" => Err(format!("no Claude Code session {id} on that machine")), _ => Err(format!("couldn't remove Claude Code session {id}")), }, ); } // Anything the machine did not mention. The connection can drop part-way // through the loop, and an id whose line never arrived is one nobody knows // the fate of -- which is its own answer, and must not read as either a // success or a clean "not there". for id in safe { outcomes.entry(id.clone()).or_insert_with(|| { Err(format!( "couldn't tell whether Claude Code session {id} was deleted -- the machine \ stopped answering part-way through the batch" )) }); } Ok(outcomes) } fn is_session_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') } /// A poll rather than a watch, because the file may be on another machine and /// there is no portable way to be told. Ten seconds is chosen against the cost /// of an ssh round trip rather than against how fast a person types. pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); /// Where an imported session came from, and how much of it has been shown. /// Kept beside the session rather than in its config, because it is a position /// in someone else's file rather than anything the person chose, and it changes /// constantly. #[derive(Debug, Clone, Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct Cursor { pub path: String, /// Lines of that file already accounted for -- whether replayed into /// the transcript or skipped because this session wrote them itself. pub lines: usize, } const CURSOR_FILE: &str = "import.json"; pub fn read_cursor(session_dir: &std::path::Path) -> Option { let text = std::fs::read_to_string(session_dir.join(CURSOR_FILE)).ok()?; serde_json::from_str(&text).ok() } pub fn write_cursor(session_dir: &std::path::Path, cursor: &Cursor) { let path = session_dir.join(CURSOR_FILE); match serde_json::to_string(cursor) { Ok(text) => { if let Err(err) = std::fs::write(&path, text) { tracing::error!( "couldn't persist the import cursor to {}: {err}", path.display() ); } } Err(err) => tracing::error!("couldn't serialize the import cursor: {err}"), } } pub async fn line_count(transport: &Transport, path: &str) -> Result { let launch = Launch::new("wc", vec!["-l".to_string(), path.to_string()], None); let out = transport.capture(&launch).await?; out.split_whitespace() .next() .and_then(|n| n.parse().ok()) .with_context(|| format!("couldn't read a line count out of {out:?}")) } /// A clear needs no special case here even though it makes the last usage /// in a file stale. Clearing gives the CLI a *new* session id, which the /// reader persists as the resume token, so this looks in a file that has /// no usage in it yet and answers `None` -- which is the true answer. /// /// `None` for every way it cannot be read: no resume token, no file, a /// machine that cannot be reached, or a file with no assistant turn in it. /// Not knowing is a state the status row draws, so there is nothing to be /// gained by inventing a number here. pub async fn context_of(transport: &Transport, session_id: &str) -> Option { if !is_session_id(session_id) { return None; } // The id crosses as an argument rather than as script text: it comes // from the CLI, but it reaches a shell on a machine that may not be // this one, and the rule there is that data never becomes syntax. let script = r#" for f in "$HOME"/.claude/projects/*/"$1".jsonl; do [ -f "$f" ] || continue grep -o '"usage":{[^}]*' "$f" | tail -1 exit 0 done "#; let launch = Launch::new( "sh", vec![ "-c".to_string(), script.to_string(), "sh".to_string(), session_id.to_string(), ], None, ); context_tokens(&transport.capture(&launch).await.ok()?) } pub async fn replay_after( transport: &Transport, path: &str, after: usize, session_dir: &std::path::Path, ) -> Result> { let launch = Launch::new( "tail", vec![format!("-n+{}", after + 1), path.to_string()], None, ); let text = transport .capture(&launch) .await .with_context(|| format!("reading {path} from line {}", after + 1))?; Ok(events_from(&text, session_dir)) } #[cfg(test)] mod tests { use super::*; #[test] fn a_session_recorded_under_two_projects_is_offered_once() { let id = "3114dee1-2f95-4de0-9c04-3d6fcc594afe"; let said = r#"{"cwd":"/home/bob/repos/survey","message":{"role":"user","content":"the real conversation"}}"#; let stub = r#"{"cwd":"/home/bob/repos/elsewhere","message":{"role":"user","content":"resumed here once"}}"#; let listing = format!( "LIVEKNOWN\n\ 1000.0\t412\t160638\t\t/home/bob/.claude/projects/-home-bob-repos-survey/{id}.jsonl\t{said}\n\ 2000.0\t4\t614\t\t/home/bob/.claude/projects/-home-bob-repos-elsewhere/{id}.jsonl\t{stub}\n" ); let rows = parse_listing(&listing).expect("parse"); assert_eq!(rows.len(), 1, "one id is one row: {rows:#?}"); assert_eq!(rows[0].lines, 412, "the conversation, not the stub"); assert_eq!(rows[0].cwd, "/home/bob/repos/survey"); } #[test] fn a_session_id_cannot_walk_out_of_the_projects_directory() { assert!(is_session_id("5ecf21da-d53f-4a11-9c0d-000000000100")); assert!(is_session_id("deadbeef")); assert!(!is_session_id("../../../etc/passwd")); assert!(!is_session_id("a/b")); assert!(!is_session_id("..")); assert!(!is_session_id("a.b")); assert!(!is_session_id("a*")); assert!(!is_session_id("a b")); assert!(!is_session_id("")); assert!(!is_session_id(&"a".repeat(65))); } #[test] fn a_batch_deletes_every_copy_and_reports_each_id_once() { let home = tempfile::tempdir().expect("tempdir"); let projects = home.path().join(".claude/projects"); let one = "5ecf21da-d53f-4a11-9c0d-000000000100"; let twice = "5ecf21da-d53f-4a11-9c0d-000000000200"; let absent = "5ecf21da-d53f-4a11-9c0d-000000000300"; for (project, id) in [("a", one), ("a", twice), ("b", twice)] { let dir = projects.join(project); std::fs::create_dir_all(&dir).expect("project dir"); std::fs::write(dir.join(format!("{id}.jsonl")), "{}\n").expect("transcript"); } let output = std::process::Command::new("sh") .args(["-c", DELETE_SCRIPT, "sh", one, twice, absent]) .env("HOME", home.path()) .output() .expect("run the delete script"); assert!(output.status.success()); assert_eq!( String::from_utf8_lossy(&output.stdout), format!("{one}\tdeleted\n{twice}\tdeleted\n{absent}\tmissing\n"), ); assert!(!projects.join("b").join(format!("{twice}.jsonl")).exists()); assert!(!projects.join("a").join(format!("{twice}.jsonl")).exists()); assert!(!projects.join("a").join(format!("{one}.jsonl")).exists()); } const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk\ YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; #[test] fn replayed_screenshots_are_saved_and_referenced() { let dir = tempfile::tempdir().expect("tempdir"); let line = format!( r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"toolu_1","content":[{{"type":"text","text":"took a screenshot"}},{{"type":"image","source":{{"type":"base64","media_type":"image/png","data":"{PNG}"}}}}]}}]}}}}"# ); let events = events_from(&line, dir.path()); let Some(Event::Image { image, .. }) = events.first() else { panic!("a replayed screenshot must become an image event: {events:?}"); }; assert!(image.ends_with(".png")); assert!(dir.path().join("files").join(image).is_file()); assert!( matches!(events.get(1), Some(Event::ToolEnd { .. })), "{events:?}" ); } #[test] fn context_tokens_add_the_input_side_only() { let usage = r#""usage":{"input_tokens":2,"cache_creation_input_tokens":703,"cache_read_input_tokens":142228,"output_tokens":587,"output_tokens_details":{"thinking_tokens":0"#; assert_eq!(context_tokens(usage), Some(142_933)); let only_cache = r#""usage":{"cache_read_input_tokens":100,"output_tokens":9"#; assert_eq!(context_tokens(only_cache), Some(100)); assert_eq!(context_tokens(""), None); assert_eq!(context_tokens(" "), None); } #[test] fn a_record_with_no_image_writes_nothing() { let dir = tempfile::tempdir().expect("tempdir"); let line = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"plain output"}]}}"#; let events = events_from(line, dir.path()); assert_eq!(events.len(), 2, "{events:?}"); assert_eq!( events[1], Event::Status { state: super::super::driver::SessionStatus::Running } ); assert!(!dir.path().join("files").exists()); } #[test] fn a_message_from_another_agent_is_kept_and_named() { let dir = tempfile::tempdir().expect("tempdir"); let line = r#"{"type":"user","isMeta":true,"origin":{"kind":"peer","from":"uds:/run/user/1000/cc-socks/605.sock","verifiedPeerPid":605,"name":"dev-updater-f5","fromMode":"prompting","body":"Pull before you touch AGENTS.md."},"message":{"role":"user","content":"Another Claude session sent a message:\n\nPull before you touch AGENTS.md.\n"}}"#; let events = events_from(line, dir.path()); assert_eq!( events[0], Event::PeerMessage { from: "dev-updater-f5".to_string(), text: "Pull before you touch AGENTS.md.".to_string(), turn_start: None, }, "{events:?}" ); assert_eq!( events[1], Event::Status { state: super::super::driver::SessionStatus::Running } ); } #[test] fn the_last_record_says_whether_the_session_is_working() { use super::super::driver::SessionStatus; let dir = tempfile::tempdir().expect("tempdir"); let asked = r#"{"type":"user","message":{"role":"user","content":"do the thing"}}"#; let calling = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"tool_use","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}"#; let done = r#"{"type":"assistant","message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"done"}]}}"#; let state = |text: &str| { events_from(text, dir.path()) .into_iter() .rev() .find_map(|event| match event { Event::Status { state } => Some(state), _ => None, }) }; assert_eq!(state(asked), Some(SessionStatus::Running)); assert_eq!( state(&[asked, calling].join("\n")), Some(SessionStatus::Running) ); assert_eq!( state(&[asked, calling, done].join("\n")), Some(SessionStatus::Idle), "a turn that has finished talking is over" ); let sidechain = r#"{"type":"assistant","isSidechain":true,"message":{"role":"assistant","stop_reason":"end_turn","content":[{"type":"text","text":"sub"}]}}"#; let unknown = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"?"}]}}"#; assert_eq!( state(&[asked, calling, sidechain, unknown].join("\n")), Some(SessionStatus::Running) ); assert_eq!(state(r#"{"type":"summary","summary":"x"}"#), None); } }