use std::collections::BTreeSet;
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, Role, Turn, derive_path,
extract_conversation,
};
fn fixture_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("test-fixtures")
.join("codex")
.join("convo.jsonl")
}
fn load_fixture_view() -> ConversationView {
let session = RolloutReader::read_session(fixture_path()).expect("read codex 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)
}
fn is_system_envelope(turn: &Turn) -> bool {
if !matches!(turn.role, Role::User) {
return false;
}
let t = turn.text.trim_start();
t.starts_with('<') && t.contains('>')
}
fn meaningful(view: &ConversationView) -> Vec<&Turn> {
view.turns
.iter()
.filter(|t| !is_system_envelope(t))
.collect()
}
fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn fixture_loads() {
let view = load_fixture_view();
assert!(
!view.turns.is_empty(),
"codex fixture should produce a non-empty view"
);
let m = meaningful(&view);
assert!(
m.iter().any(|t| matches!(t.role, Role::User)),
"fixture should contain at least one meaningful user turn"
);
assert!(
m.iter().any(|t| matches!(t.role, Role::Assistant)),
"fixture should contain at least one assistant turn"
);
}
#[test]
fn roundtrip_preserves_meaningful_turn_count_and_roles() {
let original = load_fixture_view();
let after = ir_roundtrip(&original);
let o = meaningful(&original);
let a = meaningful(&after);
assert_eq!(
o.len(),
a.len(),
"meaningful turn count diverged: original={} after={}",
o.len(),
a.len()
);
for (i, (x, y)) in o.iter().zip(a.iter()).enumerate() {
assert_eq!(
x.role, y.role,
"role at meaningful turn {i}: {:?} vs {:?}",
x.role, y.role
);
}
}
#[test]
fn roundtrip_preserves_turn_text() {
let original = load_fixture_view();
let after = ir_roundtrip(&original);
for (i, (x, y)) in meaningful(&original)
.iter()
.zip(meaningful(&after).iter())
.enumerate()
{
assert_eq!(
norm(&x.text),
norm(&y.text),
"text at turn {i} diverged\n original: {:?}\n after: {:?}",
x.text,
y.text
);
}
}
#[test]
fn roundtrip_preserves_tool_call_topology() {
let original = load_fixture_view();
let after = ir_roundtrip(&original);
for (i, (x, y)) in meaningful(&original)
.iter()
.zip(meaningful(&after).iter())
.enumerate()
{
if !matches!(x.role, Role::Assistant) {
continue;
}
let xs: BTreeSet<&str> = x.tool_uses.iter().map(|t| t.id.as_str()).collect();
let ys: BTreeSet<&str> = y.tool_uses.iter().map(|t| t.id.as_str()).collect();
assert_eq!(
xs, ys,
"tool_use id set diverged at turn {i}: {xs:?} vs {ys:?}"
);
for tx in &x.tool_uses {
let ty = y
.tool_uses
.iter()
.find(|t| t.id == tx.id)
.unwrap_or_else(|| panic!("missing tool {} after roundtrip", tx.id));
assert_eq!(tx.name, ty.name, "tool {} name diverged", tx.id);
match (&tx.result, &ty.result) {
(Some(rx), Some(ry)) => {
assert_eq!(
rx.content, ry.content,
"tool {} result content diverged",
tx.id
);
assert_eq!(rx.is_error, ry.is_error, "tool {} is_error diverged", tx.id);
}
(None, None) => {}
(l, r) => panic!(
"tool {} result presence diverged: original={} after={}",
tx.id,
l.is_some(),
r.is_some()
),
}
}
}
}
#[test]
fn roundtrip_preserves_delegations() {
let original = load_fixture_view();
let after = ir_roundtrip(&original);
let total_before: usize = original.turns.iter().map(|t| t.delegations.len()).sum();
let total_after: usize = after.turns.iter().map(|t| t.delegations.len()).sum();
assert_eq!(
total_before, total_after,
"total delegation count diverged: {total_before} → {total_after}"
);
for (i, (a, b)) in original.turns.iter().zip(after.turns.iter()).enumerate() {
assert_eq!(
a.delegations.len(),
b.delegations.len(),
"turn {i} delegation count diverged"
);
for da in &a.delegations {
let db = b
.delegations
.iter()
.find(|d| d.agent_id == da.agent_id)
.unwrap_or_else(|| panic!("delegation {} dropped at turn {i}", da.agent_id));
assert_eq!(
norm(&da.prompt),
norm(&db.prompt),
"delegation {} prompt diverged at turn {i}",
da.agent_id
);
assert_eq!(
da.turns.len(),
db.turns.len(),
"delegation {} child-turn count diverged at turn {i}",
da.agent_id
);
}
}
}
#[test]
fn projector_output_is_re_parseable_by_reader() {
let view = load_fixture_view();
let after = ir_roundtrip(&view);
let projector = CodexProjector::new();
let session = projector.project(&after).expect("project to codex session");
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");
}