agent-sessions 0.2.2

Stream Claude Code and Codex JSONL sessions as typed Rust events with source offsets
Documentation
use agent_sessions::*;
use std::{fs, process::Command};

#[test]
fn discovers_configured_roots_archives_and_subagents() {
    let tmp = tempfile::tempdir().unwrap();
    let roots = Roots::from_home(tmp.path());
    for rel in [
        ".claude/projects/p/main.jsonl",
        ".claude/projects/p/subagents/s.jsonl",
        ".codex/sessions/2026/a.jsonl",
        ".codex/archived_sessions/b.jsonl",
    ] {
        let path = tmp.path().join(rel);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(path, b"{}\n").unwrap();
    }
    let d = discover(&roots, &DiscoverFilter::default());
    assert!(d.errors.is_empty());
    assert_eq!(d.files.len(), 3);
    let d = discover(
        &roots,
        &DiscoverFilter {
            include_subagents: true,
            ..Default::default()
        },
    );
    assert_eq!(d.files.len(), 4);
    assert_eq!(
        d.files
            .iter()
            .filter(|f| f.kind == FileKind::Subagent)
            .count(),
        1
    );
    let path = tmp.path().join(".claude/projects/p/main.jsonl");
    assert_eq!(
        classify(&path, &roots).unwrap().unwrap().agent,
        Agent::ClaudeCode
    );
    assert!(
        classify(&tmp.path().join("outside.jsonl"), &roots)
            .unwrap()
            .is_none()
    );
    assert!(
        classify(
            &tmp.path().join(".claude/projects/../outside.jsonl"),
            &roots
        )
        .is_err()
    );
}

#[test]
fn missing_roots_are_normal_but_wrong_type_is_visible() {
    let tmp = tempfile::tempdir().unwrap();
    let roots = Roots::from_home(tmp.path());
    assert!(
        discover(&roots, &DiscoverFilter::default())
            .errors
            .is_empty()
    );
    fs::create_dir_all(tmp.path().join(".claude")).unwrap();
    fs::write(tmp.path().join(".claude/projects"), "not a directory").unwrap();
    let result = discover(&roots, &DiscoverFilter::default());
    assert_eq!(result.errors.len(), 1);
    assert_eq!(result.errors[0].operation, DiscoverOperation::ReadDirectory);
}

#[cfg(unix)]
#[test]
fn symlink_cycle_is_not_followed() {
    use std::os::unix::fs::symlink;
    let tmp = tempfile::tempdir().unwrap();
    let roots = Roots::from_home(tmp.path());
    let dir = tmp.path().join(".claude/projects");
    fs::create_dir_all(&dir).unwrap();
    symlink(&dir, dir.join("loop")).unwrap();
    fs::write(dir.join("main.jsonl"), "{}").unwrap();
    symlink(dir.join("main.jsonl"), dir.join("copy.jsonl")).unwrap();
    let d = discover(&roots, &DiscoverFilter::default());
    assert_eq!(d.files.len(), 1);
}

#[test]
fn file_read_uses_fresh_size_not_discovery_snapshot() {
    let tmp = tempfile::NamedTempFile::new().unwrap();
    let file = SessionFile::inspect(Agent::ClaudeCode, tmp.path()).unwrap();
    fs::write(tmp.path(), b"123456").unwrap();
    let error = read(
        &file,
        &ReadOptions {
            max_file_bytes: Some(2),
            ..Default::default()
        },
    )
    .err()
    .unwrap();
    assert!(matches!(error, ReadError::TooLarge { limit: 2 }));
}

#[test]
fn captured_prefix_budget_matches_file_and_stream_readers() {
    let tmp = tempfile::NamedTempFile::new().unwrap();
    let row = "{\"type\":\"user\",\"message\":{\"content\":\"hi\"}}\n";
    let data = row.repeat(2);
    fs::write(tmp.path(), &data).unwrap();
    let file = SessionFile::inspect(Agent::ClaudeCode, tmp.path()).unwrap();
    let opts = ReadOptions {
        max_file_bytes: Some(row.len() as u64),
        stop_at_byte: Some(row.len() as u64),
        ..Default::default()
    };
    let mut from_file = read(&file, &opts).unwrap();
    let mut from_stream = read_from(Agent::ClaudeCode, std::io::Cursor::new(data), &opts).unwrap();
    let file_events: Vec<_> = from_file.by_ref().map(Result::unwrap).collect();
    let stream_events: Vec<_> = from_stream.by_ref().map(Result::unwrap).collect();
    assert_eq!(file_events, stream_events);
    assert_eq!(file_events.len(), 1);
    assert_eq!(
        serde_json::to_value(from_file.finish()).unwrap(),
        serde_json::to_value(from_stream.finish()).unwrap()
    );
}

#[test]
fn env_overrides_are_tested_in_isolated_processes() {
    for empty in [false, true] {
        let output = Command::new(std::env::current_exe().unwrap())
            .args(["--exact", "env_child", "--ignored"])
            .env(
                "CLAUDE_CONFIG_DIR",
                if empty {
                    ""
                } else {
                    "/tmp/agent-fixture-claude"
                },
            )
            .env("CODEX_HOME", "/tmp/agent-fixture-codex")
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stdout)
        );
    }
}

#[test]
#[ignore = "invoked in isolated processes by env_overrides_are_tested_in_isolated_processes"]
fn env_child() {
    if std::env::var_os("CLAUDE_CONFIG_DIR").unwrap().is_empty() {
        assert!(Roots::from_env().is_err());
    } else {
        let r = Roots::from_env().unwrap();
        assert_eq!(
            r.claude.unwrap().to_str(),
            Some("/tmp/agent-fixture-claude")
        );
        assert_eq!(r.codex.unwrap().to_str(), Some("/tmp/agent-fixture-codex"));
    }
}