use std::path::{Path, PathBuf};
use supercode_harness::session::{Session, SessionFormat};
use supercode_harness::{ChatMessage, FunctionCall, Role, ToolCall};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
fn msg_eq(a: &ChatMessage, b: &ChatMessage) -> bool {
if a.role != b.role || a.content != b.content || a.tool_call_id != b.tool_call_id {
return false;
}
let (ca, cb) = (a.tool_calls(), b.tool_calls());
if ca.len() != cb.len() {
return false;
}
ca.iter().zip(cb).all(|(x, y)| {
x.id == y.id
&& x.function.name == y.function.name
&& x.function.parsed_arguments().ok() == y.function.parsed_arguments().ok()
})
}
fn assert_messages_eq(label: &str, a: &[ChatMessage], b: &[ChatMessage]) {
assert_eq!(
a.len(),
b.len(),
"{label}: message count changed\n before: {a:#?}\n after: {b:#?}"
);
for (i, (x, y)) in a.iter().zip(b).enumerate() {
assert!(
msg_eq(x, y),
"{label}: message {i} differs:\n before: {x:?}\n after: {y:?}"
);
}
}
fn non_system(messages: &[ChatMessage]) -> Vec<ChatMessage> {
messages
.iter()
.filter(|m| m.role != Role::System)
.cloned()
.collect()
}
fn non_reasoning_only(messages: &[ChatMessage]) -> Vec<ChatMessage> {
messages
.iter()
.filter(|m| {
!(m.role == Role::Assistant
&& m.content.is_none()
&& m.content_parts.is_none()
&& m.tool_calls().is_empty()
&& (m.metadata.contains_key("thinking")
|| m.metadata.contains_key("redacted_thinking")))
})
.cloned()
.collect()
}
#[test]
fn fixtures_exercise_the_hard_shapes() {
let cc = Session::from_claude_code(fixture("claude_code_session.jsonl")).unwrap();
assert!(
cc.messages.len() >= 4,
"claude fixture hollowed out: only {} messages (expected >= 4)",
cc.messages.len()
);
assert!(
cc.messages.iter().any(|m| m.role == Role::User),
"claude fixture must contain at least one User message"
);
assert!(
cc.messages
.iter()
.any(|m| m.role == Role::Assistant && !m.tool_calls().is_empty()),
"claude fixture must contain at least one Assistant message with a tool call"
);
assert!(
cc.messages
.iter()
.any(|m| m.role == Role::Tool
&& m.tool_call_id.as_deref().is_some_and(|s| !s.is_empty())),
"claude fixture must contain at least one Tool message with a non-empty tool_call_id"
);
let cx = Session::from_codex(fixture("codex_session.jsonl")).unwrap();
assert!(
cx.messages.len() >= 6,
"codex fixture hollowed out: only {} messages (expected >= 6)",
cx.messages.len()
);
let assistant_tool_calls = cx
.messages
.iter()
.filter(|m| m.role == Role::Assistant && !m.tool_calls().is_empty())
.count();
assert!(
assistant_tool_calls >= 2,
"codex fixture must contain >= 2 assistant tool calls, found {assistant_tool_calls}"
);
let tool_results = cx.messages.iter().filter(|m| m.role == Role::Tool).count();
assert!(
tool_results >= 2,
"codex fixture must contain >= 2 tool-result messages, found {tool_results}"
);
assert!(
cx.meta.system_prompt.is_some(),
"codex fixture must carry a base-instructions system prompt"
);
assert!(
cx.meta.session_id.is_some(),
"codex fixture must carry a session id"
);
}
#[test]
fn claude_load_save_load_semantic_roundtrip() {
let original = Session::from_claude_code(fixture("claude_code_session.jsonl")).unwrap();
let jsonl = original.to_jsonl(SessionFormat::ClaudeCode).unwrap();
let reloaded = Session::from_claude_code_str(&jsonl).unwrap();
assert_messages_eq("claude round-trip", &original.messages, &reloaded.messages);
assert_eq!(original.meta.session_id, reloaded.meta.session_id);
assert_eq!(original.meta.model, reloaded.meta.model);
assert_eq!(original.meta.cwd, reloaded.meta.cwd);
}
#[test]
fn codex_load_save_load_semantic_roundtrip() {
let original = Session::from_codex(fixture("codex_session.jsonl")).unwrap();
let jsonl = original.to_jsonl(SessionFormat::Codex).unwrap();
let reloaded = Session::from_codex_str(&jsonl).unwrap();
assert_messages_eq("codex round-trip", &original.messages, &reloaded.messages);
assert_eq!(original.meta.session_id, reloaded.meta.session_id);
assert_eq!(original.meta.model, reloaded.meta.model);
assert_eq!(original.meta.cwd, reloaded.meta.cwd);
assert_eq!(original.meta.system_prompt, reloaded.meta.system_prompt);
}
#[test]
fn claude_to_codex_reload_preserves_conversation() {
let claude = Session::from_claude_code(fixture("claude_code_session.jsonl")).unwrap();
let as_codex = claude.to_jsonl(SessionFormat::Codex).unwrap();
let reloaded = Session::from_codex_str(&as_codex).unwrap();
let before = non_reasoning_only(&non_system(&claude.messages));
let after = non_reasoning_only(&non_system(&reloaded.messages));
assert_messages_eq("claude->codex cross-format", &before, &after);
}
#[test]
fn claude_to_codex_does_not_merge_two_adjacent_but_separate_assistant_turns() {
let claude_jsonl = r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"demo","message":{"role":"user","content":"look into it"}}
{"type":"assistant","uuid":"a1","parentUuid":"u1","sessionId":"demo","message":{"role":"assistant","content":[{"type":"text","text":"Let me check that file."}]}}
{"type":"assistant","uuid":"a2","parentUuid":"a1","sessionId":"demo","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/tmp/x.txt"}}]}}
{"type":"user","uuid":"u2","parentUuid":"a2","sessionId":"demo","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"file contents"}]}}"#;
let claude = Session::from_claude_code_str(claude_jsonl).unwrap();
let source_assistants: Vec<_> = claude
.messages
.iter()
.filter(|m| m.role == Role::Assistant)
.collect();
assert_eq!(
source_assistants.len(),
2,
"fixture must load as two separate assistant messages: {:#?}",
claude.messages
);
assert_eq!(
source_assistants[0].content.as_deref(),
Some("Let me check that file.")
);
assert!(source_assistants[0].tool_calls().is_empty());
assert!(source_assistants[1].content.is_none());
assert_eq!(source_assistants[1].tool_calls().len(), 1);
let as_codex = claude.to_jsonl(SessionFormat::Codex).unwrap();
let reloaded = Session::from_codex_str(&as_codex).unwrap();
let reloaded_assistants: Vec<_> = reloaded
.messages
.iter()
.filter(|m| m.role == Role::Assistant)
.collect();
assert_eq!(
reloaded_assistants.len(),
2,
"two originally-separate assistant turns must NOT merge into one \
across a Claude -> Codex -> reload hop: {:#?}",
reloaded.messages
);
assert_eq!(
reloaded_assistants[0].content.as_deref(),
Some("Let me check that file.")
);
assert!(
reloaded_assistants[0].tool_calls().is_empty(),
"the tool call belongs to the SECOND original message, not the first: {:#?}",
reloaded_assistants[0]
);
assert!(reloaded_assistants[1].content.is_none());
assert_eq!(reloaded_assistants[1].tool_calls().len(), 1);
assert_eq!(reloaded_assistants[1].tool_calls()[0].function.name, "Read");
assert_eq!(claude.messages.len(), 4);
assert_eq!(reloaded.messages.len(), 4);
}
#[test]
fn codex_native_turn_id_reuse_across_a_stripped_merge_boundary_does_not_recombine() {
let codex_jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"compacted","payload":{"replacement_history":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Working on it."}],"metadata":{"turn_id":"turn-7"}}]}}
{"timestamp":"2024-01-01T00:00:01Z","type":"response_item","payload":{"type":"function_call","name":"Read","arguments":"{}","call_id":"call_1","metadata":{"turn_id":"turn-7"}}}
{"timestamp":"2024-01-01T00:00:02Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"file contents"}}"#;
let codex = Session::from_codex_str(codex_jsonl).unwrap();
let source_assistants: Vec<_> = codex
.messages
.iter()
.filter(|m| m.role == Role::Assistant)
.collect();
assert_eq!(
source_assistants.len(),
2,
"fixture must load as two separate assistant messages sharing turn_id \
`turn-7`: {:#?}",
codex.messages
);
assert_eq!(
source_assistants[0]
.metadata
.get("turn_id")
.map(String::as_str),
Some("turn-7")
);
assert_eq!(
source_assistants[1]
.metadata
.get("turn_id")
.map(String::as_str),
Some("turn-7"),
"both original messages must carry the SAME real turn_id for this to \
be the scenario under test"
);
let as_codex = codex.to_jsonl(SessionFormat::Codex).unwrap();
let reloaded = Session::from_codex_str(&as_codex).unwrap();
let reloaded_assistants: Vec<_> = reloaded
.messages
.iter()
.filter(|m| m.role == Role::Assistant)
.collect();
assert_eq!(
reloaded_assistants.len(),
2,
"two originally-separate assistant turns that merely SHARE a real \
turn_id (across a stripped merge boundary) must NOT recombine into \
one on a Codex -> Codex full-synthesis round-trip: {:#?}",
reloaded.messages
);
assert_eq!(
reloaded_assistants[0].content.as_deref(),
Some("Working on it.")
);
assert!(
reloaded_assistants[0].tool_calls().is_empty(),
"the tool call belongs to the SECOND original message, not the first: {:#?}",
reloaded_assistants[0]
);
assert!(reloaded_assistants[1].content.is_none());
assert_eq!(reloaded_assistants[1].tool_calls().len(), 1);
assert_eq!(reloaded_assistants[1].tool_calls()[0].function.name, "Read");
}
#[test]
fn fabricated_group_id_does_not_collide_with_a_reused_real_one() {
let mut a = ChatMessage::assistant("Let me check that file.".to_string());
a.metadata
.insert("turn_id".to_string(), "sc-grp-0".to_string());
let mut b = ChatMessage::assistant(String::new());
b.content = None;
b.tool_calls = Some(vec![ToolCall {
id: "call_b".to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "Read".to_string(),
arguments: "{}".to_string(),
},
}]);
let mut session = Session::from_claude_code_str("").unwrap();
session.messages = vec![a, b];
let as_codex = session.to_jsonl(SessionFormat::Codex).unwrap();
let reloaded = Session::from_codex_str(&as_codex).unwrap();
let reloaded_assistants: Vec<_> = reloaded
.messages
.iter()
.filter(|m| m.role == Role::Assistant)
.collect();
assert_eq!(
reloaded_assistants.len(),
2,
"msg A's reused real turn_id and msg B's freshly-fabricated one must \
not collide and recombine the two: {:#?}",
reloaded.messages
);
assert!(reloaded_assistants[0].tool_calls().is_empty());
assert_eq!(reloaded_assistants[1].tool_calls().len(), 1);
assert_eq!(reloaded_assistants[1].tool_calls()[0].function.name, "Read");
}
#[test]
fn spliced_export_tail_group_id_does_not_collide_with_replayed_prefix() {
let codex_jsonl = r#"{"timestamp":"2024-01-01T00:00:00Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Let me check that file."}],"metadata":{"turn_id":"sc-grp-0"}}}
{"timestamp":"2024-01-01T00:00:01Z","type":"response_item","payload":{"type":"function_call","name":"Read","arguments":"{}","call_id":"call_a","metadata":{"turn_id":"sc-grp-0"}}}"#;
let codex = Session::from_codex_str(codex_jsonl).unwrap();
assert_eq!(
codex.messages.len(),
2,
"fixture must load as ONE merged (message+call) assistant turn plus \
the synthesized interrupted-tool-result placeholder: {:#?}",
codex.messages
);
assert_eq!(codex.messages[0].role, Role::Assistant);
assert_eq!(codex.messages[0].tool_calls().len(), 1);
assert_eq!(
codex.messages[0]
.metadata
.get("turn_id")
.map(String::as_str),
Some("sc-grp-0")
);
assert_eq!(codex.messages[1].role, Role::Tool);
let mut appended = ChatMessage::assistant(String::new());
appended.content = None;
appended.tool_calls = Some(vec![ToolCall {
id: "call_b".to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "Read".to_string(),
arguments: "{}".to_string(),
},
}]);
let native = codex.to_native_jsonl_v2(std::slice::from_ref(&appended));
let spliced_session = Session::from_native_str(&native).unwrap();
assert_eq!(
spliced_session.messages.len(),
3,
"sanity: prefix's merged message + its interrupted-tool placeholder \
+ the freshly appended tail message"
);
let exported = spliced_session
.to_jsonl_spliced(SessionFormat::Codex, None)
.unwrap();
let reloaded = Session::from_codex_str(&exported).unwrap();
let reloaded_assistants: Vec<_> = reloaded
.messages
.iter()
.filter(|m| m.role == Role::Assistant)
.collect();
assert_eq!(
reloaded_assistants.len(),
2,
"the appended tail call must get a group id that does NOT collide \
with the still-open prefix call's `sc-grp-0` — a collision here \
would wrongly splice the unrelated appended message into the \
historical prefix message on reimport (2 -> 1): {:#?}",
reloaded.messages
);
assert_eq!(reloaded_assistants[0].tool_calls().len(), 1);
assert_eq!(reloaded_assistants[0].tool_calls()[0].id, "call_a");
assert_eq!(reloaded_assistants[1].tool_calls().len(), 1);
assert_eq!(reloaded_assistants[1].tool_calls()[0].id, "call_b");
}
#[test]
fn codex_to_claude_reload_preserves_conversation() {
let codex = Session::from_codex(fixture("codex_session.jsonl")).unwrap();
let as_cc = codex.to_jsonl(SessionFormat::ClaudeCode).unwrap();
let reloaded = Session::from_claude_code_str(&as_cc).unwrap();
let before = non_system(&codex.messages);
let after = non_system(&reloaded.messages);
assert_messages_eq("codex->claude cross-format", &before, &after);
}
#[test]
fn exported_jsonl_is_parseable_line_by_line() {
let claude = Session::from_claude_code(fixture("claude_code_session.jsonl")).unwrap();
let codex = Session::from_codex(fixture("codex_session.jsonl")).unwrap();
let cases: [(&str, &Session, SessionFormat); 4] = [
(
"claude fixture as ClaudeCode",
&claude,
SessionFormat::ClaudeCode,
),
("claude fixture as Codex", &claude, SessionFormat::Codex),
(
"codex fixture as ClaudeCode",
&codex,
SessionFormat::ClaudeCode,
),
("codex fixture as Codex", &codex, SessionFormat::Codex),
];
for (label, session, format) in cases {
let jsonl = session.to_jsonl(format).unwrap();
let mut lines_seen = 0usize;
for line in jsonl.lines().filter(|l| !l.trim().is_empty()) {
lines_seen += 1;
let v: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("{label}: line is not valid JSON ({e}): {line}"));
match format {
SessionFormat::ClaudeCode => {
assert!(
v.get("type").is_some(),
"{label}: Claude Code line missing top-level `type`: {line}"
);
assert!(
v.get("payload").is_none(),
"{label}: Claude Code line must not carry a `payload` envelope: {line}"
);
}
SessionFormat::Codex => {
assert!(
v.get("payload").is_some(),
"{label}: Codex line missing `payload` envelope: {line}"
);
}
SessionFormat::Gemini
| SessionFormat::Grok
| SessionFormat::Goose
| SessionFormat::OpenCode
| SessionFormat::Pi => {}
}
}
assert!(lines_seen > 0, "{label}: export produced no lines at all");
}
}
#[test]
fn spliced_export_tail_uuid_does_not_collide_across_an_export_reimport_append_chain() {
let claude_jsonl = r#"{"parentUuid":null,"type":"user","message":{"role":"user","content":"hello"},"uuid":"11111111-1111-4111-8111-111111111111","sessionId":"sess-1","timestamp":"2024-01-01T00:00:00Z"}
{"parentUuid":"11111111-1111-4111-8111-111111111111","type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]},"uuid":"22222222-2222-4222-8222-222222222222","sessionId":"sess-1","timestamp":"2024-01-01T00:00:01Z"}"#;
let base = Session::from_claude_code_str(claude_jsonl).unwrap();
let appended_1 = ChatMessage::assistant("first appended reply".to_string());
let native_1 = base.to_native_jsonl_v2(std::slice::from_ref(&appended_1));
let spliced_1 = Session::from_native_str(&native_1).unwrap();
let exported_1 = spliced_1
.to_jsonl_spliced(SessionFormat::ClaudeCode, None)
.unwrap();
let reloaded_1 = Session::from_claude_code_str(&exported_1).unwrap();
let appended_2a = ChatMessage::assistant("second appended reply, part 1".to_string());
let appended_2b = ChatMessage::assistant("second appended reply, part 2".to_string());
let native_2 = reloaded_1.to_native_jsonl_v2(&[appended_2a, appended_2b]);
let spliced_2 = Session::from_native_str(&native_2).unwrap();
let exported_2 = spliced_2
.to_jsonl_spliced(SessionFormat::ClaudeCode, None)
.unwrap();
let mut seen = std::collections::HashMap::<String, usize>::new();
let mut ordered_uuids = Vec::new();
for line in exported_2.lines().filter(|l| !l.trim().is_empty()) {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
if let Some(uuid) = v.get("uuid").and_then(|u| u.as_str()) {
*seen.entry(uuid.to_string()).or_insert(0) += 1;
ordered_uuids.push((uuid.to_string(), v.get("parentUuid").cloned()));
}
}
let dupes: Vec<_> = seen.iter().filter(|(_, &n)| n > 1).collect();
assert!(
dupes.is_empty(),
"every uuid across the prefix + both synthesized tails must be \
distinct — a chain of export -> reimport -> append -> export again \
must not re-fabricate a uuid the prior export's tail already used: \
duplicates = {dupes:?}, full export = {exported_2}"
);
assert_eq!(
ordered_uuids.len(),
5,
"expected exactly 5 uuid-carrying records: original user+assistant \
prefix (2), first tail (1 message), second tail (2 messages): {ordered_uuids:?}"
);
let (second_tail_first_uuid, _) = &ordered_uuids[3];
let (_, second_tail_second_parent) = &ordered_uuids[4];
assert_eq!(
second_tail_second_parent
.as_ref()
.and_then(|p| p.as_str())
.map(str::to_string),
Some(second_tail_first_uuid.clone()),
"the second tail's own two messages must still chain to each other \
via parentUuid, using the actual (possibly skipped) minted uuid: \
{ordered_uuids:?}"
);
}