use std::path::{Path, PathBuf};
use serde_json::Value;
use super::{MAX_SESSION_BYTES, codex_home};
use crate::domain::handoff::{DigestEvent, ForeignSessionSummary, HandoffSource};
const SKIP_PREFIXES: [&str; 6] = [
"<skills_instructions",
"<environment_context",
"<user_instructions",
"<turn_context",
"<permissions",
"<app_context",
];
pub fn sessions_dir() -> Option<PathBuf> {
codex_home().map(|h| h.join("sessions"))
}
pub fn discover_codex(limit: usize) -> Vec<ForeignSessionSummary> {
let Some(dir) = sessions_dir() else {
return Vec::new();
};
let mut files: Vec<PathBuf> = match collect_rollout_files(&dir) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
files.sort_by_key(|p| {
std::fs::metadata(p)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
});
files.reverse();
files.truncate(limit);
files
.into_iter()
.filter_map(|path| {
let meta = read_first_json_line(&path)?;
let payload = meta.get("payload")?;
let id = payload.get("session_id")?.as_str()?.to_string();
let cwd = payload
.get("cwd")
.and_then(|v| v.as_str())
.map(String::from);
let last_modified = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
Some(ForeignSessionSummary {
source: HandoffSource::Codex,
title: None, id,
cwd,
last_modified,
path: Some(path),
})
})
.collect()
}
pub fn preview_user_message(path: &Path) -> Option<String> {
use std::io::Read;
let mut file = std::fs::File::open(path).ok()?;
let mut buf = vec![0u8; 128 * 1024];
let n = file.read(&mut buf).unwrap_or(0);
buf.truncate(n);
let text = String::from_utf8_lossy(&buf);
for event in parse_events(&text) {
if let DigestEvent::User(t) = event {
let head: String = t
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
let head: String = head.chars().take(120).collect();
if !head.is_empty() {
return Some(head);
}
}
}
None
}
pub fn extract_codex_events(path: &Path) -> anyhow::Result<Vec<DigestEvent>> {
let bytes = std::fs::read(path)?;
anyhow::ensure!(
(bytes.len() as u64) <= MAX_SESSION_BYTES,
"codex session file too large: {}",
path.display()
);
let text = String::from_utf8_lossy(&bytes);
Ok(parse_events(&text))
}
fn parse_events(text: &str) -> Vec<DigestEvent> {
let mut events = Vec::new();
for line in text.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if v.get("type").and_then(|t| t.as_str()) != Some("response_item") {
continue;
}
let Some(payload) = v.get("payload") else {
continue;
};
match payload.get("type").and_then(|t| t.as_str()) {
Some("message") => {
let Some(text) = join_message_text(payload) else {
continue;
};
if text.trim().is_empty() || SKIP_PREFIXES.iter().any(|p| text.starts_with(p)) {
continue;
}
match payload.get("role").and_then(|r| r.as_str()) {
Some("user") => events.push(DigestEvent::User(text)),
Some("assistant") => events.push(DigestEvent::Assistant(text)),
_ => {} }
}
Some("custom_tool_call") => {
let name = payload
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("tool")
.to_string();
let args = payload
.get("input")
.or_else(|| payload.get("arguments"))
.map(|a| a.to_string())
.unwrap_or_default();
events.push(DigestEvent::ToolCall { name, args });
}
Some("custom_tool_call_output") => {
let output = payload
.get("output")
.map(|o| {
o.as_str()
.map(String::from)
.unwrap_or_else(|| o.to_string())
})
.unwrap_or_default();
if !output.trim().is_empty() {
events.push(DigestEvent::ToolOutput(output));
}
}
_ => {}
}
}
events
}
fn join_message_text(payload: &Value) -> Option<String> {
let content = payload.get("content")?.as_array()?;
let mut text = String::new();
for item in content {
let t = item.get("text").and_then(|t| t.as_str()).unwrap_or("");
if !t.is_empty() {
if !text.is_empty() {
text.push('\n');
}
text.push_str(t);
}
}
Some(text)
}
fn read_first_json_line(path: &Path) -> Option<Value> {
use std::io::BufRead;
let file = std::fs::File::open(path).ok()?;
let mut line = String::new();
std::io::BufReader::new(file).read_line(&mut line).ok()?;
serde_json::from_str(&line).ok()
}
fn collect_rollout_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(cur) = stack.pop() {
for entry in std::fs::read_dir(&cur)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if entry.file_name().to_string_lossy().starts_with("rollout-")
&& path.extension().is_some_and(|e| e == "jsonl")
{
out.push(path);
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const META: &str = r#"{"timestamp":"2026-08-15T09:00:00.000Z","type":"session_meta","payload":{"session_id":"01a00274-f7ec-7f73-bf17-ddc01849524d","cwd":"/tmp/proj","base_instructions":{"text":"You are Codex, an agent based on GPT-5. VERY LONG INSTRUCTIONS"}}"#;
fn user_msg(text: &str) -> String {
format!(
r#"{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":{}}}]}}}}"#,
serde_json::json!(text)
)
}
fn assistant_msg(text: &str) -> String {
format!(
r#"{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":{}}}]}}}}"#,
serde_json::json!(text)
)
}
#[test]
fn parses_user_assistant_and_tools() {
let mut text = String::from(META);
text.push('\n');
text.push_str(&user_msg("fix the parser"));
text.push('\n');
text.push_str(&assistant_msg("fixed it"));
text.push('\n');
text.push_str(
r#"{"type":"response_item","payload":{"type":"custom_tool_call","name":"shell","input":"cargo test"}}"#,
);
text.push('\n');
text.push_str(
r#"{"type":"response_item","payload":{"type":"custom_tool_call_output","output":"test result: ok"}}"#,
);
let events = parse_events(&text);
assert_eq!(events.len(), 4);
assert!(matches!(&events[0], DigestEvent::User(t) if t == "fix the parser"));
assert!(matches!(&events[1], DigestEvent::Assistant(t) if t == "fixed it"));
assert!(matches!(&events[2], DigestEvent::ToolCall { name, .. } if name == "shell"));
assert!(matches!(&events[3], DigestEvent::ToolOutput(t) if t.contains("ok")));
}
#[test]
fn skips_developer_and_injected_context() {
let mut text = String::new();
text.push_str(
r#"{"type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"system stuff"}]}}"#,
);
text.push('\n');
text.push_str(&user_msg("<environment_context>blah</environment_context>"));
text.push('\n');
text.push_str(&user_msg("real question"));
let events = parse_events(&text);
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], DigestEvent::User(t) if t == "real question"));
}
#[test]
fn malformed_and_unknown_lines_tolerated() {
let mut text = String::from("not json at all\n");
text.push_str(r#"{"type":"token_count","payload":{}}"#);
text.push('\n');
text.push_str(&user_msg("hello"));
text.push('\n');
text.push_str(r#"{"type":"event_msg","payload":{"type":"task_complete"}}"#);
let events = parse_events(&text);
assert_eq!(events.len(), 1);
}
#[test]
fn base_instructions_never_leak() {
let events = parse_events(META);
assert!(events.is_empty());
let summaries = parse_meta_title_check();
assert!(summaries.is_none_or(|s| !s.contains("GPT-5")));
}
fn parse_meta_title_check() -> Option<String> {
let v: Value = serde_json::from_str(META).ok()?;
v.get("payload")?
.get("base_instructions")?
.as_str()
.map(String::from)
}
#[test]
fn discover_missing_dir_is_empty() {
assert!(discover_codex(10).len() <= 200);
}
#[test]
fn discover_real_store_smoke() {
let dir = sessions_dir();
let Some(dir) = dir else { return };
if !dir.exists() {
return; }
let found = discover_codex(50);
assert!(found.iter().all(|s| s.source == HandoffSource::Codex));
assert!(found.iter().all(|s| s.id.len() == 36));
}
}