#![allow(dead_code)]
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::Value;
use tracing::warn;
#[derive(Debug, Clone)]
pub struct ContextSnapshot {
pub user_task: Option<String>,
pub recent_actions: Vec<AgentAction>,
pub current_tool: Option<AgentAction>,
pub token_count: u64,
pub context_limit: u64,
}
#[derive(Debug, Clone)]
pub struct AgentAction {
pub tool: String,
pub detail: Option<String>,
}
pub trait ContextReader: Send + Sync {
fn read(&mut self) -> Option<ContextSnapshot>;
fn claimed_path(&self) -> Option<PathBuf> {
None
}
}
pub fn context_reader_for(
tool: &str,
cwd: &str,
excluded: &[PathBuf],
) -> Option<Box<dyn ContextReader>> {
match tool {
"Claude Code" => Some(Box::new(ClaudeCodeReader::new(cwd, excluded))),
"Codex" => Some(Box::new(CodexReader::new(cwd, excluded))),
_ => None,
}
}
const BOOTSTRAP_MAX: u64 = 1024 * 1024;
fn read_range(path: &Path, start: u64, end: u64) -> std::io::Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let mut f = fs::File::open(path)?;
f.seek(SeekFrom::Start(start))?;
let len = (end - start) as usize;
let mut buf = vec![0u8; len];
f.read_exact(&mut buf)?;
Ok(buf)
}
fn parse_jsonl_lines(buf: &[u8]) -> Vec<Value> {
let text = String::from_utf8_lossy(buf);
text.lines()
.filter(|l| !l.is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
fn basename(path_str: &str) -> &str {
path_str.rsplit('/').next().unwrap_or(path_str)
}
fn claude_file_matches_cwd(path: &Path, cwd: &str) -> bool {
use std::io::BufRead;
let file = match fs::File::open(path) {
Ok(file) => file,
Err(_) => return false,
};
let reader = std::io::BufReader::new(file);
let mut saw_cwd_field = false;
for line in reader.lines().take(64) {
let line = match line {
Ok(value) => value,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(value) => value,
Err(_) => continue,
};
if let Some(entry_cwd) = value.get("cwd").and_then(Value::as_str) {
saw_cwd_field = true;
if entry_cwd == cwd {
return true;
}
}
}
!saw_cwd_field
}
pub struct ClaudeCodeReader {
cwd: String,
file_path: Option<PathBuf>,
file_size: u64,
user_task: Option<String>,
recent_actions: Vec<AgentAction>,
current_tool: Option<AgentAction>,
bootstrapped: bool,
token_count: u64,
context_limit: u64,
excluded_paths: Vec<PathBuf>,
}
impl ClaudeCodeReader {
pub fn new(cwd: &str, excluded: &[PathBuf]) -> Self {
Self {
cwd: cwd.to_string(),
file_path: None,
file_size: 0,
user_task: None,
recent_actions: Vec::new(),
current_tool: None,
bootstrapped: false,
token_count: 0,
context_limit: crate::types::context_limit_for_tool(Some("Claude Code")),
excluded_paths: excluded.to_vec(),
}
}
fn discover_file(&self) -> Option<PathBuf> {
let home = dirs_home()?;
let cwd_slug = self.cwd.replace('/', "-");
let project_dir = home.join(".claude").join("projects").join(&cwd_slug);
let entries = fs::read_dir(&project_dir).ok()?;
let mut files: Vec<(PathBuf, std::time::SystemTime)> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "jsonl"))
.filter(|e| claude_file_matches_cwd(&e.path(), &self.cwd))
.filter(|e| !self.excluded_paths.contains(&e.path()))
.filter_map(|e| {
let md = e.metadata().ok()?;
let mtime = md.modified().ok()?;
Some((e.path(), mtime))
})
.collect();
files.sort_by(|a, b| b.1.cmp(&a.1));
files.into_iter().next().map(|(p, _)| p)
}
fn parse_entries(&mut self, entries: &[Value]) {
for entry in entries {
let entry_type = entry.get("type").and_then(Value::as_str).unwrap_or("");
let msg = entry.get("message");
self.capture_claude_user_message(entry_type, msg);
self.capture_claude_assistant_message(entry_type, msg);
}
}
fn capture_claude_user_message(&mut self, entry_type: &str, msg: Option<&Value>) {
if entry_type != "user"
|| msg.and_then(|msg| msg.get("role").and_then(Value::as_str)) != Some("user")
{
return;
}
let Some(content) = msg.map(|msg| &msg["content"]) else {
return;
};
if let Some(text) = content.as_str() {
self.set_reader_user_task(text);
return;
}
for block in content.as_array().into_iter().flatten() {
if block.get("type").and_then(Value::as_str) != Some("text") {
continue;
}
if let Some(text) = block.get("text").and_then(Value::as_str) {
self.set_reader_user_task(text);
break;
}
}
}
fn capture_claude_assistant_message(&mut self, entry_type: &str, msg: Option<&Value>) {
if entry_type != "assistant"
|| msg.and_then(|msg| msg.get("role").and_then(Value::as_str)) != Some("assistant")
{
return;
}
if let Some(input_tokens) = msg
.and_then(|msg| msg.get("usage"))
.and_then(|usage| usage.get("input_tokens"))
.and_then(Value::as_u64)
{
self.token_count = input_tokens;
}
for block in msg
.and_then(|msg| msg.get("content"))
.and_then(Value::as_array)
.into_iter()
.flatten()
{
self.capture_claude_assistant_block(block);
}
}
fn capture_claude_assistant_block(&mut self, block: &Value) {
match block.get("type").and_then(Value::as_str).unwrap_or("") {
"tool_use" => {
let action = AgentAction {
tool: block
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
detail: extract_tool_detail(block),
};
self.record_reader_action(action, true);
}
"text" => {
if let Some(text) = block.get("text").and_then(Value::as_str) {
let trimmed = text.trim();
if trimmed.len() > 5 {
self.record_reader_action(
AgentAction {
tool: "said".to_string(),
detail: Some(truncate(trimmed, 100)),
},
false,
);
}
}
}
_ => {}
}
}
fn set_reader_user_task(&mut self, text: &str) {
let trimmed = text.trim();
if trimmed.is_empty() {
return;
}
self.user_task = Some(truncate(trimmed, 300));
}
fn record_reader_action(&mut self, action: AgentAction, set_current_tool: bool) {
self.recent_actions.push(action.clone());
cap_actions(&mut self.recent_actions, 10);
if set_current_tool {
self.current_tool = Some(action);
}
}
}
impl ContextReader for ClaudeCodeReader {
fn read(&mut self) -> Option<ContextSnapshot> {
let file_path = match self.file_path.clone() {
Some(p) if p.exists() => p,
_ => {
self.file_path = None;
self.discover_file()?
}
};
let stat = fs::metadata(&file_path).ok()?;
let current_size = stat.len();
if Some(&file_path) == self.file_path.as_ref() && current_size == self.file_size {
return None;
}
if self.file_path.as_ref() != Some(&file_path) {
self.file_path = Some(file_path.clone());
self.file_size = 0;
self.user_task = None;
self.recent_actions.clear();
self.current_tool = None;
self.bootstrapped = false;
self.token_count = 0;
self.context_limit = crate::types::context_limit_for_tool(Some("Claude Code"));
}
if !self.bootstrapped {
let start = current_size.saturating_sub(BOOTSTRAP_MAX);
match read_range(&file_path, start, current_size) {
Ok(buf) => {
let entries = parse_jsonl_lines(&buf);
self.parse_entries(&entries);
}
Err(e) => {
warn!(path = %file_path.display(), error = %e, "bootstrap read failed");
return None;
}
}
self.file_size = current_size;
self.bootstrapped = true;
} else {
match read_range(&file_path, self.file_size, current_size) {
Ok(buf) => {
let entries = parse_jsonl_lines(&buf);
self.parse_entries(&entries);
}
Err(e) => {
warn!(path = %file_path.display(), error = %e, "incremental read failed");
return None;
}
}
self.file_size = current_size;
}
Some(ContextSnapshot {
user_task: self.user_task.clone(),
recent_actions: last_n(&self.recent_actions, 5),
current_tool: self.current_tool.clone(),
token_count: self.token_count,
context_limit: self.context_limit,
})
}
fn claimed_path(&self) -> Option<PathBuf> {
self.file_path.clone()
}
}
pub struct CodexReader {
cwd: String,
file_path: Option<PathBuf>,
file_size: u64,
user_task: Option<String>,
recent_actions: Vec<AgentAction>,
current_tool: Option<AgentAction>,
bootstrapped: bool,
token_count: u64,
context_limit: u64,
excluded_paths: Vec<PathBuf>,
}
impl CodexReader {
pub fn new(cwd: &str, excluded: &[PathBuf]) -> Self {
Self {
cwd: cwd.to_string(),
file_path: None,
file_size: 0,
user_task: None,
recent_actions: Vec::new(),
current_tool: None,
bootstrapped: false,
token_count: 0,
context_limit: crate::types::context_limit_for_tool(Some("Codex")),
excluded_paths: excluded.to_vec(),
}
}
fn discover_file(&self) -> Option<PathBuf> {
let home = dirs_home()?;
let sessions_dir = home.join(".codex").join("sessions");
let years = sorted_subdirs_reverse(&sessions_dir, r"^\d{4}$")?;
for year in years {
let months = sorted_subdirs_reverse(&year, r"^\d{2}$");
for month in months.into_iter().flatten() {
let days = sorted_subdirs_reverse(&month, r"^\d{2}$");
for day in days.into_iter().flatten() {
let mut files: Vec<PathBuf> = fs::read_dir(&day)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
name.starts_with("rollout-") && name.ends_with(".jsonl")
})
.collect();
files.sort();
files.reverse();
for f in files {
if self.excluded_paths.contains(&f) {
continue;
}
if self.matches_cwd(&f) {
return Some(f);
}
}
}
}
}
None
}
fn matches_cwd(&self, path: &Path) -> bool {
let result: std::io::Result<bool> = (|| {
use std::io::Read;
let mut f = fs::File::open(path)?;
let mut buf = vec![0u8; 2048];
let n = f.read(&mut buf)?;
buf.truncate(n);
let text = String::from_utf8_lossy(&buf);
let first_line = text.lines().next().unwrap_or("");
if first_line.is_empty() {
return Ok(false);
}
let entry: Value = serde_json::from_str(first_line)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if entry.get("type").and_then(Value::as_str) == Some("session_meta") {
if let Some(payload) = entry.get("payload") {
return Ok(payload.get("cwd").and_then(Value::as_str) == Some(&self.cwd));
}
}
Ok(false)
})();
result.unwrap_or(false)
}
fn parse_entries(&mut self, entries: &[Value]) {
for entry in entries {
let entry_type = entry.get("type").and_then(Value::as_str).unwrap_or("");
let payload = entry
.get("payload")
.cloned()
.unwrap_or(Value::Object(Default::default()));
self.capture_codex_response_item_user(entry_type, &payload);
self.capture_codex_user_message(entry_type, &payload);
self.capture_codex_usage_response(entry_type, &payload);
self.capture_codex_token_count(entry_type, &payload);
self.capture_codex_function_call(entry_type, &payload);
self.capture_codex_agent_reasoning(entry_type, &payload);
self.capture_codex_reasoning_summary(entry_type, &payload);
}
}
fn capture_codex_response_item_user(&mut self, entry_type: &str, payload: &Value) {
if entry_type != "response_item"
|| payload.get("role").and_then(Value::as_str) != Some("user")
{
return;
}
for block in payload
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if block.get("type").and_then(Value::as_str) != Some("input_text") {
continue;
}
if let Some(text) = block.get("text").and_then(Value::as_str) {
self.set_codex_user_task(text, true);
}
}
}
fn capture_codex_user_message(&mut self, entry_type: &str, payload: &Value) {
if entry_type == "event_msg"
&& payload.get("type").and_then(Value::as_str) == Some("user_message")
{
if let Some(message) = payload.get("message").and_then(Value::as_str) {
self.set_codex_user_task(message, false);
}
}
}
fn capture_codex_usage_response(&mut self, entry_type: &str, payload: &Value) {
if entry_type != "response" {
return;
}
if let Some(input_tokens) = payload
.get("usage")
.and_then(|usage| usage.get("input_tokens"))
.and_then(Value::as_u64)
{
self.token_count = input_tokens;
}
}
fn capture_codex_token_count(&mut self, entry_type: &str, payload: &Value) {
if entry_type != "event_msg"
|| payload.get("type").and_then(Value::as_str) != Some("token_count")
{
return;
}
if let Some(input_tokens) = payload
.get("info")
.and_then(|info| info.get("total_token_usage"))
.and_then(|usage| usage.get("input_tokens"))
.and_then(Value::as_u64)
{
self.token_count = input_tokens;
}
let context_window = payload
.get("model_context_window")
.and_then(Value::as_u64)
.or_else(|| {
payload
.get("info")
.and_then(|info| info.get("model_context_window"))
.and_then(Value::as_u64)
});
if let Some(limit) = context_window.filter(|limit| *limit > 0) {
self.context_limit = limit;
}
}
fn capture_codex_function_call(&mut self, entry_type: &str, payload: &Value) {
if entry_type != "response_item"
|| payload.get("type").and_then(Value::as_str) != Some("function_call")
{
return;
}
let action = AgentAction {
tool: payload
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
detail: payload
.get("arguments")
.and_then(Value::as_str)
.and_then(parse_codex_function_call_detail),
};
self.record_codex_action(action, true);
}
fn capture_codex_agent_reasoning(&mut self, entry_type: &str, payload: &Value) {
if entry_type == "event_msg"
&& payload.get("type").and_then(Value::as_str) == Some("agent_reasoning")
{
if let Some(text) = payload.get("text").and_then(Value::as_str) {
self.set_codex_thinking(text);
}
}
}
fn capture_codex_reasoning_summary(&mut self, entry_type: &str, payload: &Value) {
if entry_type != "response_item"
|| payload.get("type").and_then(Value::as_str) != Some("reasoning")
{
return;
}
for summary in payload
.get("summary")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if summary.get("type").and_then(Value::as_str) != Some("summary_text") {
continue;
}
if let Some(text) = summary.get("text").and_then(Value::as_str) {
self.set_codex_thinking(text);
}
}
}
fn set_codex_user_task(&mut self, text: &str, skip_xml_like: bool) {
let trimmed = text.trim();
let looks_like_system_prompt = skip_xml_like && trimmed.starts_with('<');
if trimmed.is_empty() || trimmed.len() >= 1000 || looks_like_system_prompt {
return;
}
self.user_task = Some(truncate(trimmed, 300));
}
fn set_codex_thinking(&mut self, text: &str) {
self.current_tool = Some(AgentAction {
tool: "thinking".to_string(),
detail: Some(truncate(text, 100)),
});
}
fn record_codex_action(&mut self, action: AgentAction, set_current_tool: bool) {
self.recent_actions.push(action.clone());
cap_actions(&mut self.recent_actions, 10);
if set_current_tool {
self.current_tool = Some(action);
}
}
}
impl ContextReader for CodexReader {
fn read(&mut self) -> Option<ContextSnapshot> {
let file_path = self.file_path.clone().or_else(|| self.discover_file())?;
let stat = fs::metadata(&file_path).ok()?;
let current_size = stat.len();
if Some(&file_path) == self.file_path.as_ref() && current_size == self.file_size {
return None;
}
if self.file_path.as_ref() != Some(&file_path) {
self.file_path = Some(file_path.clone());
self.file_size = 0;
self.user_task = None;
self.recent_actions.clear();
self.current_tool = None;
self.bootstrapped = false;
self.token_count = 0;
self.context_limit = crate::types::context_limit_for_tool(Some("Codex"));
}
if !self.bootstrapped {
let start = current_size.saturating_sub(BOOTSTRAP_MAX);
match read_range(&file_path, start, current_size) {
Ok(buf) => {
let entries = parse_jsonl_lines(&buf);
self.parse_entries(&entries);
}
Err(e) => {
warn!(path = %file_path.display(), error = %e, "bootstrap read failed");
return None;
}
}
self.file_size = current_size;
self.bootstrapped = true;
} else {
match read_range(&file_path, self.file_size, current_size) {
Ok(buf) => {
let entries = parse_jsonl_lines(&buf);
self.parse_entries(&entries);
}
Err(e) => {
warn!(path = %file_path.display(), error = %e, "incremental read failed");
return None;
}
}
self.file_size = current_size;
}
Some(ContextSnapshot {
user_task: self.user_task.clone(),
recent_actions: last_n(&self.recent_actions, 5),
current_tool: self.current_tool.clone(),
token_count: self.token_count,
context_limit: self.context_limit,
})
}
fn claimed_path(&self) -> Option<PathBuf> {
self.file_path.clone()
}
}
fn dirs_home() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
fn sorted_subdirs_reverse(dir: &Path, pattern: &str) -> Option<Vec<PathBuf>> {
let re = regex::Regex::new(pattern).ok()?;
let entries = fs::read_dir(dir).ok()?;
let mut dirs: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().ok().map_or(false, |ft| ft.is_dir()))
.filter(|e| {
e.file_name()
.to_str()
.map_or(false, |name| re.is_match(name))
})
.map(|e| e.path())
.collect();
dirs.sort();
dirs.reverse();
Some(dirs)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max).collect()
}
}
fn cap_actions(actions: &mut Vec<AgentAction>, cap: usize) {
if actions.len() > cap {
let start = actions.len() - cap;
*actions = actions.split_off(start);
}
}
fn last_n(actions: &[AgentAction], n: usize) -> Vec<AgentAction> {
let start = actions.len().saturating_sub(n);
actions[start..].to_vec()
}
fn extract_tool_detail(block: &Value) -> Option<String> {
let input = block.get("input")?;
if let Some(fp) = input.get("file_path").and_then(Value::as_str) {
Some(basename(fp).to_string())
} else if let Some(cmd) = input.get("command").and_then(Value::as_str) {
Some(truncate(cmd, 80))
} else if let Some(pat) = input.get("pattern").and_then(Value::as_str) {
Some(pat.to_string())
} else {
None
}
}
fn parse_codex_function_call_detail(args_str: &str) -> Option<String> {
serde_json::from_str::<Value>(args_str)
.ok()
.and_then(|args| {
args.get("command")
.and_then(Value::as_str)
.map(|command| truncate(command, 80))
.or_else(|| {
args.get("file_path")
.and_then(Value::as_str)
.map(|file_path| basename(file_path).to_string())
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
#[test]
fn parse_jsonl_lines_skips_bad() {
let buf = b"{\"type\":\"user\"}\nnot json\n{\"type\":\"assistant\"}\n";
let entries = parse_jsonl_lines(buf);
assert_eq!(entries.len(), 2);
}
#[test]
fn truncate_works() {
assert_eq!(truncate("hello", 3), "hel");
assert_eq!(truncate("hi", 10), "hi");
}
#[test]
fn basename_extracts() {
assert_eq!(basename("/foo/bar/baz.rs"), "baz.rs");
assert_eq!(basename("baz.rs"), "baz.rs");
}
#[test]
fn cap_actions_limits() {
let mut actions: Vec<AgentAction> = (0..15)
.map(|i| AgentAction {
tool: format!("t{i}"),
detail: None,
})
.collect();
cap_actions(&mut actions, 10);
assert_eq!(actions.len(), 10);
assert_eq!(actions[0].tool, "t5");
}
#[test]
fn context_reader_for_known_tools() {
assert!(context_reader_for("Claude Code", "/tmp", &[]).is_some());
assert!(context_reader_for("Codex", "/tmp", &[]).is_some());
assert!(context_reader_for("Unknown", "/tmp", &[]).is_none());
}
#[test]
fn codex_reader_consumes_token_count_event_and_context_window() {
let mut reader = CodexReader::new("/tmp", &[]);
let entries = vec![serde_json::json!({
"type": "event_msg",
"payload": {
"type": "token_count",
"info": {
"total_token_usage": { "input_tokens": 99_735_u64 }
},
"model_context_window": 258_400_u64
}
})];
reader.parse_entries(&entries);
assert_eq!(reader.token_count, 99_735);
assert_eq!(reader.context_limit, 258_400);
}
#[test]
fn codex_reader_keeps_previous_context_limit_when_event_lacks_window() {
let mut reader = CodexReader::new("/tmp", &[]);
let default_limit = reader.context_limit;
let entries = vec![serde_json::json!({
"type": "event_msg",
"payload": {
"type": "token_count",
"info": {
"total_token_usage": { "input_tokens": 12_345_u64 }
}
}
})];
reader.parse_entries(&entries);
assert_eq!(reader.token_count, 12_345);
assert_eq!(reader.context_limit, default_limit);
}
#[test]
fn claude_reader_discovery_filters_slug_collision_by_exact_cwd() {
let _lock = crate::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let cwd_a = "/tmp/a-b/c";
let cwd_b = "/tmp/a/b-c";
let slug_a = cwd_a.replace('/', "-");
let slug_b = cwd_b.replace('/', "-");
assert_eq!(slug_a, slug_b, "test requires slug collision");
let project_dir = tmp.path().join(".claude").join("projects").join(slug_a);
fs::create_dir_all(&project_dir).expect("mkdir");
let file_a = project_dir.join("session-a.jsonl");
fs::write(
&file_a,
format!(
"{{\"type\":\"user\",\"cwd\":\"{}\",\"message\":{{\"role\":\"user\",\"content\":\"TASK_A\"}}}}\n",
cwd_a
),
)
.expect("write file a");
thread::sleep(Duration::from_millis(50));
let file_b = project_dir.join("session-b.jsonl");
fs::write(
&file_b,
format!(
"{{\"type\":\"user\",\"cwd\":\"{}\",\"message\":{{\"role\":\"user\",\"content\":\"TASK_B\"}}}}\n",
cwd_b
),
)
.expect("write file b");
let previous_home = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let reader = ClaudeCodeReader::new(cwd_a, &[]);
let discovered = reader.discover_file();
assert_eq!(discovered, Some(file_a));
if let Some(prev) = previous_home {
std::env::set_var("HOME", prev);
} else {
std::env::remove_var("HOME");
}
}
#[test]
fn claude_reader_read_bootstraps_and_then_reads_incremental_updates() {
let _lock = crate::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let cwd = "/tmp/project-alpha";
let slug = cwd.replace('/', "-");
let project_dir = tmp.path().join(".claude").join("projects").join(slug);
fs::create_dir_all(&project_dir).expect("project dir");
let session_file = project_dir.join("session.jsonl");
fs::write(
&session_file,
format!(
concat!(
"{{\"type\":\"user\",\"cwd\":\"{cwd}\",\"message\":{{\"role\":\"user\",\"content\":\"investigate startup\"}}}}\n",
"{{\"type\":\"assistant\",\"message\":{{\"role\":\"assistant\",\"usage\":{{\"input_tokens\":321}},\"content\":[{{\"type\":\"tool_use\",\"name\":\"exec\",\"input\":{{\"cmd\":\"ls\"}}}}]}}}}\n"
),
cwd = cwd
),
)
.expect("session file");
let previous_home = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let mut reader = ClaudeCodeReader::new(cwd, &[]);
let first = reader.read().expect("bootstrap snapshot");
assert_eq!(first.user_task.as_deref(), Some("investigate startup"));
assert_eq!(first.token_count, 321);
assert_eq!(
first.current_tool.as_ref().map(|tool| tool.tool.as_str()),
Some("exec")
);
assert!(reader.read().is_none(), "no new data should yield None");
fs::write(
&session_file,
format!(
concat!(
"{{\"type\":\"user\",\"cwd\":\"{cwd}\",\"message\":{{\"role\":\"user\",\"content\":\"investigate startup\"}}}}\n",
"{{\"type\":\"assistant\",\"message\":{{\"role\":\"assistant\",\"usage\":{{\"input_tokens\":321}},\"content\":[{{\"type\":\"tool_use\",\"name\":\"exec\",\"input\":{{\"cmd\":\"ls\"}}}}]}}}}\n",
"{{\"type\":\"assistant\",\"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"done reading logs\"}}]}}}}\n"
),
cwd = cwd
),
)
.expect("append assistant line");
let second = reader.read().expect("incremental snapshot");
assert_eq!(second.user_task.as_deref(), Some("investigate startup"));
assert!(
second
.recent_actions
.iter()
.any(|action| action.tool == "said"),
"incremental assistant text should be recorded"
);
if let Some(prev) = previous_home {
std::env::set_var("HOME", prev);
} else {
std::env::remove_var("HOME");
}
}
#[test]
fn codex_reader_read_discovers_matching_rollout_and_tracks_incremental_usage() {
let _lock = crate::test_support::ENV_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
let sessions_dir = tmp
.path()
.join(".codex")
.join("sessions")
.join("2026")
.join("03")
.join("16");
fs::create_dir_all(&sessions_dir).expect("sessions dir");
let other = sessions_dir.join("rollout-other.jsonl");
fs::write(
&other,
"{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/tmp/other\"}}\n",
)
.expect("other rollout");
let target = sessions_dir.join("rollout-target.jsonl");
fs::write(
&target,
concat!(
"{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/tmp/project\"}}\n",
"{\"type\":\"response_item\",\"payload\":{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"fix websocket bug\"}]}}\n",
"{\"type\":\"response_item\",\"payload\":{\"type\":\"function_call\",\"name\":\"exec\",\"arguments\":\"{\\\"cmd\\\":\\\"git status\\\"}\"}}\n",
"{\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"total_token_usage\":{\"input_tokens\":555}},\"model_context_window\":258400}}\n"
),
)
.expect("target rollout");
let previous_home = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let mut reader = CodexReader::new("/tmp/project", &[]);
let first = reader.read().expect("bootstrap snapshot");
assert_eq!(first.user_task.as_deref(), Some("fix websocket bug"));
assert_eq!(first.token_count, 555);
assert_eq!(first.context_limit, 258_400);
assert_eq!(
first.current_tool.as_ref().map(|tool| tool.tool.as_str()),
Some("exec")
);
fs::write(
&target,
concat!(
"{\"type\":\"session_meta\",\"payload\":{\"cwd\":\"/tmp/project\"}}\n",
"{\"type\":\"response_item\",\"payload\":{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"fix websocket bug\"}]}}\n",
"{\"type\":\"response_item\",\"payload\":{\"type\":\"function_call\",\"name\":\"exec\",\"arguments\":\"{\\\"cmd\\\":\\\"git status\\\"}\"}}\n",
"{\"type\":\"event_msg\",\"payload\":{\"type\":\"token_count\",\"info\":{\"total_token_usage\":{\"input_tokens\":555}},\"model_context_window\":258400}}\n",
"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"tighten the retry path\"}}\n"
),
)
.expect("updated rollout");
let second = reader.read().expect("incremental snapshot");
assert_eq!(second.user_task.as_deref(), Some("tighten the retry path"));
assert!(
reader.read().is_none(),
"steady state should not re-emit snapshot"
);
if let Some(prev) = previous_home {
std::env::set_var("HOME", prev);
} else {
std::env::remove_var("HOME");
}
}
}