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

842 lines
32 KiB
Rust

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<u64>,
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<Vec<Importable>> {
// Claude Code writes a descriptor per live session at
// `~/.claude/sessions/<pid>.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<Option<Importable>> {
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<Vec<Importable>> {
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<Importable> = 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<Importable> {
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::<Value>(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<u64> {
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<String> {
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::<Vec<_>>()
.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 <path>`, 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<String> {
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<Event> {
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::<Value>(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<Event> {
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<super::driver::SessionStatus> {
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<Event>, 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<Event>,
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<Event>, 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<HashMap<String, Result<(), String>>> {
let (safe, mut outcomes): (Vec<&String>, HashMap<String, Result<(), String>>) =
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<Cursor> {
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<usize> {
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<u64> {
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<Vec<Event>> {
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<cross-session-message from-name=\"dev-updater-f5\">\nPull before you touch AGENTS.md.\n</cross-session-message>"}}"#;
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);
}
}