use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
use toolpath::v1::{Graph, Path};
use toolpath_codex::project::CodexProjector;
use toolpath_codex::types::{ResponseItem, RolloutItem, Session};
use toolpath_codex::{RolloutReader, to_view};
use toolpath_convo::{
ConversationProjector, ConversationView, DeriveConfig, derive_path, extract_conversation,
};
const FIXTURE: &str = include_str!("fixtures/sample-codex-python.jsonl");
fn write_fixture(dir: &std::path::Path) -> PathBuf {
let path = dir.join("source.jsonl");
fs::write(&path, FIXTURE).unwrap();
path
}
fn load_source() -> (TempDir, Session) {
let temp = TempDir::new().unwrap();
let path = write_fixture(temp.path());
let session = RolloutReader::read_session(&path).expect("parse fixture");
(temp, session)
}
fn roundtrip(source: &Session) -> (ConversationView, Session, Path) {
let view_forward: ConversationView = to_view(source);
let path = derive_path(&view_forward, &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 reparsed = back.into_single_path().expect("single path");
let view_back = extract_conversation(&reparsed);
let cwd = source
.meta()
.map(|m| m.cwd.to_string_lossy().to_string())
.unwrap_or_default();
let projector = CodexProjector::new().with_cwd(cwd);
let rebuilt = projector.project(&view_back).expect("project");
(view_back, rebuilt, reparsed)
}
#[test]
fn roundtrip_preserves_session_id() {
let (_t, source) = load_source();
let (_, rebuilt, _) = roundtrip(&source);
assert_eq!(rebuilt.id, source.id);
}
#[test]
fn rebuilt_has_session_meta_first() {
let (_t, source) = load_source();
let (_, rebuilt, _) = roundtrip(&source);
let first = rebuilt.lines.first().expect("at least one line");
assert_eq!(first.kind, "session_meta");
assert_eq!(
first.payload["id"].as_str(),
Some(source.id.as_str()),
"session_meta payload must carry the source's session id"
);
}
#[test]
fn rebuilt_has_turn_context_after_session_meta() {
let (_t, source) = load_source();
let (_, rebuilt, _) = roundtrip(&source);
assert!(rebuilt.lines.len() >= 2, "need session_meta + turn_context");
assert_eq!(rebuilt.lines[1].kind, "turn_context");
assert_eq!(
rebuilt.lines[1].payload["cwd"].as_str(),
source
.meta()
.as_ref()
.map(|m| m.cwd.to_string_lossy().to_string())
.as_deref()
);
}
#[test]
fn roundtrip_preserves_user_assistant_message_count() {
let (_t, source) = load_source();
let (_, rebuilt, _) = roundtrip(&source);
let count_messages = |s: &Session, role: &str| -> usize {
s.lines
.iter()
.filter_map(|l| match l.item() {
RolloutItem::ResponseItem(ResponseItem::Message(m)) if m.role == role => Some(()),
_ => None,
})
.count()
};
let src_user = count_messages(&source, "user");
let rb_user = count_messages(&rebuilt, "user");
assert_eq!(rb_user, src_user, "user message count");
let src_asst = count_messages(&source, "assistant");
let rb_asst = count_messages(&rebuilt, "assistant");
assert!(
rb_asst >= src_asst.saturating_sub(1),
"assistant message count: rebuilt={}, source={}",
rb_asst,
src_asst
);
}
#[test]
fn roundtrip_preserves_function_call_arguments_and_outputs() {
let (_t, source) = load_source();
let (_, rebuilt, _) = roundtrip(&source);
fn fc_pairs(s: &Session) -> Vec<(String, String, String)> {
let mut pairs: std::collections::HashMap<String, (String, String, String)> =
Default::default();
for line in &s.lines {
match line.item() {
RolloutItem::ResponseItem(ResponseItem::FunctionCall(fc)) => {
pairs
.entry(fc.call_id.clone())
.or_insert((fc.call_id.clone(), fc.name.clone(), String::new()))
.1 = fc.name.clone();
}
RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput(fco)) => {
pairs
.entry(fco.call_id.clone())
.or_insert((fco.call_id.clone(), String::new(), String::new()))
.2 = fco.output.clone();
}
_ => {}
}
}
let mut v: Vec<_> = pairs.into_values().collect();
v.sort();
v
}
let src = fc_pairs(&source);
let rb = fc_pairs(&rebuilt);
assert_eq!(rb.len(), src.len(), "function-call count mismatch");
for (s, r) in src.iter().zip(rb.iter()) {
assert_eq!(s.0, r.0, "call_id");
assert_eq!(s.1, r.1, "name for {}", s.0);
assert!(
r.2.contains(&s.2) || s.2.contains(&r.2),
"output for {} diverged: src={:?} rb={:?}",
s.0,
s.2,
r.2
);
}
}
#[test]
fn roundtrip_preserves_custom_tool_call_inputs() {
let (_t, source) = load_source();
let (_, rebuilt, _) = roundtrip(&source);
fn ctc_inputs(s: &Session) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = s
.lines
.iter()
.filter_map(|l| match l.item() {
RolloutItem::ResponseItem(ResponseItem::CustomToolCall(c)) => {
Some((c.call_id.clone(), c.input.clone()))
}
_ => None,
})
.collect();
out.sort();
out
}
let src = ctc_inputs(&source);
let rb = ctc_inputs(&rebuilt);
assert_eq!(rb.len(), src.len(), "custom-tool-call count mismatch");
for (s, r) in src.iter().zip(rb.iter()) {
assert_eq!(s.0, r.0, "call_id");
assert_eq!(s.1, r.1, "input for {}", s.0);
}
}
#[test]
fn projected_jsonl_reparses_through_codex_reader() {
let (_t, source) = load_source();
let (_, rebuilt, _) = roundtrip(&source);
let temp = TempDir::new().unwrap();
let out_path = temp.path().join("rebuilt.jsonl");
let mut lines: Vec<String> = Vec::with_capacity(rebuilt.lines.len());
for line in &rebuilt.lines {
lines.push(serde_json::to_string(line).unwrap());
}
fs::write(&out_path, lines.join("\n")).unwrap();
let reread = RolloutReader::read_session(&out_path).expect("Codex reader accepts our output");
assert_eq!(reread.id, rebuilt.id);
assert_eq!(reread.lines.len(), rebuilt.lines.len());
}