use std::path::{Path, PathBuf};
use supercode::reduce::{export_session, REDUCTION_SENTINEL};
use supercode::session::{Session, SessionFormat};
use supercode::{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()
}
fn assert_well_formed(label: &str, jsonl: &str, format: SessionFormat) {
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::OpenCode | SessionFormat::Pi | SessionFormat::Grok => {}
}
}
assert!(lines_seen > 0, "{label}: export produced no lines at all");
}
fn plain_message(role: Role, content: &str) -> ChatMessage {
ChatMessage {
role,
content: Some(content.to_string()),
content_parts: None,
tool_calls: None,
tool_call_id: None,
name: None,
metadata: Default::default(),
}
}
fn two_synthetic_turns() -> Vec<ChatMessage> {
vec![
plain_message(Role::User, "Appended turn: what is 2+2?"),
plain_message(Role::Assistant, "4."),
]
}
#[test]
fn export_ignores_reduction_and_leaks_nothing() {
let original = Session::from_claude_code(fixture("claude_code_session.jsonl")).unwrap();
let appended = two_synthetic_turns();
let sidecar = original.to_native_jsonl_v2(&appended);
let mut full_messages = original.messages.clone();
full_messages.extend(appended.iter().cloned());
for format in [SessionFormat::ClaudeCode, SessionFormat::Codex] {
let exported = export_session(&sidecar, format)
.unwrap_or_else(|e| panic!("export_session({format:?}) failed: {e}"));
assert!(
!exported.contains(REDUCTION_SENTINEL),
"export_session({format:?}) leaked the reduction sentinel:\n{exported}"
);
let reloaded = Session::load_str(&exported, format)
.unwrap_or_else(|e| panic!("reloading export_session({format:?}) output failed: {e}"));
let (expected, actual) = match format {
SessionFormat::Codex => (
non_reasoning_only(&non_system(&full_messages)),
non_reasoning_only(&non_system(&reloaded.messages)),
),
_ => (non_system(&full_messages), non_system(&reloaded.messages)),
};
assert_messages_eq(
&format!("export_session({format:?}) round-trip"),
&expected,
&actual,
);
assert_well_formed(
&format!("export_session({format:?}) output"),
&exported,
format,
);
}
}
#[test]
fn export_session_allows_sentinel_mentions_but_rejects_exact_stubs() {
let original = Session::from_claude_code(fixture("claude_code_session.jsonl")).unwrap();
let genuine_but_sentinel_shaped = plain_message(
Role::User,
"please literally output [sc-reduced tool-output r0001-aaaa: not a real reduction]",
);
let sidecar = original.to_native_jsonl_v2(&[genuine_but_sentinel_shaped]);
for format in [SessionFormat::ClaudeCode, SessionFormat::Codex] {
let exported = export_session(&sidecar, format)
.unwrap_or_else(|e| panic!("sentinel mention must remain exportable: {e}"));
assert!(exported.contains("please literally output [sc-reduced"));
}
let exact_stub = plain_message(
Role::Tool,
"[sc-reduced tool-output r0001-aaaa: full output in session sidecar]",
);
let poisoned = original.to_native_jsonl_v2(&[exact_stub]);
for format in [SessionFormat::ClaudeCode, SessionFormat::Codex] {
let result = export_session(&poisoned, format);
assert!(result.is_err(), "an exact reduction stub must fail closed");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains(REDUCTION_SENTINEL),
"error message should name the sentinel it found: {msg:?}"
);
}
let truncated_stub = plain_message(
Role::Tool,
"visible output prefix\n\n[sc-reduced tool-output r0003-cccc: full output in session sidecar]",
);
let poisoned_truncated = original.to_native_jsonl_v2(&[truncated_stub]);
assert!(
export_session(&poisoned_truncated, SessionFormat::Codex).is_err(),
"a reduction stub after a retained output prefix must fail closed"
);
let tool_input_stub = ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(vec![ToolCall {
id: "call-stub".to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "write_file".to_string(),
arguments: serde_json::json!({
"path": "out.txt",
"content": "[sc-reduced tool-input r0002-bbbb: original field in session sidecar]"
})
.to_string(),
},
}]),
tool_call_id: None,
name: None,
metadata: Default::default(),
};
let poisoned_args = original.to_native_jsonl_v2(&[tool_input_stub]);
assert!(
export_session(&poisoned_args, SessionFormat::Codex).is_err(),
"a grammar-valid reduction stub nested in tool arguments must fail closed"
);
}
fn tool_search_pair() -> Vec<ChatMessage> {
let call = ToolCall {
id: "call_search_1".to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "tool_search".to_string(),
arguments: serde_json::json!({"query": "patch", "max_results": 5}).to_string(),
},
};
let assistant = ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(vec![call]),
tool_call_id: None,
name: None,
metadata: Default::default(),
};
let result = ChatMessage {
role: Role::Tool,
content: Some(serde_json::json!(["apply_patch"]).to_string()),
content_parts: None,
tool_calls: None,
tool_call_id: Some("call_search_1".to_string()),
name: None,
metadata: Default::default(),
};
vec![assistant, result]
}
#[test]
fn tool_search_pair_roundtrips_to_codex() {
let original = Session::from_claude_code(fixture("claude_code_session.jsonl")).unwrap();
let appended = tool_search_pair();
let sidecar = original.to_native_jsonl_v2(&appended);
let exported = export_session(&sidecar, SessionFormat::Codex).unwrap();
assert!(
exported.contains("\"tool_search_call\""),
"Codex export must contain a tool_search_call record:\n{exported}"
);
assert!(
exported.contains("\"tool_search_output\""),
"Codex export must contain a tool_search_output record:\n{exported}"
);
assert!(
!exported.contains("\"function_call\",\"name\":\"tool_search\"")
&& !exported.contains("\"name\":\"tool_search\",\"arguments\""),
"tool_search must not ALSO be emitted as a generic function_call:\n{exported}"
);
let reloaded = Session::from_codex_str(&exported).unwrap();
let call_msg = reloaded
.messages
.iter()
.find(|m| {
m.tool_calls()
.iter()
.any(|c| c.function.name == "tool_search")
})
.expect("reloaded Codex session must contain a tool_search ToolCall");
let call = call_msg
.tool_calls()
.iter()
.find(|c| c.function.name == "tool_search")
.unwrap()
.clone();
let result_msg = reloaded
.messages
.iter()
.find(|m| m.tool_call_id.as_deref() == Some(call.id.as_str()))
.expect("reloaded Codex session must contain the matching tool result");
assert_eq!(result_msg.role, Role::Tool);
let arr: Vec<String> = serde_json::from_str(result_msg.content.as_deref().unwrap_or(""))
.expect("tool_search result content should parse as a JSON array");
assert_eq!(arr, vec!["apply_patch".to_string()]);
let cc_exported = export_session(&sidecar, SessionFormat::ClaudeCode).unwrap();
assert!(
!cc_exported.contains(REDUCTION_SENTINEL),
"Claude Code export must not leak the sentinel either"
);
let cc_reloaded = Session::from_claude_code_str(&cc_exported).unwrap();
let cc_call = cc_reloaded
.messages
.iter()
.find_map(|m| {
m.tool_calls()
.iter()
.find(|c| c.function.name == "tool_search")
.cloned()
})
.expect("Claude Code reload must still carry the tool_search ToolCall");
let cc_result = cc_reloaded
.messages
.iter()
.find(|m| m.tool_call_id.as_deref() == Some(cc_call.id.as_str()))
.expect("Claude Code reload must carry the matching tool_result");
assert_eq!(cc_result.role, Role::Tool);
}
#[test]
fn tool_search_call_merges_with_preceding_text_on_codex_reload() {
let call = ToolCall {
id: "call_ts_1".to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "tool_search".to_string(),
arguments: serde_json::json!({"query": "patch"}).to_string(),
},
};
let assistant = ChatMessage {
role: Role::Assistant,
content: Some("Let me look for the right tool.".to_string()),
content_parts: None,
tool_calls: Some(vec![call]),
tool_call_id: None,
name: None,
metadata: Default::default(),
};
let original = Session::from_claude_code(fixture("claude_code_session.jsonl")).unwrap();
let sidecar = original.to_native_jsonl_v2(&[assistant]);
let exported = export_session(&sidecar, SessionFormat::Codex).unwrap();
let reloaded = Session::from_codex_str(&exported).unwrap();
let merged: Vec<_> = reloaded
.messages
.iter()
.filter(|m| {
m.role == Role::Assistant
&& m.content.as_deref() == Some("Let me look for the right tool.")
})
.collect();
assert_eq!(
merged.len(),
1,
"text+tool_search must reload as exactly ONE assistant message, not split: {:#?}",
reloaded.messages
);
assert_eq!(
merged[0].tool_calls().len(),
1,
"the tool_search call must be merged INTO the text message, not orphaned: {:#?}",
merged[0]
);
assert_eq!(merged[0].tool_calls()[0].function.name, "tool_search");
}
#[test]
fn two_tool_search_calls_merge_to_one_message_on_codex_reload() {
let calls = vec![
ToolCall {
id: "call_ts_a".to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "tool_search".to_string(),
arguments: serde_json::json!({"query": "a"}).to_string(),
},
},
ToolCall {
id: "call_ts_b".to_string(),
kind: "function".to_string(),
function: FunctionCall {
name: "tool_search".to_string(),
arguments: serde_json::json!({"query": "b"}).to_string(),
},
},
];
let assistant = ChatMessage {
role: Role::Assistant,
content: None,
content_parts: None,
tool_calls: Some(calls),
tool_call_id: None,
name: None,
metadata: Default::default(),
};
let original = Session::from_claude_code(fixture("claude_code_session.jsonl")).unwrap();
let sidecar = original.to_native_jsonl_v2(&[assistant]);
let exported = export_session(&sidecar, SessionFormat::Codex).unwrap();
let reloaded = Session::from_codex_str(&exported).unwrap();
let merged: Vec<_> = reloaded
.messages
.iter()
.filter(|m| {
m.tool_calls()
.iter()
.any(|c| c.function.name == "tool_search")
})
.collect();
assert_eq!(
merged.len(),
1,
"two tool_search blocks from the SAME original message must reload \
as ONE ChatMessage, not two: {:#?}",
reloaded.messages
);
assert_eq!(
merged[0].tool_calls().len(),
2,
"both tool_search calls must land on the same merged message: {:#?}",
merged[0]
);
}