From 4c566d484afc60bb9e33895810dafaa6acfe790b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 2 Oct 2025 13:06:21 -0700 Subject: [PATCH] Separate interactive and non-interactive sessions (#4612) Do not show exec session in VSCode/TUI selector. --- .../app-server/src/codex_message_processor.rs | 2 + codex-rs/app-server/src/message_processor.rs | 6 +- codex-rs/core/src/codex.rs | 10 +- codex-rs/core/src/conversation_manager.rs | 22 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/list.rs | 15 +- codex-rs/core/src/rollout/mod.rs | 4 + codex-rs/core/src/rollout/recorder.rs | 14 +- codex-rs/core/src/rollout/tests.rs | 265 ++++++++++++++---- codex-rs/core/tests/suite/cli_stream.rs | 2 +- codex-rs/core/tests/suite/client.rs | 3 +- codex-rs/exec/src/lib.rs | 7 +- codex-rs/mcp-server/src/message_processor.rs | 4 +- codex-rs/protocol/src/protocol.rs | 31 +- codex-rs/tui/src/app.rs | 6 +- codex-rs/tui/src/lib.rs | 10 +- codex-rs/tui/src/resume_picker.rs | 10 +- 17 files changed, 346 insertions(+), 66 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1e5eb82e..c158621d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -53,6 +53,7 @@ use codex_core::AuthManager; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::Cursor as RolloutCursor; +use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::NewConversation; use codex_core::RolloutRecorder; use codex_core::SessionMeta; @@ -708,6 +709,7 @@ impl CodexMessageProcessor { &self.config.codex_home, page_size, cursor_ref, + INTERACTIVE_SESSION_SOURCES, ) .await { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 9d8db2f9..15086c19 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -17,6 +17,7 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; +use codex_protocol::protocol::SessionSource; use std::sync::Arc; pub(crate) struct MessageProcessor { @@ -35,7 +36,10 @@ impl MessageProcessor { ) -> Self { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared(config.codex_home.clone(), false); - let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); + let conversation_manager = Arc::new(ConversationManager::new( + auth_manager.clone(), + SessionSource::VSCode, + )); let codex_message_processor = CodexMessageProcessor::new( auth_manager, conversation_manager, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3114fc30..2bba2b42 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -22,6 +22,7 @@ use codex_protocol::protocol::ConversationPathResponseEvent; use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TaskStartedEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; @@ -172,6 +173,7 @@ impl Codex { config: Config, auth_manager: Arc, conversation_history: InitialHistory, + session_source: SessionSource, ) -> CodexResult { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -200,6 +202,7 @@ impl Codex { auth_manager.clone(), tx_event.clone(), conversation_history, + session_source, ) .await .map_err(|e| { @@ -334,6 +337,7 @@ impl Session { auth_manager: Arc, tx_event: Sender, initial_history: InitialHistory, + session_source: SessionSource, ) -> anyhow::Result<(Arc, TurnContext)> { let ConfigureSession { provider, @@ -357,7 +361,11 @@ impl Session { let conversation_id = ConversationId::default(); ( conversation_id, - RolloutRecorderParams::new(conversation_id, user_instructions.clone()), + RolloutRecorderParams::new( + conversation_id, + user_instructions.clone(), + session_source, + ), ) } InitialHistory::Resumed(resumed_history) => ( diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 73d9b658..150794fc 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -17,6 +17,7 @@ use codex_protocol::ConversationId; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionSource; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -35,20 +36,25 @@ pub struct NewConversation { pub struct ConversationManager { conversations: Arc>>>, auth_manager: Arc, + session_source: SessionSource, } impl ConversationManager { - pub fn new(auth_manager: Arc) -> Self { + pub fn new(auth_manager: Arc, session_source: SessionSource) -> Self { Self { conversations: Arc::new(RwLock::new(HashMap::new())), auth_manager, + session_source, } } /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. pub fn with_auth(auth: CodexAuth) -> Self { - Self::new(crate::AuthManager::from_auth_for_testing(auth)) + Self::new( + crate::AuthManager::from_auth_for_testing(auth), + SessionSource::Exec, + ) } pub async fn new_conversation(&self, config: Config) -> CodexResult { @@ -64,7 +70,13 @@ impl ConversationManager { let CodexSpawnOk { codex, conversation_id, - } = Codex::spawn(config, auth_manager, InitialHistory::New).await?; + } = Codex::spawn( + config, + auth_manager, + InitialHistory::New, + self.session_source, + ) + .await?; self.finalize_spawn(codex, conversation_id).await } @@ -121,7 +133,7 @@ impl ConversationManager { let CodexSpawnOk { codex, conversation_id, - } = Codex::spawn(config, auth_manager, initial_history).await?; + } = Codex::spawn(config, auth_manager, initial_history, self.session_source).await?; self.finalize_spawn(codex, conversation_id).await } @@ -155,7 +167,7 @@ impl ConversationManager { let CodexSpawnOk { codex, conversation_id, - } = Codex::spawn(config, auth_manager, history).await?; + } = Codex::spawn(config, auth_manager, history, self.session_source).await?; self.finalize_spawn(codex, conversation_id).await } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 4c7dfdcb..94350a44 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -68,6 +68,7 @@ pub mod terminal; mod tool_apply_patch; pub mod turn_diff_tracker; pub use rollout::ARCHIVED_SESSIONS_SUBDIR; +pub use rollout::INTERACTIVE_SESSION_SOURCES; pub use rollout::RolloutRecorder; pub use rollout::SESSIONS_SUBDIR; pub use rollout::SessionMeta; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 755e63df..b27b3382 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -17,6 +17,7 @@ use super::SESSIONS_SUBDIR; use crate::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionSource; /// Returned page of conversation summaries. #[derive(Debug, Default, PartialEq)] @@ -52,6 +53,7 @@ struct HeadTailSummary { tail: Vec, saw_session_meta: bool, saw_user_event: bool, + source: Option, created_at: Option, updated_at: Option, } @@ -106,6 +108,7 @@ pub(crate) async fn get_conversations( codex_home: &Path, page_size: usize, cursor: Option<&Cursor>, + allowed_sources: &[SessionSource], ) -> io::Result { let mut root = codex_home.to_path_buf(); root.push(SESSIONS_SUBDIR); @@ -121,7 +124,8 @@ pub(crate) async fn get_conversations( let anchor = cursor.cloned(); - let result = traverse_directories_for_paths(root.clone(), page_size, anchor).await?; + let result = + traverse_directories_for_paths(root.clone(), page_size, anchor, allowed_sources).await?; Ok(result) } @@ -140,6 +144,7 @@ async fn traverse_directories_for_paths( root: PathBuf, page_size: usize, anchor: Option, + allowed_sources: &[SessionSource], ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); let mut scanned_files = 0usize; @@ -196,6 +201,13 @@ async fn traverse_directories_for_paths( let summary = read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT) .await .unwrap_or_default(); + if !allowed_sources.is_empty() + && !summary + .source + .is_some_and(|source| allowed_sources.iter().any(|s| s == &source)) + { + continue; + } // Apply filters: must have session meta and at least one user message event if summary.saw_session_meta && summary.saw_user_event { let HeadTailSummary { @@ -341,6 +353,7 @@ async fn read_head_and_tail( match rollout_line.item { RolloutItem::SessionMeta(session_meta_line) => { + summary.source = Some(session_meta_line.meta.source); summary.created_at = summary .created_at .clone() diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 3c4cb105..23410bd4 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -1,7 +1,11 @@ //! Rollout module: persistence and discovery of session rollout files. +use codex_protocol::protocol::SessionSource; + pub const SESSIONS_SUBDIR: &str = "sessions"; pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions"; +pub const INTERACTIVE_SESSION_SOURCES: &[SessionSource] = + &[SessionSource::Cli, SessionSource::VSCode]; pub mod list; pub(crate) mod policy; diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index f67c076c..95f5d479 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -32,6 +32,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::SessionSource; /// Records all [`ResponseItem`]s for a session and flushes them to disk after /// every update. @@ -53,6 +54,7 @@ pub enum RolloutRecorderParams { Create { conversation_id: ConversationId, instructions: Option, + source: SessionSource, }, Resume { path: PathBuf, @@ -71,10 +73,15 @@ enum RolloutCmd { } impl RolloutRecorderParams { - pub fn new(conversation_id: ConversationId, instructions: Option) -> Self { + pub fn new( + conversation_id: ConversationId, + instructions: Option, + source: SessionSource, + ) -> Self { Self::Create { conversation_id, instructions, + source, } } @@ -89,8 +96,9 @@ impl RolloutRecorder { codex_home: &Path, page_size: usize, cursor: Option<&Cursor>, + allowed_sources: &[SessionSource], ) -> std::io::Result { - get_conversations(codex_home, page_size, cursor).await + get_conversations(codex_home, page_size, cursor, allowed_sources).await } /// Attempt to create a new [`RolloutRecorder`]. If the sessions directory @@ -101,6 +109,7 @@ impl RolloutRecorder { RolloutRecorderParams::Create { conversation_id, instructions, + source, } => { let LogFileInfo { file, @@ -127,6 +136,7 @@ impl RolloutRecorder { originator: originator().value.clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), instructions, + source, }), ) } diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 7664ced0..b1f34e3a 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -12,6 +12,7 @@ use time::format_description::FormatItem; use time::macros::format_description; use uuid::Uuid; +use crate::rollout::INTERACTIVE_SESSION_SOURCES; use crate::rollout::list::ConversationItem; use crate::rollout::list::ConversationsPage; use crate::rollout::list::Cursor; @@ -28,13 +29,17 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::UserMessageEvent; +const NO_SOURCE_FILTER: &[SessionSource] = &[]; + fn write_session_file( root: &Path, ts_str: &str, uuid: Uuid, num_records: usize, + source: Option, ) -> std::io::Result<(OffsetDateTime, Uuid)> { let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); @@ -52,17 +57,23 @@ fn write_session_file( let file_path = dir.join(filename); let mut file = File::create(file_path)?; + let mut payload = serde_json::json!({ + "id": uuid, + "timestamp": ts_str, + "instructions": null, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + }); + + if let Some(source) = source { + payload["source"] = serde_json::to_value(source).unwrap(); + } + let meta = serde_json::json!({ "timestamp": ts_str, "type": "session_meta", - "payload": { - "id": uuid, - "timestamp": ts_str, - "instructions": null, - "cwd": ".", - "originator": "test_originator", - "cli_version": "test_version" - } + "payload": payload, }); writeln!(file, "{meta}")?; @@ -99,11 +110,34 @@ async fn test_list_conversations_latest_first() { let u3 = Uuid::from_u128(3); // Create three sessions across three days - write_session_file(home, "2025-01-01T12-00-00", u1, 3).unwrap(); - write_session_file(home, "2025-01-02T12-00-00", u2, 3).unwrap(); - write_session_file(home, "2025-01-03T12-00-00", u3, 3).unwrap(); + write_session_file( + home, + "2025-01-01T12-00-00", + u1, + 3, + Some(SessionSource::VSCode), + ) + .unwrap(); + write_session_file( + home, + "2025-01-02T12-00-00", + u2, + 3, + Some(SessionSource::VSCode), + ) + .unwrap(); + write_session_file( + home, + "2025-01-03T12-00-00", + u3, + 3, + Some(SessionSource::VSCode), + ) + .unwrap(); - let page = get_conversations(home, 10, None).await.unwrap(); + let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES) + .await + .unwrap(); // Build expected objects let p1 = home @@ -131,7 +165,8 @@ async fn test_list_conversations_latest_first() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })]; let head_2 = vec![serde_json::json!({ "id": u2, @@ -139,7 +174,8 @@ async fn test_list_conversations_latest_first() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })]; let head_1 = vec![serde_json::json!({ "id": u1, @@ -147,7 +183,8 @@ async fn test_list_conversations_latest_first() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })]; let expected_cursor: Cursor = @@ -198,13 +235,50 @@ async fn test_pagination_cursor() { let u5 = Uuid::from_u128(55); // Oldest to newest - write_session_file(home, "2025-03-01T09-00-00", u1, 1).unwrap(); - write_session_file(home, "2025-03-02T09-00-00", u2, 1).unwrap(); - write_session_file(home, "2025-03-03T09-00-00", u3, 1).unwrap(); - write_session_file(home, "2025-03-04T09-00-00", u4, 1).unwrap(); - write_session_file(home, "2025-03-05T09-00-00", u5, 1).unwrap(); + write_session_file( + home, + "2025-03-01T09-00-00", + u1, + 1, + Some(SessionSource::VSCode), + ) + .unwrap(); + write_session_file( + home, + "2025-03-02T09-00-00", + u2, + 1, + Some(SessionSource::VSCode), + ) + .unwrap(); + write_session_file( + home, + "2025-03-03T09-00-00", + u3, + 1, + Some(SessionSource::VSCode), + ) + .unwrap(); + write_session_file( + home, + "2025-03-04T09-00-00", + u4, + 1, + Some(SessionSource::VSCode), + ) + .unwrap(); + write_session_file( + home, + "2025-03-05T09-00-00", + u5, + 1, + Some(SessionSource::VSCode), + ) + .unwrap(); - let page1 = get_conversations(home, 2, None).await.unwrap(); + let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES) + .await + .unwrap(); let p5 = home .join("sessions") .join("2025") @@ -223,7 +297,8 @@ async fn test_pagination_cursor() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })]; let head_4 = vec![serde_json::json!({ "id": u4, @@ -231,7 +306,8 @@ async fn test_pagination_cursor() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })]; let expected_cursor1: Cursor = serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap(); @@ -258,9 +334,14 @@ async fn test_pagination_cursor() { }; assert_eq!(page1, expected_page1); - let page2 = get_conversations(home, 2, page1.next_cursor.as_ref()) - .await - .unwrap(); + let page2 = get_conversations( + home, + 2, + page1.next_cursor.as_ref(), + INTERACTIVE_SESSION_SOURCES, + ) + .await + .unwrap(); let p3 = home .join("sessions") .join("2025") @@ -279,7 +360,8 @@ async fn test_pagination_cursor() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })]; let head_2 = vec![serde_json::json!({ "id": u2, @@ -287,7 +369,8 @@ async fn test_pagination_cursor() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })]; let expected_cursor2: Cursor = serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap(); @@ -314,9 +397,14 @@ async fn test_pagination_cursor() { }; assert_eq!(page2, expected_page2); - let page3 = get_conversations(home, 2, page2.next_cursor.as_ref()) - .await - .unwrap(); + let page3 = get_conversations( + home, + 2, + page2.next_cursor.as_ref(), + INTERACTIVE_SESSION_SOURCES, + ) + .await + .unwrap(); let p1 = home .join("sessions") .join("2025") @@ -329,7 +417,8 @@ async fn test_pagination_cursor() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })]; let expected_cursor3: Cursor = serde_json::from_str(&format!("\"2025-03-01T09-00-00|{u1}\"")).unwrap(); @@ -355,9 +444,11 @@ async fn test_get_conversation_contents() { let uuid = Uuid::new_v4(); let ts = "2025-04-01T10-30-00"; - write_session_file(home, ts, uuid, 2).unwrap(); + write_session_file(home, ts, uuid, 2, Some(SessionSource::VSCode)).unwrap(); - let page = get_conversations(home, 1, None).await.unwrap(); + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES) + .await + .unwrap(); let path = &page.items[0].path; let content = get_conversation(path).await.unwrap(); @@ -375,7 +466,8 @@ async fn test_get_conversation_contents() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })]; let expected_cursor: Cursor = serde_json::from_str(&format!("\"{ts}|{uuid}\"")).unwrap(); let expected_page = ConversationsPage { @@ -393,7 +485,19 @@ async fn test_get_conversation_contents() { assert_eq!(page, expected_page); // Entire file contents equality - let meta = serde_json::json!({"timestamp": ts, "type": "session_meta", "payload": {"id": uuid, "timestamp": ts, "instructions": null, "cwd": ".", "originator": "test_originator", "cli_version": "test_version"}}); + let meta = serde_json::json!({ + "timestamp": ts, + "type": "session_meta", + "payload": { + "id": uuid, + "timestamp": ts, + "instructions": null, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "vscode", + } + }); let user_event = serde_json::json!({ "timestamp": ts, "type": "event_msg", @@ -428,6 +532,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> { cwd: ".".into(), originator: "test_originator".into(), cli_version: "test_version".into(), + source: SessionSource::VSCode, }, git: None, }), @@ -460,7 +565,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> { } drop(file); - let page = get_conversations(home, 1, None).await?; + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?; let item = page.items.first().expect("conversation item"); let tail_len = item.tail.len(); assert_eq!(tail_len, 10usize.min(total_messages)); @@ -511,6 +616,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> { cwd: ".".into(), originator: "test_originator".into(), cli_version: "test_version".into(), + source: SessionSource::VSCode, }, git: None, }), @@ -542,7 +648,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> { } drop(file); - let page = get_conversations(home, 1, None).await?; + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?; let tail = &page.items.first().expect("conversation item").tail; assert_eq!(tail.len(), 3); @@ -595,6 +701,7 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> { cwd: ".".into(), originator: "test_originator".into(), cli_version: "test_version".into(), + source: SessionSource::VSCode, }, git: None, }), @@ -640,7 +747,7 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> { writeln!(file, "{}", serde_json::to_string(&shutdown_event)?)?; drop(file); - let page = get_conversations(home, 1, None).await?; + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?; let tail = &page.items.first().expect("conversation item").tail; let expected: Vec = (0..4) @@ -678,11 +785,13 @@ async fn test_stable_ordering_same_second_pagination() { let u2 = Uuid::from_u128(2); let u3 = Uuid::from_u128(3); - write_session_file(home, ts, u1, 0).unwrap(); - write_session_file(home, ts, u2, 0).unwrap(); - write_session_file(home, ts, u3, 0).unwrap(); + write_session_file(home, ts, u1, 0, Some(SessionSource::VSCode)).unwrap(); + write_session_file(home, ts, u2, 0, Some(SessionSource::VSCode)).unwrap(); + write_session_file(home, ts, u3, 0, Some(SessionSource::VSCode)).unwrap(); - let page1 = get_conversations(home, 2, None).await.unwrap(); + let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES) + .await + .unwrap(); let p3 = home .join("sessions") @@ -703,7 +812,8 @@ async fn test_stable_ordering_same_second_pagination() { "instructions": null, "cwd": ".", "originator": "test_originator", - "cli_version": "test_version" + "cli_version": "test_version", + "source": "vscode", })] }; let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap(); @@ -730,9 +840,14 @@ async fn test_stable_ordering_same_second_pagination() { }; assert_eq!(page1, expected_page1); - let page2 = get_conversations(home, 2, page1.next_cursor.as_ref()) - .await - .unwrap(); + let page2 = get_conversations( + home, + 2, + page1.next_cursor.as_ref(), + INTERACTIVE_SESSION_SOURCES, + ) + .await + .unwrap(); let p1 = home .join("sessions") .join("2025") @@ -754,3 +869,59 @@ async fn test_stable_ordering_same_second_pagination() { }; assert_eq!(page2, expected_page2); } + +#[tokio::test] +async fn test_source_filter_excludes_non_matching_sessions() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let interactive_id = Uuid::from_u128(42); + let non_interactive_id = Uuid::from_u128(77); + + write_session_file( + home, + "2025-08-02T10-00-00", + interactive_id, + 2, + Some(SessionSource::Cli), + ) + .unwrap(); + write_session_file( + home, + "2025-08-01T10-00-00", + non_interactive_id, + 2, + Some(SessionSource::Exec), + ) + .unwrap(); + + let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES) + .await + .unwrap(); + let paths: Vec<_> = interactive_only + .items + .iter() + .map(|item| item.path.as_path()) + .collect(); + + assert_eq!(paths.len(), 1); + assert!(paths.iter().all(|path| { + path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl") + })); + + let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER) + .await + .unwrap(); + let all_paths: Vec<_> = all_sessions + .items + .into_iter() + .map(|item| item.path) + .collect(); + assert_eq!(all_paths.len(), 2); + assert!(all_paths.iter().any(|path| { + path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl") + })); + assert!(all_paths.iter().any(|path| { + path.ends_with("rollout-2025-08-01T10-00-00-00000000-0000-0000-0000-00000000004d.jsonl") + })); +} diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 5b698934..8fc36772 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -76,7 +76,7 @@ async fn chat_mode_stream_cli() { server.verify().await; // Verify a new session rollout was created and is discoverable via list_conversations - let page = RolloutRecorder::list_conversations(home.path(), 10, None) + let page = RolloutRecorder::list_conversations(home.path(), 10, None, &[]) .await .expect("list conversations"); assert!( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d4ff9da2..7157a105 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -17,6 +17,7 @@ use codex_core::built_in_model_providers; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_core::protocol::SessionSource; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; use codex_protocol::models::ReasoningItemReasoningSummary; @@ -538,7 +539,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { Ok(None) => panic!("No CodexAuth found in codex_home"), Err(e) => panic!("Failed to load CodexAuth: {e}"), }; - let conversation_manager = ConversationManager::new(auth_manager); + let conversation_manager = ConversationManager::new(auth_manager, SessionSource::Exec); let NewConversation { conversation: codex, .. diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0ec905de..98f3d22f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -17,6 +17,7 @@ use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_core::protocol::SessionSource; use codex_core::protocol::TaskCompleteEvent; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; @@ -237,7 +238,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any } let auth_manager = AuthManager::shared(config.codex_home.clone(), true); - let conversation_manager = ConversationManager::new(auth_manager.clone()); + let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec); // Handle resume subcommand by resolving a rollout path and using explicit resume API. let NewConversation { @@ -375,7 +376,9 @@ async fn resolve_resume_path( args: &crate::cli::ResumeArgs, ) -> anyhow::Result> { if args.last { - match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None).await { + match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None, &[]) + .await + { Ok(page) => Ok(page.items.first().map(|it| it.path.clone())), Err(e) => { error!("Error listing conversations: {e}"); diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 418c2a51..bb24f5f0 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -8,6 +8,7 @@ use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; use codex_protocol::ConversationId; +use codex_protocol::protocol::SessionSource; use codex_core::AuthManager; use codex_core::ConversationManager; @@ -53,7 +54,8 @@ impl MessageProcessor { ) -> Self { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared(config.codex_home.clone(), false); - let conversation_manager = Arc::new(ConversationManager::new(auth_manager)); + let conversation_manager = + Arc::new(ConversationManager::new(auth_manager, SessionSource::Mcp)); Self { outgoing, initialized: false, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 17a4a844..2c6d3b33 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -919,7 +919,20 @@ impl InitialHistory { } } -#[derive(Serialize, Deserialize, Clone, Default, Debug, TS)] +#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, TS, Default)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +pub enum SessionSource { + Cli, + #[default] + VSCode, + Exec, + Mcp, + #[serde(other)] + Unknown, +} + +#[derive(Serialize, Deserialize, Clone, Debug, TS)] pub struct SessionMeta { pub id: ConversationId, pub timestamp: String, @@ -927,6 +940,22 @@ pub struct SessionMeta { pub originator: String, pub cli_version: String, pub instructions: Option, + #[serde(default)] + pub source: SessionSource, +} + +impl Default for SessionMeta { + fn default() -> Self { + SessionMeta { + id: ConversationId::default(), + timestamp: String::new(), + cwd: PathBuf::new(), + originator: String::new(), + cli_version: String::new(), + instructions: None, + source: SessionSource::default(), + } + } } #[derive(Serialize, Deserialize, Debug, Clone, TS)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ab43ceb0..cb3dea5e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -18,6 +18,7 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::persist_model_selection; use codex_core::model_family::find_family_for_model; +use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::ConversationId; @@ -86,7 +87,10 @@ impl App { let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); - let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); + let conversation_manager = Arc::new(ConversationManager::new( + auth_manager.clone(), + SessionSource::Cli, + )); let enhanced_keys_supported = tui.enhanced_keys_supported(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d8dde74f..7807bf0a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -9,6 +9,7 @@ use codex_app_server_protocol::AuthMode; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; +use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -393,7 +394,14 @@ async fn run_ratatui_app( } } } else if cli.resume_last { - match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await { + match RolloutRecorder::list_conversations( + &config.codex_home, + 1, + None, + INTERACTIVE_SESSION_SOURCES, + ) + .await + { Ok(page) => page .items .first() diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index ded7d41a..eb2a9cd4 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -8,6 +8,7 @@ use chrono::Utc; use codex_core::ConversationItem; use codex_core::ConversationsPage; use codex_core::Cursor; +use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use color_eyre::eyre::Result; use crossterm::event::KeyCode; @@ -77,6 +78,7 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result Result<()> { - let page = RolloutRecorder::list_conversations(&self.codex_home, PAGE_SIZE, None).await?; + let page = RolloutRecorder::list_conversations( + &self.codex_home, + PAGE_SIZE, + None, + INTERACTIVE_SESSION_SOURCES, + ) + .await?; self.reset_pagination(); self.all_rows.clear(); self.filtered_rows.clear();