toolpath-codex 0.5.0

Derive Toolpath provenance documents from Codex CLI session logs
Documentation
//! Compaction-event roundtrip: a Codex rollout that includes a
//! `compacted` line in the middle should still preserve the
//! pre-compact and post-compact conversation content through the
//! projection round-trip.
//!
//! Synthetic fixture is justified per project policy: real compaction
//! fires when the model context window fills mid-session and can't
//! reliably be triggered by a 5-minute capture prompt.
//!
//! What this test asserts (and why):
//!
//!   - The fixture loads via `RolloutReader::read_session` without
//!     crashing on the `compacted` line.
//!   - Pre-compact user/assistant content survives the round-trip.
//!   - Post-compact user/assistant content survives the round-trip.
//!   - The conversation projects back to JSONL that re-parses through
//!     `RolloutReader`.
//!
//! Known limitation (documented, not asserted): the `compacted`
//! rollout line itself carries an opaque payload (Codex doesn't model
//! its inner shape — `Compacted(Value)`). Today the IR drops it on the
//! floor. Acceptable loss for "good UX" — the surrounding messages
//! are what users actually read.

use std::path::{Path, PathBuf};

use toolpath::v1::Graph;
use toolpath_codex::project::CodexProjector;
use toolpath_codex::{RolloutReader, to_view};
use toolpath_convo::{
    ConversationProjector, ConversationView, DeriveConfig, derive_path, extract_conversation,
};

fn fixture_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("compacted_session.jsonl")
}

fn load_view() -> ConversationView {
    let session = RolloutReader::read_session(fixture_path()).expect("read fixture");
    to_view(&session)
}

fn ir_roundtrip(view: &ConversationView) -> ConversationView {
    let path = derive_path(view, &DeriveConfig::default());
    let graph = Graph::from_path(path);
    let json = graph.to_json().expect("serialize Graph");
    let back = Graph::from_json(&json).expect("parse Graph");
    let path = back.into_single_path().expect("single path");
    extract_conversation(&path)
}

#[test]
fn fixture_loads_without_panic() {
    let view = load_view();
    assert!(
        !view.turns.is_empty(),
        "compaction fixture should produce turns"
    );
}

#[test]
fn pre_compact_content_survives_roundtrip() {
    let original = load_view();
    let after = ir_roundtrip(&original);

    let needles = ["refactor the auth module", "reading the current auth code"];
    for n in needles {
        assert!(
            original.turns.iter().any(|t| t.text.contains(n)),
            "pre-compact text {n:?} missing from initial view"
        );
        assert!(
            after.turns.iter().any(|t| t.text.contains(n)),
            "pre-compact text {n:?} dropped after roundtrip"
        );
    }
}

#[test]
fn post_compact_content_survives_roundtrip() {
    let original = load_view();
    let after = ir_roundtrip(&original);

    let needles = [
        "now add session validation",
        "added session validation to login()",
    ];
    for n in needles {
        assert!(
            original.turns.iter().any(|t| t.text.contains(n)),
            "post-compact text {n:?} missing from initial view"
        );
        assert!(
            after.turns.iter().any(|t| t.text.contains(n)),
            "post-compact text {n:?} dropped after roundtrip"
        );
    }
}

#[test]
fn projector_output_is_re_parseable_by_reader() {
    let view = load_view();
    let after = ir_roundtrip(&view);
    let projector = CodexProjector::new();
    let session = projector.project(&after).expect("project");

    let mut lines: Vec<String> = Vec::new();
    for line in &session.lines {
        lines.push(serde_json::to_string(line).expect("serialize rollout line"));
    }

    let tmp = tempfile::Builder::new()
        .suffix(".jsonl")
        .tempfile()
        .expect("tempfile");
    std::fs::write(tmp.path(), lines.join("\n")).expect("write tempfile");
    RolloutReader::read_session(tmp.path()).expect("re-read projected JSONL");
}