use super::{
bounded_redacted_output, dedup_paths, ok_or_flag, parse_ts, title_from_messages, Adapter,
Discovered,
};
use crate::model::{Message, Role, Session, ToolCall, ToolResult};
use crate::util::short_id;
use anyhow::Result;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
mod history;
pub use history::CodexHistory;
pub struct Codex {
root: Option<PathBuf>,
dir: &'static str,
}
impl Default for Codex {
fn default() -> Self {
Codex {
root: None,
dir: "sessions",
}
}
}
impl Codex {
pub fn in_home(home: impl Into<PathBuf>) -> Self {
Codex {
root: Some(home.into().join("sessions")),
dir: "sessions",
}
}
pub fn archived() -> Self {
Codex {
root: None,
dir: "archived_sessions",
}
}
pub fn archived_in(home: impl Into<PathBuf>) -> Self {
Codex {
root: Some(home.into().join("archived_sessions")),
dir: "archived_sessions",
}
}
}
impl Adapter for Codex {
fn name(&self) -> &'static str {
"codex"
}
fn root(&self) -> Option<PathBuf> {
match &self.root {
Some(root) => Some(root.clone()),
None => Some(dirs::home_dir()?.join(".codex").join(self.dir)),
}
}
fn reconcile_scope(&self) -> Option<String> {
super::root_scope(self.root().as_deref())
}
fn discover(&self) -> Discovered {
let Some(root) = self.root() else {
return Vec::new().into();
};
if !root.exists() {
return Vec::new().into(); }
let mut had_error = false;
let files = WalkDir::new(root)
.into_iter()
.filter_map(|e| ok_or_flag(e, &mut had_error))
.filter(|e| e.file_type().is_file())
.filter(|e| {
let name = e.file_name().to_string_lossy();
name.starts_with("rollout-")
&& (name.ends_with(".jsonl") || name.ends_with(".jsonl.zst"))
})
.map(|e| e.into_path())
.collect();
Discovered { files, had_error }
}
fn parse(&self, path: &Path) -> Result<Session> {
let (lines, windowed) = crate::util::session_lines_keeping(path, |line| {
line.contains("\"user_message\"")
|| line.contains("\"agent_message\"")
|| line.contains("\"type\":\"message\"")
})?;
let mut messages: Vec<Message> = Vec::new();
let mut touched: Vec<String> = Vec::new();
let mut cwd: Option<String> = None;
let mut subagent = false;
let mut failed_mcp_calls: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut started = None;
let mut ended = None;
let mut response_user_texts: HashMap<String, u32> = HashMap::new();
let mut event_user_texts: HashMap<String, u32> = HashMap::new();
let (function_call_ids, function_call_args) = function_calls(&lines);
for line in &lines {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
let ts = v
.get("timestamp")
.and_then(Value::as_str)
.and_then(parse_ts);
if let Some(t) = ts {
if started.is_none() {
started = Some(t);
}
ended = Some(t);
}
match v.get("type").and_then(Value::as_str) {
Some("session_meta") => {
subagent |= is_subagent_meta(&v);
if cwd.is_none() {
cwd = v
.pointer("/payload/cwd")
.and_then(Value::as_str)
.map(String::from);
}
}
Some("response_item") => {
match v.pointer("/payload/type").and_then(Value::as_str) {
Some("message") => {
let role = match v.pointer("/payload/role").and_then(Value::as_str) {
Some("user") => Role::User,
Some("assistant") => Role::Assistant,
_ => continue,
};
let Some(Value::Array(blocks)) = v.pointer("/payload/content") else {
continue;
};
for b in blocks {
let Some(text) = b.get("text").and_then(Value::as_str) else {
continue;
};
if role == Role::User && is_boilerplate(text) {
continue;
}
if role == Role::User {
if let Some(n) =
event_user_texts.get_mut(text).filter(|n| **n > 0)
{
*n -= 1; continue;
}
*response_user_texts.entry(text.to_string()).or_insert(0) += 1;
}
push(&mut messages, role, text, ts);
}
}
Some("function_call") => {
let payload = v.get("payload").unwrap_or(&Value::Null);
let raw_args = payload.get("arguments").and_then(Value::as_str);
touched_by(payload, &mut touched);
let mut args = if let Some(raw_args) = raw_args {
serde_json::from_str(raw_args)
.unwrap_or_else(|_| json!({"arguments": raw_args}))
} else {
json!({})
};
redact_json_strings(&mut args);
messages.push(Message::tool_call(
ts,
ToolCall {
id: call_id(payload),
name: payload
.get("name")
.and_then(Value::as_str)
.unwrap_or("?")
.to_owned(),
args,
},
));
}
Some("custom_tool_call") => {
let payload = v.get("payload").unwrap_or(&Value::Null);
let input = payload.get("input").and_then(Value::as_str).unwrap_or("");
let is_patch = input.contains("*** Begin Patch");
let (name, mut args) = if is_patch {
let patch = input
.split_once("*** Begin Patch")
.map(|(_, patch)| format!("*** Begin Patch{patch}"))
.unwrap_or_else(|| input.to_owned())
.replace("\\n", "\n");
touched_by(payload, &mut touched);
("apply_patch", json!({"patch": patch}))
} else if let Some(cmd) = exec_command_cmd(input) {
("exec_command", json!({"cmd": cmd}))
} else {
("exec", json!({"input": input}))
};
redact_json_strings(&mut args);
messages.push(Message::tool_call(
ts,
ToolCall {
id: call_id(payload),
name: if name != "exec" {
name.to_owned()
} else {
payload
.get("name")
.and_then(Value::as_str)
.unwrap_or(name)
.to_owned()
},
args,
},
));
}
Some("function_call_output" | "custom_tool_call_output") => {
let payload = v.get("payload").unwrap_or(&Value::Null);
let (output, is_error) = payload
.get("output")
.map(unwrap_output_value)
.unwrap_or_default();
messages.push(Message::tool_result(
ts,
ToolResult {
call_id: call_id(payload),
text: bounded_redacted_output(&output),
is_error,
},
));
}
_ => {}
}
}
Some("event_msg") => match v.pointer("/payload/type").and_then(Value::as_str) {
Some("user_message") => {
if let Some(t) = v.pointer("/payload/message").and_then(Value::as_str) {
if !is_boilerplate(t) {
if let Some(n) = response_user_texts.get_mut(t).filter(|n| **n > 0)
{
*n -= 1; } else {
*event_user_texts.entry(t.to_string()).or_insert(0) += 1;
push(&mut messages, Role::User, t, ts);
}
}
}
}
Some("agent_message") => {
if let Some(t) = v.pointer("/payload/message").and_then(Value::as_str) {
push(&mut messages, Role::Assistant, t, ts);
}
}
Some("mcp_tool_call_end") => {
let result = v.pointer("/payload/result");
let failed = result.is_some_and(|r| {
r.get("Err").is_some()
|| r.pointer("/Ok/isError").and_then(Value::as_bool) == Some(true)
});
let id = v.pointer("/payload/call_id").and_then(Value::as_str);
if let (true, Some(id)) = (failed, id) {
failed_mcp_calls.insert(id.to_owned());
}
let mut args = v
.pointer("/payload/invocation/arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let paired = id.is_some_and(|id| function_call_ids.contains(id))
|| function_call_args.contains(&args);
if !paired {
redact_json_strings(&mut args);
let name = v
.pointer("/payload/invocation/tool")
.and_then(Value::as_str)
.unwrap_or("?")
.to_owned();
messages.push(Message::tool_call(
ts,
ToolCall {
id: id.map(str::to_owned),
name,
args,
},
));
messages.push(Message::tool_result(
ts,
ToolResult {
call_id: id.map(str::to_owned),
text: bounded_redacted_output(&mcp_result_text(result)),
is_error: failed,
},
));
}
}
_ => {}
},
Some("message") => {
let role = match v.get("role").and_then(Value::as_str) {
Some("user") => Role::User,
Some("assistant") => Role::Assistant,
_ => continue,
};
let Some(Value::Array(blocks)) = v.get("content") else {
continue;
};
for b in blocks {
let Some(text) = b.get("text").and_then(Value::as_str) else {
continue;
};
if role == Role::User && is_boilerplate(text) {
continue;
}
push(&mut messages, role, text, ts);
}
}
_ => {}
}
}
if windowed {
let mut all = Vec::new();
let read = crate::util::for_each_line(path, |line| {
if !line.contains("*** ") {
return;
}
let Ok(v) = serde_json::from_str::<Value>(line) else {
return;
};
if v.get("type").and_then(Value::as_str) == Some("response_item") {
touched_by(v.get("payload").unwrap_or(&Value::Null), &mut all);
}
});
if read.is_ok() {
touched = all;
}
}
let project = cwd.unwrap_or_default();
for message in &mut messages {
if let Some(crate::model::ToolEvent::Part(crate::model::ToolPart::Result(result))) =
&mut message.tool
{
if result
.call_id
.as_ref()
.is_some_and(|id| failed_mcp_calls.contains(id))
{
result.is_error = true;
}
}
}
let title = if windowed {
format!("[large] {}", title_from_messages(&messages))
} else {
title_from_messages(&messages)
};
Ok(Session {
id: short_id(&path.to_string_lossy()),
tool: self.name(),
path: path.to_path_buf(),
project,
started,
ended,
title,
subagent,
messages,
touched: dedup_paths(touched),
edits: Vec::new(),
})
}
}
fn collect_patched_paths(args: &str, out: &mut Vec<String>) {
const MARKERS: [&str; 4] = [
"*** Add File: ",
"*** Update File: ",
"*** Delete File: ",
"*** Move to: ",
];
let normalized = args.replace("\\n", "\n");
for line in normalized.lines() {
let line = line.trim();
for m in MARKERS {
if let Some(rest) = line.strip_prefix(m) {
let path = rest.trim().trim_matches('"');
if !path.is_empty() {
out.push(path.to_string());
}
}
}
}
}
fn touched_by(payload: &Value, out: &mut Vec<String>) {
match payload.get("type").and_then(Value::as_str) {
Some("function_call") => collect_patched_paths(
payload
.get("arguments")
.and_then(Value::as_str)
.unwrap_or_default(),
out,
),
Some("custom_tool_call") => {
let input = payload.get("input").and_then(Value::as_str).unwrap_or("");
if let Some((_, patch)) = input.split_once("*** Begin Patch") {
collect_patched_paths(&format!("*** Begin Patch{patch}").replace("\\n", "\n"), out);
}
}
_ => {}
}
}
fn call_id(payload: &Value) -> Option<String> {
payload
.get("call_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_owned)
}
fn function_calls(lines: &[String]) -> (std::collections::HashSet<String>, Vec<Value>) {
let mut ids = std::collections::HashSet::new();
let mut args = Vec::new();
for line in lines {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if v.get("type").and_then(Value::as_str) != Some("response_item") {
continue;
}
let payload = v.get("payload").unwrap_or(&Value::Null);
if !matches!(
payload.get("type").and_then(Value::as_str),
Some("function_call" | "custom_tool_call")
) {
continue;
}
if let Some(id) = payload.get("call_id").and_then(Value::as_str) {
ids.insert(id.to_owned());
}
if let Some(parsed) = payload
.get("arguments")
.and_then(Value::as_str)
.and_then(|raw| serde_json::from_str::<Value>(raw).ok())
{
args.push(parsed);
}
}
(ids, args)
}
fn mcp_result_text(result: Option<&Value>) -> String {
let Some(result) = result else {
return String::new();
};
if let Some(err) = result.get("Err") {
return err.as_str().map_or_else(|| err.to_string(), str::to_owned);
}
result
.pointer("/Ok/content")
.and_then(Value::as_array)
.map(|blocks| {
blocks
.iter()
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
}
fn unwrap_output_value(output: &Value) -> (String, bool) {
match output {
Value::String(text) => unwrap_output(text),
Value::Array(blocks) => {
let mut parts = Vec::new();
let mut is_error = false;
for text in blocks
.iter()
.filter_map(|block| block.get("text").and_then(Value::as_str))
{
if text.starts_with("Script completed\n") || text.starts_with("Script failed") {
is_error |= text.starts_with("Script failed");
continue;
}
let (chunk, chunk_error) = unwrap_output(text);
is_error |= chunk_error;
if !chunk.is_empty() {
parts.push(chunk);
}
}
(parts.join("\n"), is_error)
}
_ => (String::new(), false),
}
}
fn unwrap_output(text: &str) -> (String, bool) {
let Ok(Value::Object(object)) = serde_json::from_str::<Value>(text) else {
return unwrap_text_output(text).unwrap_or_else(|| (text.to_owned(), false));
};
let Some(output) = object.get("output").and_then(Value::as_str) else {
return (text.to_owned(), false);
};
let exit_code = object.get("exit_code").and_then(Value::as_i64).or_else(|| {
object
.get("metadata")
.and_then(|metadata| metadata.get("exit_code"))
.and_then(Value::as_i64)
});
(output.to_owned(), exit_code.is_some_and(|code| code != 0))
}
fn unwrap_text_output(text: &str) -> Option<(String, bool)> {
let (header, output) = text
.split_once("\nOutput:\n")
.or_else(|| text.strip_suffix("\nOutput:").map(|header| (header, "")))?;
let exit_code = header.lines().find_map(|line| {
line.strip_prefix("Exit code: ")
.or_else(|| line.strip_prefix("Process exited with code "))
.and_then(|code| code.trim().parse::<i64>().ok())
})?;
Some((output.to_owned(), exit_code != 0))
}
fn redact_json_strings(value: &mut Value) {
match value {
Value::String(text) => *text = crate::redact::redact(text).into_owned(),
Value::Array(values) => values.iter_mut().for_each(redact_json_strings),
Value::Object(values) => values.values_mut().for_each(redact_json_strings),
_ => {}
}
}
fn is_boilerplate(text: &str) -> bool {
let t = text.trim_start();
t.starts_with("<user_instructions>")
|| t.starts_with("<environment_context>")
|| t.starts_with("<ENVIRONMENT_CONTEXT>")
|| t.starts_with("<turn_context>")
|| t.starts_with("# AGENTS.md instructions")
|| t.starts_with("<INSTRUCTIONS>")
}
fn push(
messages: &mut Vec<Message>,
role: Role,
text: &str,
ts: Option<chrono::DateTime<chrono::Utc>>,
) {
let text = text.trim();
if !text.is_empty() {
messages.push(Message {
role,
text: text.to_string(),
ts,
tool: None,
});
}
}
pub(crate) fn is_subagent_meta(v: &Value) -> bool {
v.pointer("/payload/thread_source").and_then(Value::as_str) == Some("subagent")
|| v.pointer("/payload/source/subagent").is_some()
}
fn exec_command_cmd(input: &str) -> Option<String> {
let cmds: Vec<String> = input
.split("tools.exec_command(")
.skip(1)
.filter_map(js_cmd_literal)
.collect();
(!cmds.is_empty()).then(|| cmds.join("; "))
}
fn js_cmd_literal(call: &str) -> Option<String> {
let (_, after_key) = call.split_once("cmd")?;
let rest = after_key.trim_start().strip_prefix(':')?.trim_start();
let mut chars = rest.chars();
let quote = chars.next()?;
if !matches!(quote, '"' | '\'' | '`') {
return None;
}
let mut cmd = String::new();
while let Some(c) = chars.next() {
match c {
c if c == quote => return (!cmd.trim().is_empty()).then_some(cmd),
'\\' => match chars.next()? {
'n' => cmd.push('\n'),
't' => cmd.push('\t'),
'r' => cmd.push('\r'),
other => cmd.push(other),
},
c => cmd.push(c),
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_home_discovers_that_installs_rollouts_and_scopes_reconciliation() {
let home = tempfile::tempdir().unwrap();
let day = home
.path()
.join("sessions")
.join("2026")
.join("01")
.join("01");
std::fs::create_dir_all(&day).unwrap();
let file = day.join("rollout-2026-01-01T10-00-00-abc.jsonl");
std::fs::write(
&file,
"{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/repo\"}}\n",
)
.unwrap();
let adapter = Codex::in_home(home.path());
let found = adapter.discover();
assert!(!found.had_error);
assert_eq!(found.files, vec![file.clone()]);
let scope = adapter
.reconcile_scope()
.expect("an explicit install is scoped");
assert!(
file.to_string_lossy().starts_with(&scope),
"{scope} must be a prefix of the discovered {}",
file.display()
);
assert_eq!(
Codex::default().reconcile_scope(),
crate::adapters::root_scope(Codex::default().root().as_deref()),
"the stock adapter speaks only for its own install"
);
}
#[test]
fn a_subagent_rollout_is_parsed_as_a_subagent() {
let dir = tempfile::tempdir().unwrap();
let write = |name: &str, meta: &str| {
let file = dir.path().join(name);
std::fs::write(
&file,
format!(
"{{\"timestamp\":\"2026-10-07T00:00:00Z\",\"type\":\"session_meta\",\"payload\":{meta}}}\n\
{{\"timestamp\":\"2026-10-07T00:00:01Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{{\"type\":\"output_text\",\"text\":\"working\"}}]}}}}\n"
),
)
.unwrap();
file
};
let main = write("main.jsonl", r#"{"cwd":"/repo","source":"cli"}"#);
let by_thread = write(
"sub1.jsonl",
r#"{"cwd":"/repo","thread_source":"subagent","agent_path":"/root/worker"}"#,
);
let by_source = write(
"sub2.jsonl",
r#"{"cwd":"/repo","source":{"subagent":{"thread_spawn":{"depth":1}}}}"#,
);
let codex = Codex::default();
assert!(!codex.parse(&main).unwrap().subagent);
assert!(codex.parse(&by_thread).unwrap().subagent);
assert!(codex.parse(&by_source).unwrap().subagent);
}
#[test]
fn a_windowed_rollout_keeps_every_file_it_patched() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("rollout-2026-10-01T00-00-00-abc.jsonl");
let mut body =
String::from("{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/repo\"}}\n");
for i in 0..30 {
body.push_str(&format!(
"{{\"type\":\"response_item\",\"payload\":{{\"type\":\"custom_tool_call\",\"call_id\":\"c{i}\",\"name\":\"apply_patch\",\"input\":\"*** Begin Patch\\n*** Update File: src/f{i}.rs\\n@@\\n-a\\n+b\\n*** End Patch\"}}}}\n"
));
body.push_str(&format!(
"{{\"type\":\"response_item\",\"payload\":{{\"type\":\"function_call\",\"call_id\":\"s{i}\",\"name\":\"shell\",\"arguments\":\"{{\\\"cmd\\\":\\\"apply_patch <<EOF\\\\n*** Begin Patch\\\\n*** Add File: src/g{i}.rs\\\\n+x\\\\n*** End Patch\\\\nEOF\\\"}}\"}}}}\n"
));
}
std::fs::write(&file, body).unwrap();
crate::util::TEST_WINDOW.with(|w| w.set(Some((2_000, 600, 600))));
let session = Codex::default().parse(&file).unwrap();
crate::util::TEST_WINDOW.with(|w| w.set(None));
assert!(session.title.starts_with("[large]"), "{}", session.title);
assert_eq!(session.touched.len(), 60, "{:?}", session.touched);
assert!(session.touched.contains(&"src/f15.rs".to_string()));
assert!(session.touched.contains(&"src/g15.rs".to_string()));
}
#[test]
fn a_windowed_rollout_keeps_the_conversation_in_between() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("rollout-2026-10-01T00-00-00-abc.jsonl");
let mut body =
String::from("{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/repo\"}}\n");
for i in 0..30 {
body.push_str(&format!(
"{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"user_message\",\"message\":\"question number {i}\"}}}}\n"
));
body.push_str(&format!(
"{{\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":[{{\"type\":\"input_text\",\"text\":\"question number {i}\"}}]}}}}\n"
));
body.push_str(&format!(
"{{\"type\":\"response_item\",\"payload\":{{\"type\":\"function_call_output\",\"call_id\":\"c{i}\",\"output\":\"bulky output {i}\"}}}}\n"
));
body.push_str(&format!(
"{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"agent_message\",\"message\":\"answer number {i}\"}}}}\n"
));
}
std::fs::write(&file, body).unwrap();
crate::util::TEST_WINDOW.with(|w| w.set(Some((2_000, 600, 600))));
let session = Codex::default().parse(&file).unwrap();
crate::util::TEST_WINDOW.with(|w| w.set(None));
assert!(session.title.starts_with("[large]"), "{}", session.title);
let text = |role: Role| -> Vec<String> {
session
.messages
.iter()
.filter(|m| m.role == role)
.map(|m| m.text.clone())
.collect()
};
let questions: Vec<String> = (0..30).map(|i| format!("question number {i}")).collect();
let answers: Vec<String> = (0..30).map(|i| format!("answer number {i}")).collect();
assert_eq!(text(Role::User), questions, "each prompt once");
assert_eq!(text(Role::Assistant), answers);
}
}