use crate::config;
use crate::pricing::Provider;
use chrono::{Datelike, Local, SecondsFormat, Timelike, Utc};
use serde::ser::{Serialize, SerializeMap, Serializer};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Provenance {
pub harness: String,
pub session_id: String,
pub session_path: String,
pub converted_at: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Turn {
User(String),
Assistant(String),
Call {
id: String,
name: String,
input: String,
},
Result { id: String, output: String },
}
#[derive(Debug, Clone, Default)]
pub struct Transcript {
pub session_id: String,
pub cwd: String,
pub title: String,
pub branch: String,
pub model: String,
pub started_at: String,
pub source_harness: String,
pub source_path: String,
pub turns: Vec<Turn>,
}
#[derive(Debug, Clone)]
pub struct Converted {
pub provider: Provider,
pub session_id: String,
pub path: PathBuf,
pub id_kept: bool,
}
pub fn read_claude(path: &Path) -> Option<Transcript> {
let mut records: HashMap<String, Value> = HashMap::new();
let mut seen: HashMap<String, ()> = HashMap::new();
let mut leaf: Option<String> = None;
let mut leaf_at = i64::MIN;
let mut first_prompt = String::new();
let mut title = String::new();
for line in lines(path) {
let Ok(value) = line else { break };
let uuid = str_field(&value, "uuid").to_owned();
if uuid.is_empty() || seen.insert(uuid.clone(), ()).is_some() {
continue;
}
let kind = str_field(&value, "type");
let is_turn = matches!(kind, "user" | "assistant");
if is_turn && value.get("isSidechain").and_then(Value::as_bool) == Some(true) {
continue;
}
match str_field(&value, "type") {
"user" | "assistant" => {
let at = value
.get("timestamp")
.and_then(Value::as_str)
.and_then(crate::util::parse_ts)
.map(|d| d.timestamp_millis())
.unwrap_or(0);
if at >= leaf_at {
leaf_at = at;
leaf = Some(uuid.clone());
}
}
"last-prompt" => {
if let Some(id) = value.get("leafUuid").and_then(Value::as_str) {
leaf = Some(id.to_owned());
leaf_at = i64::MAX;
}
}
"ai-title" => {
if let Some(t) = value.get("aiTitle").and_then(Value::as_str)
&& !t.is_empty()
{
title = t.to_owned();
}
}
_ => {}
}
if first_prompt.is_empty()
&& kind == "user"
&& let Some(text) = plain_prompt(&value)
{
first_prompt = text;
}
records.insert(uuid, value);
}
let mut chain: Vec<&Value> = Vec::new();
let mut cursor = leaf;
while let Some(id) = cursor {
let Some(record) = records.get(&id) else {
break;
};
chain.push(record);
cursor = record
.get("parentUuid")
.and_then(Value::as_str)
.map(str::to_owned);
}
chain.reverse();
let (first, last) = (chain.first()?, chain.last()?);
let mut out = Transcript {
session_id: path.file_stem()?.to_str()?.to_owned(),
cwd: str_field(first, "cwd").to_owned(),
title: title.clone(),
branch: str_field(last, "gitBranch").to_owned(),
model: chain
.iter()
.rev()
.find_map(|r| r.pointer("/message/model"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
started_at: str_field(first, "timestamp").to_owned(),
source_harness: Provider::Claude.as_str().to_owned(),
source_path: path.to_string_lossy().into_owned(),
turns: Vec::new(),
};
for record in &chain {
let message = record.get("message");
match str_field(record, "type") {
"user" => user_turns(message, &mut out.turns),
"assistant" => assistant_turns(message, &mut out.turns),
_ => {}
}
}
if title.is_empty() {
out.title = first_prompt;
}
Some(out)
}
pub fn read_codex(path: &Path) -> Option<Transcript> {
let mut out = Transcript {
session_id: path
.file_stem()
.and_then(|s| s.to_str())
.and_then(config::trailing_uuid)
.unwrap_or_default()
.to_owned(),
source_harness: Provider::Codex.as_str().to_owned(),
source_path: path.to_string_lossy().into_owned(),
..Transcript::default()
};
let mut meta_id = String::new();
let mut last = String::new();
for line in lines(path) {
let Ok(value) = line else { break };
let Some(payload) = value.get("payload") else {
continue;
};
let at = value
.get("timestamp")
.and_then(Value::as_str)
.and_then(stamp_of);
let at = at.as_deref().unwrap_or_default();
match str_field(&value, "type") {
"session_meta" => {
if out.cwd.is_empty() {
out.cwd = str_field(payload, "cwd").to_owned();
}
if let Some(id) = payload.get("id").and_then(Value::as_str) {
meta_id = id.to_owned();
}
if out.started_at.is_empty() {
out.started_at = str_field(payload, "timestamp").to_owned();
}
}
"turn_context" => {
if let Some(model) = payload.get("model").and_then(Value::as_str) {
out.model = model.to_owned();
}
}
"response_item" => {
let before = out.turns.len();
codex_item(payload, at, &mut out);
if out.turns.len() > before && !at.is_empty() {
last = at.to_owned();
}
}
_ => {}
}
}
if out.session_id.is_empty() {
out.session_id = meta_id;
}
if out.started_at.is_empty() {
out.started_at = last.clone();
}
if out.title.is_empty() {
out.title = out
.turns
.iter()
.find_map(|t| match t {
Turn::User(text) => Some(text.clone()),
_ => None,
})
.unwrap_or_default();
}
if out.turns.is_empty() {
return None;
}
Some(out)
}
fn codex_item(payload: &Value, at: &str, out: &mut Transcript) {
let kind = str_field(payload, "type");
match kind {
"message" => {
let text = content_text(payload.get("content"));
if text.trim().is_empty() {
return;
}
match str_field(payload, "role") {
"user" => {
if out.started_at.is_empty() && !at.is_empty() {
out.started_at = at.to_owned();
}
out.turns.push(Turn::User(text));
}
"assistant" => out.turns.push(Turn::Assistant(text)),
_ => {}
}
}
"function_call" | "custom_tool_call" => {
let (Some(id), false) = (
payload.get("call_id").and_then(Value::as_str),
str_field(payload, "name").is_empty(),
) else {
return;
};
let input = payload
.get("arguments")
.or_else(|| payload.get("input"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
out.turns.push(Turn::Call {
id: id.to_owned(),
name: str_field(payload, "name").to_owned(),
input,
});
}
"function_call_output" | "custom_tool_call_output" => {
let Some(id) = payload.get("call_id").and_then(Value::as_str) else {
return;
};
out.turns.push(Turn::Result {
id: id.to_owned(),
output: content_text(payload.get("output")),
});
}
_ => {}
}
}
pub fn write_claude(transcript: &Transcript, home: &Path) -> std::io::Result<Converted> {
let cwd = working_dir(transcript)?;
let dir = home.join("projects").join(project_slug(&cwd));
std::fs::create_dir_all(&dir)?;
let (id, id_kept) = free_id_claude(home, transcript.session_id.as_str());
let path = dir.join(format!("{id}.jsonl"));
let provenance = provenance_of(transcript);
let mut out = BufWriter::new(std::fs::File::create(&path)?);
writeln!(out, "{}", line_of(marker(&provenance, &id)))?;
let mut parent: Option<String> = None;
let mut at = first_instant(&transcript.started_at);
let mut leaf: Option<String> = None;
let mut first_prompt = String::new();
let mut message = 0u32;
for turn in &transcript.turns {
let (kind, body) = match turn {
Turn::User(text) => {
if first_prompt.is_empty() {
first_prompt = text.clone();
}
message += 1;
("user", json!({ "role": "user", "content": text }))
}
Turn::Assistant(text) => {
message += 1;
(
"assistant",
json!({
"model": transcript.model,
"id": message_id(&id, message),
"type": "message",
"role": "assistant",
"content": [{ "type": "text", "text": text }],
"stop_reason": "end_turn",
}),
)
}
Turn::Call {
id: call,
name,
input,
} => {
message += 1;
(
"assistant",
json!({
"model": transcript.model,
"id": message_id(call, message),
"type": "message",
"role": "assistant",
"content": [{
"type": "tool_use",
"id": call,
"name": name,
"input": as_input(input),
}],
"stop_reason": "tool_use",
}),
)
}
Turn::Result { id: call, output } => (
"user",
json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": call,
"content": output,
}],
}),
),
};
let uuid = new_uuid();
writeln!(
out,
"{}",
line_of(json!({
"parentUuid": parent,
"isSidechain": false,
"type": kind,
"message": body,
"uuid": uuid,
"timestamp": at,
"userType": "external",
"entrypoint": "cli",
"cwd": cwd.to_string_lossy(),
"sessionId": id,
"gitBranch": transcript.branch,
}))
)?;
leaf = Some(uuid);
parent = leaf.clone();
at = next_instant(&at);
}
if let Some(leaf) = &leaf {
writeln!(
out,
"{}",
line_of(json!({
"type": "last-prompt",
"leafUuid": leaf,
"lastPrompt": clip(&first_prompt, 200),
"sessionId": id,
}))
)?;
writeln!(
out,
"{}",
line_of(json!({
"type": "ai-title",
"aiTitle": clip(&title_of(transcript, &first_prompt), 120),
"sessionId": id,
}))
)?;
}
out.flush()?;
Ok(Converted {
provider: Provider::Claude,
session_id: id,
path,
id_kept,
})
}
pub fn project_slug(cwd: &Path) -> String {
const LIMIT: usize = 200;
let full: String = cwd.to_string_lossy().into_owned();
let plain: String = full
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
match plain.len() {
n if n <= LIMIT => plain,
_ => {
let cut: String = plain.chars().take(LIMIT).collect();
format!("{cut}-{}", base36(slug_hash(&full)))
}
}
}
fn slug_hash(s: &str) -> u32 {
let mut h: i32 = 0;
for c in s.encode_utf16() {
h = h.wrapping_shl(5).wrapping_sub(h).wrapping_add(c as i32);
}
h.unsigned_abs()
}
fn base36(mut n: u32) -> String {
const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
if n == 0 {
return "0".into();
}
let mut out = Vec::new();
while n > 0 {
out.push(DIGITS[(n % 36) as usize]);
n /= 36;
}
out.reverse();
String::from_utf8(out).unwrap_or_default()
}
pub fn write_codex(transcript: &Transcript, home: &Path) -> std::io::Result<Converted> {
let start = crate::util::parse_ts(&transcript.started_at).unwrap_or_else(Utc::now);
let local = start.with_timezone(&Local);
let dir = home
.join("sessions")
.join(format!("{:04}", local.year()))
.join(format!("{:02}", local.month()))
.join(format!("{:02}", local.day()));
std::fs::create_dir_all(&dir)?;
let (id, id_kept) = free_id_codex(home, transcript.session_id.as_str());
let path = dir.join(format!(
"rollout-{:04}-{:02}-{:02}T{:02}-{:02}-{:02}-{id}.jsonl",
local.year(),
local.month(),
local.day(),
local.hour(),
local.minute(),
local.second()
));
let provenance = provenance_of(transcript);
let cwd = working_dir(transcript)?;
let day = format!(
"{:04}-{:02}-{:02}",
local.year(),
local.month(),
local.day()
);
let here = cwd.to_string_lossy().into_owned();
let mut out = BufWriter::new(std::fs::File::create(&path)?);
writeln!(
out,
"{}",
line_of(json!({
"type": "session_meta",
"timestamp": start.to_rfc3339_opts(SecondsFormat::Millis, true),
"payload": {
"id": id,
"session_id": id,
"timestamp": start.to_rfc3339_opts(SecondsFormat::Millis, true),
"cwd": here,
"originator": "codex-tui",
"cli_version": CODEX_CLI_VERSION,
"source": "cli",
"thread_source": "user",
"model_provider": "openai",
"base_instructions": { "text": "" },
"cctop_conversion": {
"from": provenance.harness,
"sourceSessionId": provenance.session_id,
"sourcePath": provenance.session_path,
"convertedAt": provenance.converted_at,
},
},
}))
)?;
writeln!(
out,
"{}",
line_of(json!({
"type": "turn_context",
"timestamp": start.to_rfc3339_opts(SecondsFormat::Millis, true),
"payload": {
"turn_id": id,
"cwd": here,
"workspace_roots": [here],
"current_date": day,
"approval_policy": "on-request",
"sandbox_policy": { "type": "workspace-write", "network_access": false },
},
}))
)?;
let mut at = start;
for turn in &transcript.turns {
let payload = match turn {
Turn::User(text) => json!({
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": text }],
}),
Turn::Assistant(text) => json!({
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": text }],
}),
Turn::Call {
id: call,
name,
input,
} => json!({
"type": "function_call",
"name": name,
"arguments": input,
"call_id": call,
}),
Turn::Result { id: call, output } => json!({
"type": "function_call_output",
"call_id": call,
"output": output,
}),
};
writeln!(
out,
"{}",
line_of(json!({
"type": "response_item",
"timestamp": at.to_rfc3339_opts(SecondsFormat::Millis, true),
"payload": payload,
}))
)?;
at = at
.checked_add_signed(chrono::Duration::milliseconds(1))
.unwrap_or(start);
}
out.flush()?;
Ok(Converted {
provider: Provider::Codex,
session_id: id,
path,
id_kept,
})
}
const CODEX_CLI_VERSION: &str = "0.149.1";
pub fn provenance_of_file(path: &Path) -> Option<Provenance> {
let text = crate::util::read_head(path, 64 * 1024)?;
let value: Value = serde_json::from_str(text.lines().next()?).ok()?;
if str_field(&value, "type") != "cctop-conversion" {
return None;
}
read_provenance(&value, path)
}
pub fn provenance_of_codex(path: &Path) -> Option<Provenance> {
let text = crate::util::read_head(path, 64 * 1024)?;
let value: Value = serde_json::from_str(text.lines().next()?).ok()?;
if str_field(&value, "type") != "session_meta" {
return None;
}
let marker = value.pointer("/payload/cctop_conversion")?;
read_provenance(marker, path)
}
fn marker(provenance: &Provenance, new_id: &str) -> Value {
json!({
"type": "cctop-conversion",
"convertedFrom": provenance.harness,
"sourceSessionId": provenance.session_id,
"sourcePath": provenance.session_path,
"convertedTo": new_id,
"convertedAt": provenance.converted_at,
})
}
fn read_provenance(marker: &Value, path: &Path) -> Option<Provenance> {
let session_id = marker.get("sourceSessionId").and_then(Value::as_str)?;
let source_path = marker
.get("sourcePath")
.and_then(Value::as_str)
.filter(|p| Path::new(p).is_file())
.unwrap_or_default();
Some(Provenance {
harness: marker
.get("convertedFrom")
.or_else(|| marker.get("from"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
session_id: session_id.to_owned(),
session_path: if source_path.is_empty() {
path.to_string_lossy().into_owned()
} else {
source_path.to_owned()
},
converted_at: marker
.get("convertedAt")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned(),
})
}
fn provenance_of(transcript: &Transcript) -> Provenance {
Provenance {
harness: transcript.source_harness.clone(),
session_id: transcript.session_id.clone(),
session_path: transcript.source_path.clone(),
converted_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
}
}
pub fn convert(
source: Provider,
source_transcript: &Path,
target: Provider,
target_home: &Path,
) -> Option<Converted> {
match (source, target) {
(Provider::Claude, Provider::Codex) => {
write_codex(&read_claude(source_transcript)?, target_home).ok()
}
(Provider::Codex, Provider::Claude) => {
write_claude(&read_codex(source_transcript)?, target_home).ok()
}
_ => None,
}
}
pub fn convertible(source: Provider, target: Provider) -> bool {
matches!(
(source, target),
(Provider::Claude, Provider::Codex) | (Provider::Codex, Provider::Claude)
)
}
pub fn convertible_session(session: &crate::session::Session) -> bool {
if session.remote.is_some() || session.surface.is_desktop() {
return false;
}
matches!(session.provider, Provider::Claude | Provider::Codex) && session.data_file.is_some()
}
fn free_id_claude(home: &Path, wanted: &str) -> (String, bool) {
if config::is_full_uuid(wanted) && !claude_id_taken(home, wanted) {
return (wanted.to_owned(), true);
}
(new_uuid(), false)
}
fn claude_id_taken(home: &Path, id: &str) -> bool {
let projects = home.join("projects");
config::list_dir(&projects)
.iter()
.any(|project| projects.join(project).join(format!("{id}.jsonl")).is_file())
}
fn free_id_codex(home: &Path, wanted: &str) -> (String, bool) {
if config::is_full_uuid(wanted) && !codex_id_taken(home, wanted) {
return (wanted.to_owned(), true);
}
(new_uuid(), false)
}
fn codex_id_taken(home: &Path, id: &str) -> bool {
let sessions = home.join("sessions");
walk(&sessions, 0, &mut |path| {
path.file_stem()
.and_then(|n| n.to_str())
.and_then(config::trailing_uuid)
== Some(id)
})
}
fn walk(dir: &Path, depth: usize, each: &mut impl FnMut(&Path) -> bool) -> bool {
if depth > 8 || !dir.is_dir() {
return false;
}
for name in config::list_dir(dir) {
let path = dir.join(&name);
let hit = if path.is_dir() {
walk(&path, depth + 1, each)
} else {
each(&path)
};
if hit {
return true;
}
}
false
}
fn new_uuid() -> String {
let mut bytes = crate::util::random_bytes(16);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
struct Ordered(Value);
impl Serialize for Ordered {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match &self.0 {
Value::Object(map) => {
let mut out = s.serialize_map(Some(map.len()))?;
for (key, value) in map {
out.serialize_entry(key, value)?;
}
out.end()
}
other => other.serialize(s),
}
}
}
fn line_of(value: Value) -> String {
serde_json::to_string(&Ordered(value)).unwrap_or_else(|_| "{}".into())
}
fn lines(path: &Path) -> impl Iterator<Item = Result<Value, serde_json::Error>> {
let reader = std::fs::File::open(path).ok().map(BufReader::new);
Box::new(reader.into_iter().flat_map(|mut r| {
std::iter::from_fn(move || {
let mut line = String::new();
match r.read_line(&mut line) {
Ok(0) | Err(_) => None,
Ok(_) => Some(serde_json::from_str(&line)),
}
})
}))
}
fn str_field<'a>(value: &'a Value, key: &str) -> &'a str {
value.get(key).and_then(Value::as_str).unwrap_or_default()
}
fn plain_prompt(record: &Value) -> Option<String> {
let content = record.pointer("/message/content")?;
match content {
Value::String(text) => Some(text.clone()),
Value::Array(blocks) => {
let text: Vec<&str> = blocks
.iter()
.filter(|b| str_field(b, "type") == "text")
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect();
(!text.is_empty()).then(|| text.join("\n"))
}
_ => None,
}
}
fn user_turns(message: Option<&Value>, out: &mut Vec<Turn>) {
let Some(content) = message.and_then(|m| m.get("content")) else {
return;
};
match content {
Value::String(text) => {
if !text.trim().is_empty() {
out.push(Turn::User(text.clone()));
}
}
Value::Array(blocks) => {
for block in blocks {
match str_field(block, "type") {
"text" => {
if let Some(text) = block.get("text").and_then(Value::as_str)
&& !text.trim().is_empty()
{
out.push(Turn::User(text.to_owned()));
}
}
"tool_result" => {
let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else {
continue;
};
out.push(Turn::Result {
id: id.to_owned(),
output: content_text(block.get("content")),
});
}
_ => {}
}
}
}
_ => {}
}
}
fn assistant_turns(message: Option<&Value>, out: &mut Vec<Turn>) {
let Some(Value::Array(blocks)) = message.and_then(|m| m.get("content")) else {
return;
};
for block in blocks {
match str_field(block, "type") {
"text" => {
if let Some(text) = block.get("text").and_then(Value::as_str)
&& !text.trim().is_empty()
{
out.push(Turn::Assistant(text.to_owned()));
}
}
"tool_use" => {
let (Some(id), Some(name)) = (
block.get("id").and_then(Value::as_str),
block.get("name").and_then(Value::as_str),
) else {
continue;
};
out.push(Turn::Call {
id: id.to_owned(),
name: name.to_owned(),
input: block
.get("input")
.filter(|i| i.is_object())
.map_or_else(|| "{}".into(), ToString::to_string),
});
}
_ => {}
}
}
}
fn content_text(content: Option<&Value>) -> String {
match content {
Some(Value::Array(parts)) => parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join(""),
Some(Value::String(text)) => text.clone(),
_ => String::new(),
}
}
fn as_input(input: &str) -> Value {
serde_json::from_str::<Value>(input)
.ok()
.filter(Value::is_object)
.unwrap_or_else(|| json!({ "input": input }))
}
fn stamp_of(value: &str) -> Option<String> {
Some(crate::util::parse_ts(value)?.to_rfc3339_opts(SecondsFormat::Millis, true))
}
fn working_dir(transcript: &Transcript) -> std::io::Result<PathBuf> {
match transcript.cwd.is_empty() {
true => std::env::current_dir(),
false => Ok(PathBuf::from(&transcript.cwd)),
}
}
fn first_instant(value: &str) -> String {
crate::util::parse_ts(value)
.unwrap_or_else(Utc::now)
.to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn next_instant(at: &str) -> String {
crate::util::parse_ts(at)
.unwrap_or_else(Utc::now)
.checked_add_signed(chrono::Duration::milliseconds(1))
.unwrap_or_else(Utc::now)
.to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn title_of(transcript: &Transcript, fallback: &str) -> String {
let title = match transcript.title.trim().is_empty() {
true => fallback,
false => transcript.title.as_str(),
};
clip(title, 120)
}
fn clip(text: &str, max: usize) -> String {
match text.char_indices().nth(max) {
Some((at, _)) => format!("{}…", text[..at].trim_end()),
None => text.to_owned(),
}
}
fn message_id(seed: &str, nth: u32) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in seed.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("msg_{hash:016x}{nth:08x}")
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("cctop-convert-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_file(name: &str, body: &str) -> PathBuf {
let path = scratch(&format!("src-{name}")).join(name);
std::fs::write(&path, body).unwrap();
path
}
fn sample() -> Transcript {
Transcript {
session_id: "019f1075-3f22-7ad0-b496-73dcda6a7a25".into(),
cwd: "/tmp/proj".into(),
title: "a task".into(),
branch: "main".into(),
model: "some-model".into(),
started_at: "2026-09-28T00:00:00.000Z".into(),
source_harness: "codex".into(),
source_path: "/tmp/proj/rollout.jsonl".into(),
turns: vec![
Turn::User("hello".into()),
Turn::Call {
id: "call_abc".into(),
name: "shell".into(),
input: r#"{"cmd":"ls"}"#.into(),
},
Turn::Result {
id: "call_abc".into(),
output: "a\nb".into(),
},
Turn::Assistant("done".into()),
],
}
}
#[test]
fn the_slug_is_what_the_harness_writes() {
assert_eq!(
project_slug(Path::new("/home/flo/cctop")),
"-home-flo-cctop"
);
assert_eq!(project_slug(Path::new("/tmp")), "-tmp");
assert_eq!(
project_slug(Path::new("/home/flo/.claude/jobs/3a74/perm")),
"-home-flo--claude-jobs-3a74-perm"
);
}
#[test]
fn an_over_long_path_is_hashed_rather_than_shared() {
let a = project_slug(Path::new(&format!("/{}", "x".repeat(300))));
let b = project_slug(Path::new(&format!("/{}{}", "x".repeat(299), "y")));
assert_ne!(a, b);
assert!(a.len() > 201 && a.len() <= 200 + 1 + 7, "{}", a.len());
assert_eq!(&a[..200], &b[..200]);
}
#[test]
fn base36_is_the_digits_javascript_would_use() {
assert_eq!(base36(0), "0");
assert_eq!(base36(35), "z");
assert_eq!(base36(36), "10");
assert_eq!(base36(1_234_567), "qglj");
}
#[test]
fn a_claude_transcript_is_read_along_its_chain_not_its_file_order() {
let path = write_file(
"chain.jsonl",
&[
r#"{"type":"assistant","uuid":"b","parentUuid":"a","isSidechain":false,"timestamp":"2026-09-28T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"second"}]}}"#,
r#"{"type":"user","uuid":"a","parentUuid":null,"isSidechain":false,"timestamp":"2026-09-28T00:00:01.000Z","message":{"role":"user","content":"first"}}"#,
r#"{"type":"assistant","uuid":"side","parentUuid":null,"isSidechain":true,"timestamp":"2026-09-28T00:00:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"a subagent"}]}}"#,
]
.join("\n"),
);
let t = read_claude(&path).unwrap();
assert_eq!(
t.turns,
vec![Turn::User("first".into()), Turn::Assistant("second".into())]
);
}
#[test]
fn a_sidechain_record_is_left_out_of_the_conversation() {
let path = write_file(
"side.jsonl",
&[
r#"{"type":"user","uuid":"a","parentUuid":null,"isSidechain":false,"timestamp":"2026-09-28T00:00:01.000Z","message":{"role":"user","content":"first"}}"#,
r#"{"type":"assistant","uuid":"side","parentUuid":"a","isSidechain":true,"timestamp":"2026-09-28T00:00:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"a subagent"}]}}"#,
]
.join("\n"),
);
let t = read_claude(&path).unwrap();
assert_eq!(t.turns, vec![Turn::User("first".into())]);
}
#[test]
fn a_claude_tool_call_and_its_result_become_a_pair() {
let path = write_file(
"tools.jsonl",
&[
r#"{"type":"user","uuid":"a","parentUuid":null,"isSidechain":false,"timestamp":"2026-09-28T00:00:01.000Z","message":{"role":"user","content":"run it"}}"#,
r#"{"type":"assistant","uuid":"b","parentUuid":"a","isSidechain":false,"timestamp":"2026-09-28T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}},{"type":"thinking","thinking":"hmm"}]}}"#,
r#"{"type":"user","uuid":"c","parentUuid":"b","isSidechain":false,"timestamp":"2026-09-28T00:00:03.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a\nb"}]}}"#,
]
.join("\n"),
);
let t = read_claude(&path).unwrap();
assert_eq!(
t.turns,
vec![
Turn::User("run it".into()),
Turn::Call {
id: "toolu_1".into(),
name: "Bash".into(),
input: r#"{"command":"ls"}"#.into(),
},
Turn::Result {
id: "toolu_1".into(),
output: "a\nb".into(),
},
]
);
}
#[test]
fn a_codex_rollout_is_read_and_its_developer_turns_dropped() {
let path = write_file(
"rollout.jsonl",
&[
r#"{"type":"session_meta","timestamp":"2026-09-28T00:00:00.000Z","payload":{"id":"019f1075-3f22-7ad0-b496-73dcda6a7a25","cwd":"/tmp/x","timestamp":"2026-09-28T00:00:00.000Z"}}"#,
r#"{"type":"response_item","timestamp":"2026-09-28T00:00:01.000Z","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"you are codex"}]}}"#,
r#"{"type":"response_item","timestamp":"2026-09-28T00:00:02.000Z","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"do it"}]}}"#,
r#"{"type":"response_item","timestamp":"2026-09-28T00:00:03.000Z","payload":{"type":"function_call","name":"shell","arguments":"{\"cmd\":\"ls\"}","call_id":"call_abc"}}"#,
r#"{"type":"response_item","timestamp":"2026-09-28T00:00:04.000Z","payload":{"type":"function_call_output","call_id":"call_abc","output":"a\nb"}}"#,
]
.join("\n"),
);
let t = read_codex(&path).unwrap();
assert_eq!(t.session_id, "019f1075-3f22-7ad0-b496-73dcda6a7a25");
assert_eq!(t.cwd, "/tmp/x");
assert_eq!(t.turns.len(), 3);
assert!(
!t.turns
.iter()
.any(|x| matches!(x, Turn::User(u) if u.contains("codex"))),
"the developer turn is codex's own system prompt, not the conversation"
);
assert_eq!(
t.turns[1],
Turn::Call {
id: "call_abc".into(),
name: "shell".into(),
input: r#"{"cmd":"ls"}"#.into(),
}
);
}
#[test]
fn a_written_claude_transcript_puts_uuid_next_to_timestamp() {
let home = scratch("claude-out");
let got = write_claude(&sample(), &home).unwrap();
assert!(got.id_kept, "a free id should be kept");
assert_eq!(got.session_id, sample().session_id);
let body = std::fs::read_to_string(&got.path).unwrap();
for line in body.lines().filter(|l| l.contains(r#""uuid""#)) {
assert!(
line.contains(r#""uuid":""#) && line.contains(r#"","timestamp":""#),
"uuid and timestamp must be adjacent: {line}"
);
}
}
#[test]
fn every_written_record_names_the_session_the_file_is_called() {
let home = scratch("claude-id");
let got = write_claude(&sample(), &home).unwrap();
let body = std::fs::read_to_string(&got.path).unwrap();
for line in body.lines().filter(|l| l.contains(r#""sessionId""#)) {
assert!(line.contains(&sample().session_id), "{line}");
}
assert!(got.path.ends_with(format!("{}.jsonl", sample().session_id)));
}
#[test]
fn a_written_claude_transcript_chains_and_advances() {
let home = scratch("claude-chain");
let got = write_claude(&sample(), &home).unwrap();
let body = std::fs::read_to_string(&got.path).unwrap();
let mut parent: Option<String> = None;
let mut last = String::new();
for line in body.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if !matches!(str_field(&v, "type"), "user" | "assistant") {
continue;
}
assert_eq!(
v.get("parentUuid")
.and_then(Value::as_str)
.map(str::to_owned),
parent,
"each record links to the one before it"
);
assert!(v.pointer("/message").is_some(), "message must be an object");
if last.as_str() > str_field(&v, "timestamp") {
panic!("timestamps must not go backwards");
}
last = str_field(&v, "timestamp").to_owned();
parent = v.get("uuid").and_then(Value::as_str).map(str::to_owned);
}
assert!(parent.is_some());
}
#[test]
fn a_written_codex_rollout_opens_with_metadata_naming_the_file() {
let home = scratch("codex-out");
let got = write_codex(&sample(), &home).unwrap();
assert!(got.id_kept);
let body = std::fs::read_to_string(&got.path).unwrap();
let first: Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
assert_eq!(first["type"], "session_meta");
assert_eq!(first["payload"]["id"], sample().session_id.as_str());
assert!(
got.path
.file_name()
.unwrap()
.to_str()
.unwrap()
.ends_with(&format!("{}.jsonl", sample().session_id))
);
}
#[test]
fn an_id_already_in_the_store_is_not_written_over() {
let home = scratch("taken");
let dir = home
.join("projects")
.join(project_slug(Path::new("/tmp/proj")));
std::fs::create_dir_all(&dir).unwrap();
let taken = "5049dbcd-8ef7-412f-bf57-589e61c41d0e";
let original = "{\"type\":\"user\",\"uuid\":\"x\"}\n";
std::fs::write(dir.join(format!("{taken}.jsonl")), original).unwrap();
let mut t = sample();
t.session_id = taken.into();
let got = write_claude(&t, &home).unwrap();
assert!(!got.id_kept);
assert_ne!(got.session_id, taken);
assert_eq!(
std::fs::read_to_string(dir.join(format!("{taken}.jsonl"))).unwrap(),
original,
"the conversation that was there is untouched"
);
}
#[test]
fn a_codex_id_taken_in_another_day_is_still_taken() {
let home = scratch("codex-taken");
let dir = home.join("sessions/2020/01/01");
std::fs::create_dir_all(&dir).unwrap();
let taken = "5049dbcd-8ef7-412f-bf57-589e61c41d0e";
std::fs::write(
dir.join(format!("rollout-2020-01-01T00-00-00-{taken}.jsonl")),
"",
)
.unwrap();
let mut t = sample();
t.session_id = taken.into();
assert!(!write_codex(&t, &home).unwrap().id_kept);
}
#[test]
fn provenance_survives_a_round_trip_in_both_directions() {
let home = scratch("provenance");
for (write, read) in [
(
write_claude as fn(&Transcript, &Path) -> std::io::Result<Converted>,
provenance_of_file as fn(&Path) -> Option<Provenance>,
),
(
write_codex,
provenance_of_codex as fn(&Path) -> Option<Provenance>,
),
] {
let got = write(&sample(), &home).unwrap();
let p = read(&got.path).expect("the marker is readable");
assert_eq!(p.session_id, sample().session_id);
assert_eq!(p.harness, "codex");
assert!(!p.converted_at.is_empty());
}
}
#[test]
fn a_transcript_cctop_did_not_write_has_no_provenance() {
let home = scratch("foreign");
let dir = home
.join("projects")
.join(project_slug(Path::new("/tmp/proj")));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("f83c1a2b-0000-4000-8000-000000000000.jsonl");
std::fs::write(&path, "{\"type\":\"user\",\"uuid\":\"a\"}\n").unwrap();
assert_eq!(provenance_of_file(&path), None);
}
#[test]
fn both_directions_are_offered_and_the_rest_are_not() {
assert!(convertible(Provider::Claude, Provider::Codex));
assert!(convertible(Provider::Codex, Provider::Claude));
assert!(!convertible(Provider::Claude, Provider::Claude));
assert!(!convertible(Provider::Codex, Provider::OpenCode));
assert!(!convertible(Provider::OpenCode, Provider::Claude));
}
#[test]
#[ignore = "runs the machine's real codex against a scratch store"]
fn the_codex_binary_accepts_a_rollout_cctop_wrote() {
let home = std::env::temp_dir().join(format!("cctop-verify-codex-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let written = write_codex(&sample(), &home).expect("the rollout can be written");
let run = |args: &[&str]| -> String {
let out = std::process::Command::new("codex")
.args(args)
.env("CODEX_HOME", &home)
.output();
match out {
Ok(o) => format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
),
Err(e) => panic!("could not run codex: {e}"),
}
};
let doctor = run(&["doctor"]);
for line in ["rollout DB scan errors", "rollout DB malformed file names"] {
let found = doctor
.lines()
.find(|l| l.contains(line))
.unwrap_or_else(|| panic!("codex doctor reported no {line:?}:\n{doctor}"));
assert!(
found.trim_end().ends_with(" 0"),
"codex could not read the rollout: {found}"
);
}
let archived = run(&["archive", &written.session_id]);
assert!(
archived.contains("Archived session"),
"codex did not resolve the id:\n{archived}"
);
let _ = run(&["unarchive", &written.session_id]);
}
#[test]
#[ignore = "resumes a session with the machine's real claude binary"]
fn the_claude_binary_accepts_a_transcript_cctop_wrote() {
let home = std::env::temp_dir().join(format!("cctop-verify-claude-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let written = write_claude(&sample(), &home).expect("the transcript can be written");
let run = |id: &str| -> String {
let out = std::process::Command::new("claude")
.args(["--resume", id, "-p", "reply with the single word ok"])
.env("CLAUDE_CONFIG_DIR", &home)
.output();
match out {
Ok(o) => format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
),
Err(e) => panic!("could not run claude: {e}"),
}
};
let not_found = |text: &str| {
text.contains("No conversation found") || text.contains("No valid conversation chain")
};
let ours = run(&written.session_id);
eprintln!("converted session: {}", first_line(&ours));
assert!(
!not_found(&ours),
"claude would not resume what cctop wrote:\n{ours}"
);
let absent = run("00000000-0000-4000-8000-000000000000");
eprintln!("absent session: {}", first_line(&absent));
assert!(
not_found(&absent),
"claude resolved an id that is not there, so the check above proves nothing:\n{absent}"
);
eprintln!("(no credentials in the scratch dir, so this stops before the model)");
}
fn first_line(text: &str) -> String {
text.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("(nothing)")
.chars()
.take(100)
.collect()
}
#[test]
#[ignore = "converts a real session from this machine"]
fn a_real_session_survives_the_trip_in_both_directions() {
let pair = std::env::var("CCTOP_CONVERT_PAIR").unwrap_or_else(|_| "claude-codex".into());
let (source, target) = match pair.as_str() {
"claude-codex" => (Provider::Claude, Provider::Codex),
"codex-claude" => (Provider::Codex, Provider::Claude),
other => panic!("CCTOP_CONVERT_PAIR is {other:?}, not a known pair"),
};
let sessions = match source {
Provider::Claude => crate::session::claude::list_sessions(),
_ => crate::session::codex::list_sessions(),
};
let candidates: Vec<_> = sessions
.into_iter()
.filter(crate::convert::convertible_session)
.filter_map(|s| s.data_file)
.collect();
assert!(
!candidates.is_empty(),
"no {source:?} session to convert on this machine"
);
let mut sized: Vec<_> = candidates
.into_iter()
.map(|p| {
let n = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(u64::MAX);
(n, p)
})
.collect();
sized.sort();
let (_, path) = sized.remove(0);
eprintln!(
"converting {} ({} bytes)",
path.display(),
std::fs::metadata(&path).map_or(0, |m| m.len())
);
let home = scratch("real");
let written =
crate::convert::convert(source, &path, target, &home).expect("the conversion runs");
let back = match target {
Provider::Claude => read_claude(&written.path),
_ => read_codex(&written.path),
}
.expect("what cctop wrote, cctop can read back");
let before = match source {
Provider::Claude => read_claude(&path),
_ => read_codex(&path),
}
.expect("the source is readable");
assert!(
back.turns.len() >= before.turns.len() / 2,
"most of the conversation went missing: {} of {} turns",
back.turns.len(),
before.turns.len()
);
let first_of = |turns: &[Turn]| {
turns.iter().find_map(|t| match t {
Turn::User(text) => Some(text.clone()),
_ => None,
})
};
assert_eq!(
first_of(&back.turns),
first_of(&before.turns),
"the opening prompt did not survive"
);
let calls = |turns: &[Turn]| {
turns
.iter()
.filter(|t| matches!(t, Turn::Call { .. }))
.count()
};
let results = |turns: &[Turn]| {
turns
.iter()
.filter(|t| matches!(t, Turn::Result { .. }))
.count()
};
assert_eq!(
calls(&back.turns),
calls(&before.turns),
"a tool call was lost"
);
assert_eq!(
results(&back.turns),
results(&before.turns),
"a tool result was lost"
);
eprintln!(
"{} turns, {} tool calls, id kept: {}",
back.turns.len(),
calls(&back.turns),
written.id_kept
);
}
#[test]
fn a_claude_session_becomes_a_codex_rollout_and_back() {
let source = write_file(
"5049dbcd-8ef7-412f-bf57-589e61c41d0e.jsonl",
&[
r#"{"type":"user","uuid":"a","parentUuid":null,"isSidechain":false,"timestamp":"2026-09-28T00:00:01.000Z","cwd":"/tmp/proj","message":{"role":"user","content":"hello"},"sessionId":"5049dbcd-8ef7-412f-bf57-589e61c41d0e","gitBranch":"main"}"#,
r#"{"type":"assistant","uuid":"b","parentUuid":"a","isSidechain":false,"timestamp":"2026-09-28T00:00:02.000Z","message":{"role":"assistant","model":"m","content":[{"type":"text","text":"hi"}]},"sessionId":"5049dbcd-8ef7-412f-bf57-589e61c41d0e"}"#,
]
.join("\n"),
);
let codex_home = scratch("rt-codex");
let to_codex = convert(Provider::Claude, &source, Provider::Codex, &codex_home).unwrap();
assert!(to_codex.id_kept, "the id survives into the other store");
let back = read_codex(&to_codex.path).unwrap();
assert_eq!(
back.turns,
vec![Turn::User("hello".into()), Turn::Assistant("hi".into())]
);
assert_eq!(back.cwd, "/tmp/proj");
let claude_home = scratch("rt-claude");
let to_claude = convert(
Provider::Codex,
&to_codex.path,
Provider::Claude,
&claude_home,
)
.unwrap_or_else(|| write_claude(&back, &claude_home).expect("the copy can be written"));
assert!(to_claude.id_kept, "the id survives the whole round trip");
assert_eq!(to_claude.session_id, "5049dbcd-8ef7-412f-bf57-589e61c41d0e");
let again = read_claude(&to_claude.path).unwrap();
assert_eq!(
again.turns,
vec![Turn::User("hello".into()), Turn::Assistant("hi".into())]
);
}
}