From 811882f6c24409f4ca4856962bc0e2476c152bd6 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 30 Jan 2026 11:37:08 -0800 Subject: [PATCH] Validate and fix bug report 10216 --- codex-rs/core/src/rollout/list.rs | 33 +++++++++- codex-rs/core/src/rollout/tests.rs | 98 ++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index edff86606b..6e273ea701 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -17,10 +17,13 @@ use uuid::Uuid; use super::ARCHIVED_SESSIONS_SUBDIR; use super::SESSIONS_SUBDIR; +use crate::event_mapping::parse_turn_item; use crate::protocol::EventMsg; use crate::state_db; use codex_file_search as file_search; use codex_protocol::ThreadId; +use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMetaLine; @@ -44,7 +47,9 @@ pub struct ThreadsPage { pub struct ThreadItem { /// Absolute path to the rollout file. pub path: PathBuf, - /// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line). + /// Parsed records used for summaries. + /// Includes the first `HEAD_RECORD_LIMIT` JSONL records (including meta line), + /// and may append the first user message found shortly after the head. pub head: Vec, /// RFC3339 timestamp string for when the session was created, if available. /// created_at comes from the filename timestamp with second precision. @@ -64,6 +69,7 @@ pub type ConversationsPage = ThreadsPage; #[derive(Default)] struct HeadTailSummary { head: Vec, + first_user_message: Option, saw_session_meta: bool, saw_user_event: bool, source: Option, @@ -940,6 +946,12 @@ impl<'a> ProviderMatcher<'a> { } } +fn head_contains_user_message(head: &[serde_json::Value]) -> bool { + head.iter() + .filter_map(|value| serde_json::from_value::(value.clone()).ok()) + .any(|item| matches!(parse_turn_item(&item), Some(TurnItem::UserMessage(_)))) +} + async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { use tokio::io::AsyncBufReadExt; @@ -985,10 +997,20 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { @@ -1009,6 +1031,13 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result std::io::Result { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let dt = PrimitiveDateTime::parse(ts_str, format) + .unwrap() + .assume_utc(); + let dir = root + .join("sessions") + .join(format!("{:04}", dt.year())) + .join(format!("{:02}", u8::from(dt.month()))) + .join(format!("{:02}", dt.day())); + fs::create_dir_all(&dir)?; + + let filename = format!("rollout-{ts_str}-{uuid}.jsonl"); + let file_path = dir.join(filename); + let mut file = File::create(&file_path)?; + + let payload = serde_json::json!({ + "id": uuid, + "timestamp": ts_str, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "cli", + "model_provider": TEST_PROVIDER, + }); + let meta = serde_json::json!({ + "timestamp": ts_str, + "type": "session_meta", + "payload": payload, + }); + writeln!(file, "{meta}")?; + + for i in 0..assistant_before_user { + let assistant_line = serde_json::json!({ + "timestamp": ts_str, + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": format!("assistant-{i}")} + ] + } + }); + writeln!(file, "{assistant_line}")?; + } + + let user_line = serde_json::json!({ + "timestamp": ts_str, + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "real question"} + ] + } + }); + writeln!(file, "{user_line}")?; + + let times = FileTimes::new().set_modified(dt.into()); + file.set_times(times)?; + Ok(file_path) +} + fn write_session_file_with_meta_payload( root: &Path, ts_str: &str, @@ -633,6 +707,30 @@ async fn test_list_threads_scans_past_head_for_user_event() { assert_eq!(page.items.len(), 1); } +#[tokio::test] +async fn test_head_summary_includes_first_user_message_beyond_head_limit() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let uuid = Uuid::from_u128(1234); + let ts = "2025-06-01T12-00-00"; + let path = write_session_file_with_delayed_user_response_item(home, ts, uuid, 12).unwrap(); + + let head = crate::rollout::list::read_head_for_summary(&path) + .await + .unwrap(); + + let preview = head + .iter() + .filter_map(|value| serde_json::from_value::(value.clone()).ok()) + .find_map(|item| match event_mapping::parse_turn_item(&item) { + Some(TurnItem::UserMessage(user)) => Some(user.message().to_string()), + _ => None, + }); + + assert_eq!(preview.as_deref(), Some("real question")); +} + #[tokio::test] async fn test_get_thread_contents() { let temp = TempDir::new().unwrap();