use crate::context::ir::{build_tool_call, build_tool_result, extract_text};
use crate::context::{ContextReader, for_each_jsonl_record};
use crate::message::{
Context, ContextListing, ConversationMessage, Entry, Part, Passthrough, ProviderId, Reasoning,
ToolCall,
};
use anyhow::Context as _;
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
pub struct CodexReader {
file_path: PathBuf,
}
impl CodexReader {
pub fn new(file_path: PathBuf) -> Self {
Self { file_path }
}
}
impl ContextReader for CodexReader {
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
let file_path = &self.file_path;
let file =
fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
let mut reader = BufReader::new(file);
let mut first_line = String::new();
reader
.read_line(&mut first_line)
.with_context(|| format!("read {}", file_path.display()))?;
let first_line = first_line.trim_end();
if first_line.is_empty() {
anyhow::bail!("empty file");
}
let session: Value = serde_json::from_str(first_line)?;
if session["type"].as_str() != Some("session_meta") {
anyhow::bail!("first line is not a Codex session header");
}
let payload = &session["payload"];
let id = payload["id"].as_str().unwrap_or("unknown").to_string();
let cwd = payload["cwd"].as_str().unwrap_or("");
let timestamp = payload["timestamp"]
.as_str()
.or_else(|| session["timestamp"].as_str())
.unwrap_or("");
Ok(vec![ContextListing {
id,
provider_id: ProviderId {
label: None,
cwd: PathBuf::from(cwd),
count: 0,
from: if timestamp.is_empty() {
None
} else {
DateTime::parse_from_rfc3339(timestamp)
.ok()
.map(|dt| dt.with_timezone(&Utc))
},
until: None,
},
path: file_path.clone(),
parent_id: None,
}])
}
fn delete_context(&self, _context_id: &str) -> anyhow::Result<()> {
fs::remove_file(&self.file_path)
.with_context(|| format!("remove {}", self.file_path.display()))
}
fn read_context(&self, _context_id: &str) -> anyhow::Result<Context> {
let mut entries = Vec::new();
let mut messages = Vec::new();
let mut previous_entry_id = String::new();
let mut cwd = String::new();
let mut tool_names: HashMap<String, String> = HashMap::new();
for_each_jsonl_record(&self.file_path, |line_num, entry_json| {
if entry_json["type"].as_str() == Some("session_meta") {
if cwd.is_empty()
&& let Some(value) = entry_json["payload"]["cwd"].as_str()
{
cwd = value.to_string();
}
return;
}
let Some((role, parts)) = codex_message(entry_json, &mut tool_names) else {
return;
};
let payload = &entry_json["payload"];
let entry_id = codex_entry_id(payload, line_num);
let parent_id = previous_entry_id.clone();
messages.push(ConversationMessage::new(entry_id.clone(), role, parts));
entries.push(Entry {
id: entry_id.clone(),
parent_id,
});
previous_entry_id = entry_id;
})?;
Ok(Context {
entries,
messages,
cwd: (!cwd.is_empty()).then_some(cwd),
})
}
}
fn codex_message(
entry: &Value,
tool_names: &mut HashMap<String, String>,
) -> Option<(String, Vec<Part>)> {
if entry["type"].as_str() != Some("response_item") {
return None;
}
let payload = &entry["payload"];
match payload["type"].as_str()? {
"message" => Some(codex_text_or_assistant(payload)),
"function_call" | "custom_tool_call" | "local_shell_call" => {
let call = codex_tool_call(payload);
if !call.id.is_empty() {
tool_names.insert(call.id.clone(), call.name.clone());
}
Some(("assistant".to_string(), vec![Part::ToolCall(call)]))
}
"reasoning" => {
let parts = payload["summary"]
.as_array()
.into_iter()
.flatten()
.filter_map(|part| part["text"].as_str())
.map(|text| Part::Reasoning(Reasoning::new(text)))
.collect();
Some(("assistant".to_string(), parts))
}
"function_call_output" | "custom_tool_call_output" => {
let call_id = payload["call_id"].as_str().unwrap_or("").to_string();
let tool_name = tool_names
.get(&call_id)
.cloned()
.unwrap_or_else(|| "tool".to_string());
let content = codex_output_text(payload);
let is_error = payload["status"].as_str() == Some("failed");
Some((
"user".to_string(),
vec![Part::ToolResult(build_tool_result(
call_id, tool_name, content, is_error,
))],
))
}
other => Some((
"assistant".to_string(),
vec![Part::Passthrough(Passthrough {
kind: other.to_string(),
raw: payload.clone(),
})],
)),
}
}
fn codex_text_or_assistant(payload: &Value) -> (String, Vec<Part>) {
let role = payload["role"].as_str().unwrap_or("unknown").to_string();
let text = extract_text(Some(&payload["content"]));
(role, vec![Part::Text(text)])
}
fn codex_entry_id(payload: &Value, line_num: usize) -> String {
let fallback = || format!("codex-{line_num}");
let Some(id) = payload["id"]
.as_str()
.or_else(|| payload["call_id"].as_str())
else {
return fallback();
};
if matches!(
payload["type"].as_str(),
Some("function_call_output" | "custom_tool_call_output")
) {
format!("{id}-output")
} else {
id.to_string()
}
}
fn codex_tool_call(payload: &Value) -> ToolCall {
let id = payload["call_id"].as_str().unwrap_or("").to_string();
let name = payload["name"]
.as_str()
.or_else(|| payload["command"].as_str())
.or_else(|| payload["type"].as_str())
.unwrap_or("tool")
.to_string();
let arguments = if let Some(command) = payload["action"]["command"].as_str() {
serde_json::json!({ "command": command })
} else {
let raw = payload.get("arguments").or_else(|| payload.get("input"));
build_tool_call(id.clone(), name.clone(), raw).arguments
};
ToolCall {
id,
name,
arguments,
}
}
fn codex_output_text(payload: &Value) -> String {
for key in ["output", "content"] {
let Some(value) = payload.get(key) else {
continue;
};
if let Some(s) = value.as_str() {
return s.to_string();
}
if value.is_array() {
return extract_text(Some(value));
}
if !value.is_null() {
return value.to_string();
}
}
String::new()
}