agent-sessions 0.3.0

Read native coding-agent sessions as typed Rust events with source provenance
Documentation
use agent_sessions::*;
use std::io::{self, BufReader, Cursor, Read};

fn reader(s: &[u8], opts: ReadOptions) -> SessionReader<Cursor<&[u8]>> {
    read_from(Agent::ClaudeCode, Cursor::new(s), &opts).unwrap()
}
fn message() -> Vec<u8> {
    b"{\"type\":\"user\",\"message\":{\"content\":\" hello \"}}\n".to_vec()
}

#[test]
fn physical_occurrences_and_whitespace_are_preserved() {
    let mut data = vec![b'\n'];
    data.extend(message());
    data.extend(message());
    let mut r = reader(&data, ReadOptions::default());
    let events = r.by_ref().collect::<Result<Vec<_>, _>>().unwrap();
    assert_eq!(events.len(), 2);
    assert_eq!(events[0].location.record_index, 1);
    assert_eq!(events[1].location.record_index, 2);
    assert_eq!(events[0].location.byte_start, 1);
    assert_eq!(events[0].location.byte_end, 1 + message().len() as u64);
    assert!(matches!(&events[0].value,Event::Message(m) if m.text==" hello "));
    assert_eq!(r.finish().last_complete_byte, data.len() as u64);
}

#[test]
fn valid_final_record_needs_no_newline() {
    let mut data = message();
    data.pop();
    let mut r = reader(&data, ReadOptions::default());
    assert_eq!(r.by_ref().count(), 1);
    assert!(r.finish().is_complete());
}

#[test]
fn tail_requires_explicit_tolerance_and_syntax_eof() {
    for (tail, valid_partial) in [
        (b"{\"type\":".as_slice(), true),
        (b"{broken}".as_slice(), false),
    ] {
        let mut data = message();
        data.extend(tail);
        let mut r = reader(
            &data,
            ReadOptions {
                tail: TailMode::AllowIncomplete,
                ..Default::default()
            },
        );
        assert!(r.next().unwrap().is_ok());
        if valid_partial {
            assert!(r.next().is_none());
            assert_eq!(r.summary().status, ReadStatus::IncompleteTail);
        } else {
            assert!(r.next().unwrap().is_err());
            assert!(r.next().is_none());
        }
        assert_eq!(r.finish().last_complete_byte, message().len() as u64);
    }
    let mut r = reader(b"{", ReadOptions::default());
    assert!(r.next().unwrap().is_err());
    assert!(r.next().is_none());
    assert_eq!(r.finish().status, ReadStatus::CompleteWithErrors);
    let mut r = reader(
        b"{\n",
        ReadOptions {
            tail: TailMode::AllowIncomplete,
            ..Default::default()
        },
    );
    assert!(r.next().unwrap().is_err());
}

#[test]
fn malformed_middle_and_utf8_are_visible_and_do_not_advance_checkpoint() {
    let mut data = message();
    data.extend(b"oops\n\xff\n");
    data.extend(message());
    let mut r = reader(&data, ReadOptions::default());
    let out = r.by_ref().collect::<Vec<_>>();
    assert_eq!(out.len(), 4);
    assert!(out[0].is_ok());
    assert!(out[3].is_ok());
    assert!(matches!(
        &out[2],
        Err(StreamError::Line {
            kind: LineErrorKind::InvalidUtf8,
            ..
        })
    ));
    let s = r.finish();
    assert_eq!(s.line_errors, 2);
    assert_eq!(s.last_complete_byte, message().len() as u64);
    assert_eq!(s.status, ReadStatus::CompleteWithErrors);
}

#[test]
fn oversized_line_is_drained_before_next_record() {
    let mut data = vec![b'x'; 100_000];
    data.push(b'\n');
    data.extend(message());
    let mut r = reader(
        &data,
        ReadOptions {
            max_line_bytes: Some(128),
            ..Default::default()
        },
    );
    assert!(matches!(
        r.next(),
        Some(Err(StreamError::Line {
            kind: LineErrorKind::TooLong,
            ..
        }))
    ));
    assert_eq!(r.next().unwrap().unwrap().location.record_index, 1);
    assert!(r.next().is_none());
    assert_eq!(r.finish().last_complete_byte, 0);
}

#[test]
fn stream_file_limit_is_fatal_once() {
    let data = message();
    let limit = data.len() as u64 - 1;
    let mut r = reader(
        &data,
        ReadOptions {
            max_file_bytes: Some(limit),
            ..Default::default()
        },
    );
    assert!(matches!(r.next(), Some(Err(StreamError::TooLarge { .. }))));
    assert!(r.next().is_none());
    assert_eq!(r.finish().status, ReadStatus::Failed);
}

#[test]
fn exact_snapshot_and_short_snapshot() {
    let mut data = message();
    let boundary = data.len() as u64;
    data.extend(b"garbage");
    let opts = ReadOptions {
        stop_at_byte: Some(boundary),
        ..Default::default()
    };
    let mut r = reader(&data, opts.clone());
    assert_eq!(r.by_ref().count(), 1);
    assert!(r.finish().is_complete());
    let mut r = reader(b"{}", opts);
    assert!(matches!(
        r.next(),
        Some(Err(StreamError::SnapshotTruncated { .. }))
    ));
    assert!(r.next().is_none());
    assert_eq!(r.finish().status, ReadStatus::Failed);
}

#[test]
fn early_stop_is_not_completion_and_partial_record_is_not_checkpointed() {
    let data = br#"{"type":"assistant","cwd":"/work","message":{"content":"text"}}"#;
    let mut r = reader(data, ReadOptions::default());
    assert!(matches!(r.next().unwrap().unwrap().value, Event::Meta(_)));
    let s = r.finish();
    assert_eq!(s.status, ReadStatus::StoppedEarly);
    assert_eq!(s.last_complete_byte, 0);
    let r = reader(data, ReadOptions::default());
    assert_eq!(r.finish().bytes_read, 0);
}

struct Breaks {
    prefix: Cursor<Vec<u8>>,
}
impl Read for Breaks {
    fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
        let n = self.prefix.read(out)?;
        if n == 0 {
            Err(io::Error::other("synthetic IO failure"))
        } else {
            Ok(n)
        }
    }
}
#[test]
fn late_io_failure_preserves_failed_status() {
    let source = BufReader::with_capacity(
        3,
        Breaks {
            prefix: Cursor::new(message()),
        },
    );
    let mut r = read_from(Agent::ClaudeCode, source, &ReadOptions::default()).unwrap();
    assert!(r.next().unwrap().is_ok());
    assert!(matches!(r.next(), Some(Err(StreamError::Io(_)))));
    assert!(r.next().is_none());
    assert_eq!(r.finish().status, ReadStatus::Failed);
}