use crate::context::ContextReader;
use crate::message::{
AssistantResponse, Context, ContextListing, ConversationMessage, Entry, MessageKind,
TextContent, ToolCall, ToolResultData,
};
use crate::text;
use anyhow::Context as _;
use serde_json::Value;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
pub struct CodexReader {
file_path: Option<PathBuf>,
}
impl CodexReader {
pub fn new(file_path: PathBuf) -> Self {
Self {
file_path: Some(file_path),
}
}
pub fn empty() -> Self {
Self { file_path: None }
}
}
impl ContextReader for CodexReader {
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
let Some(file_path) = &self.file_path else {
return Ok(Vec::new());
};
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("");
let detail = match (timestamp.is_empty(), cwd.is_empty()) {
(true, true) => String::new(),
(false, true) => timestamp.to_string(),
(true, false) => format!("cwd: {cwd}"),
(false, false) => format!("{timestamp}, cwd: {cwd}"),
};
Ok(vec![ContextListing {
id,
detail,
path: Some(file_path.clone()),
}])
}
fn read_context(&self, _context_id: &str) -> anyhow::Result<Context> {
let Some(file_path) = &self.file_path else {
anyhow::bail!("no sessions found");
};
let file =
fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
let mut messages = Vec::new();
let mut previous_entry_id = String::new();
for (line_num, line_result) in reader.lines().enumerate() {
let line = line_result
.with_context(|| format!("{}:{}: read error", file_path.display(), line_num + 1))?;
if line.trim().is_empty() {
continue;
}
let entry_json: Value = serde_json::from_str(&line).with_context(|| {
format!("{}:{}: JSON parse error", file_path.display(), line_num + 1)
})?;
if entry_json["type"].as_str() == Some("session_meta") {
continue;
}
let Some(kind) = codex_message_kind(&entry_json) else {
continue;
};
let payload = &entry_json["payload"];
let entry_type = payload["type"]
.as_str()
.or_else(|| entry_json["type"].as_str())
.unwrap_or("entry")
.to_string();
let entry_id = codex_entry_id(payload, line_num);
let parent_id = previous_entry_id.clone();
messages.push(ConversationMessage {
entry_id: entry_id.clone(),
kind,
});
entries.push(Entry {
line: line_num,
r#type: entry_type,
id: entry_id.clone(),
parent_id,
});
previous_entry_id = entry_id;
}
Ok(Context {
path: file_path.clone(),
entries,
messages,
})
}
}
fn codex_message_kind(entry: &Value) -> Option<MessageKind> {
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" => {
Some(MessageKind::AssistantResponse(AssistantResponse {
thinking: Vec::new(),
tool_calls: vec![codex_tool_call(payload)],
text: String::new(),
}))
}
"reasoning" => Some(MessageKind::AssistantResponse(AssistantResponse {
thinking: codex_content_parts_text(&payload["summary"]),
tool_calls: Vec::new(),
text: String::new(),
})),
"function_call_output" | "custom_tool_call_output" => {
Some(MessageKind::ToolResultData(ToolResultData {
tool_name: payload["call_id"].as_str().unwrap_or("tool").to_string(),
content: codex_output_text(payload),
is_error: payload["status"].as_str() == Some("failed"),
}))
}
_ => None,
}
}
fn codex_text_or_assistant(payload: &Value) -> MessageKind {
let role = payload["role"].as_str().unwrap_or("unknown").to_string();
let text = codex_content_text(&payload["content"]);
if role == "assistant" {
MessageKind::AssistantResponse(AssistantResponse {
thinking: Vec::new(),
tool_calls: Vec::new(),
text,
})
} else {
MessageKind::TextContent(TextContent { role, text })
}
}
fn codex_content_text(content: &Value) -> String {
text::join_lines(&codex_content_parts_text(content), "\n")
}
fn codex_content_parts_text(content: &Value) -> Vec<String> {
match content.as_array() {
Some(parts) => parts
.iter()
.filter_map(|part| {
part["text"]
.as_str()
.or_else(|| part["input_text"].as_str())
.or_else(|| part["output_text"].as_str())
})
.map(str::to_string)
.collect(),
None => content
.as_str()
.map(|s| vec![s.to_string()])
.unwrap_or_default(),
}
}
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 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 if let Some(arguments) = payload.get("arguments") {
parse_arguments(arguments)
} else if let Some(input) = payload.get("input") {
parse_arguments(input)
} else {
Value::Null
};
ToolCall { name, arguments }
}
fn parse_arguments(arguments: &Value) -> Value {
if let Some(s) = arguments.as_str()
&& let Ok(json) = serde_json::from_str(s)
{
return json;
}
arguments.clone()
}
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 codex_content_text(value);
}
if !value.is_null() {
return value.to_string();
}
}
String::new()
}