diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 7340871f0f..8cfb16eb38 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -314,6 +314,9 @@ pub struct ListConversationsParams { /// Opaque pagination cursor returned by a previous call. #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, + /// Optional model provider filter (matches against session metadata). + #[serde(skip_serializing_if = "Option::is_none")] + pub model_provider: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -325,6 +328,9 @@ pub struct ConversationSummary { /// RFC3339 timestamp string for the session start, if available. #[serde(skip_serializing_if = "Option::is_none")] pub timestamp: Option, + /// Optional model provider recorded for the session. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_provider: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ff1a43f5e5..0d37112dbb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -818,9 +818,14 @@ impl CodexMessageProcessor { request_id: RequestId, params: ListConversationsParams, ) { - let page_size = params.page_size.unwrap_or(25); + let ListConversationsParams { + page_size, + cursor, + model_provider, + } = params; + let page_size = page_size.unwrap_or(25); // Decode the optional cursor string to a Cursor via serde (Cursor implements Deserialize from string) - let cursor_obj: Option = match params.cursor { + let cursor_obj: Option = match cursor { Some(s) => serde_json::from_str::(&format!("\"{s}\"")).ok(), None => None, }; @@ -831,6 +836,7 @@ impl CodexMessageProcessor { page_size, cursor_ref, INTERACTIVE_SESSION_SOURCES, + model_provider.as_deref(), ) .await { @@ -1643,12 +1649,14 @@ fn extract_conversation_summary( } else { Some(session_meta.timestamp.clone()) }; + let model_provider = session_meta.model_provider.clone(); Some(ConversationSummary { conversation_id: session_meta.id, timestamp, path, preview: preview.to_string(), + model_provider, }) } @@ -1672,7 +1680,8 @@ mod tests { "cwd": "/", "originator": "codex", "cli_version": "0.0.0", - "instructions": null + "instructions": null, + "model_provider": "test-provider" }), json!({ "type": "message", @@ -1701,6 +1710,7 @@ mod tests { ); assert_eq!(summary.path, path); assert_eq!(summary.preview, "Count to 5"); + assert_eq!(summary.model_provider, Some("test-provider".to_string())); Ok(()) } } diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index 58c887da34..41fd7c98f3 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -57,6 +57,7 @@ async fn test_list_and_resume_conversations() { .send_list_conversations_request(ListConversationsParams { page_size: Some(2), cursor: None, + model_provider: None, }) .await .expect("send listConversations"); @@ -74,6 +75,8 @@ async fn test_list_and_resume_conversations() { // Newest first; preview text should match assert_eq!(items[0].preview, "Hello A"); assert_eq!(items[1].preview, "Hello B"); + assert_eq!(items[0].model_provider.as_deref(), Some("test-provider")); + assert_eq!(items[1].model_provider.as_deref(), Some("test-provider")); assert!(items[0].path.is_absolute()); assert!(next_cursor.is_some()); @@ -82,6 +85,7 @@ async fn test_list_and_resume_conversations() { .send_list_conversations_request(ListConversationsParams { page_size: Some(2), cursor: next_cursor, + model_provider: None, }) .await .expect("send listConversations page 2"); @@ -99,8 +103,59 @@ async fn test_list_and_resume_conversations() { } = to_response::(resp2).expect("deserialize response"); assert_eq!(items2.len(), 1); assert_eq!(items2[0].preview, "Hello C"); + assert_eq!(items2[0].model_provider.as_deref(), Some("test-provider")); assert!(next2.is_some()); + // Filtering by model provider should return only matching sessions. + let filter_req_id = mcp + .send_list_conversations_request(ListConversationsParams { + page_size: Some(10), + cursor: None, + model_provider: Some("test-provider".to_string()), + }) + .await + .expect("send listConversations filtered"); + let filter_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(filter_req_id)), + ) + .await + .expect("listConversations filtered timeout") + .expect("listConversations filtered resp"); + let ListConversationsResponse { + items: filtered_items, + next_cursor: filtered_next, + } = to_response::(filter_resp).expect("deserialize filtered"); + assert_eq!(filtered_items.len(), 3); + assert!(filtered_next.is_none()); + assert!( + filtered_items + .iter() + .all(|item| item.model_provider.as_deref() == Some("test-provider")) + ); + + let empty_req_id = mcp + .send_list_conversations_request(ListConversationsParams { + page_size: Some(10), + cursor: None, + model_provider: Some("other".to_string()), + }) + .await + .expect("send listConversations filtered empty"); + let empty_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(empty_req_id)), + ) + .await + .expect("listConversations filtered empty timeout") + .expect("listConversations filtered empty resp"); + let ListConversationsResponse { + items: empty_items, + next_cursor: empty_next, + } = to_response::(empty_resp).expect("deserialize filtered empty"); + assert!(empty_items.is_empty()); + assert!(empty_next.is_none()); + // Now resume one of the sessions and expect a SessionConfigured notification and response. let resume_req_id = mcp .send_resume_conversation_request(ResumeConversationParams { diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index b142bc2d04..e95420ed45 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -54,6 +54,7 @@ struct HeadTailSummary { saw_session_meta: bool, saw_user_event: bool, source: Option, + model_provider: Option, created_at: Option, updated_at: Option, } @@ -109,6 +110,7 @@ pub(crate) async fn get_conversations( page_size: usize, cursor: Option<&Cursor>, allowed_sources: &[SessionSource], + model_provider: Option<&str>, ) -> io::Result { let mut root = codex_home.to_path_buf(); root.push(SESSIONS_SUBDIR); @@ -124,8 +126,14 @@ pub(crate) async fn get_conversations( let anchor = cursor.cloned(); - let result = - traverse_directories_for_paths(root.clone(), page_size, anchor, allowed_sources).await?; + let result = traverse_directories_for_paths( + root.clone(), + page_size, + anchor, + allowed_sources, + model_provider, + ) + .await?; Ok(result) } @@ -145,6 +153,7 @@ async fn traverse_directories_for_paths( page_size: usize, anchor: Option, allowed_sources: &[SessionSource], + model_provider: Option<&str>, ) -> io::Result { let mut items: Vec = Vec::with_capacity(page_size); let mut scanned_files = 0usize; @@ -208,6 +217,12 @@ async fn traverse_directories_for_paths( { continue; } + if let Some(filter) = model_provider { + match summary.model_provider.as_deref() { + Some(provider) if provider == filter => {} + _ => 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 { @@ -354,6 +369,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.model_provider = session_meta_line.meta.model_provider.clone(); summary.created_at = summary .created_at .clone() diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index ff8ab301a2..8722886d1a 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -97,8 +97,16 @@ impl RolloutRecorder { page_size: usize, cursor: Option<&Cursor>, allowed_sources: &[SessionSource], + model_provider: Option<&str>, ) -> std::io::Result { - get_conversations(codex_home, page_size, cursor, allowed_sources).await + get_conversations( + codex_home, + page_size, + cursor, + allowed_sources, + model_provider, + ) + .await } /// Attempt to create a new [`RolloutRecorder`]. If the sessions directory diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 27c8509a67..34d5ff7757 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -39,6 +39,24 @@ fn write_session_file( uuid: Uuid, num_records: usize, source: Option, +) -> std::io::Result<(OffsetDateTime, Uuid)> { + write_session_file_with_provider( + root, + ts_str, + uuid, + num_records, + source, + Some("test-provider"), + ) +} + +fn write_session_file_with_provider( + root: &Path, + ts_str: &str, + uuid: Uuid, + num_records: usize, + source: Option, + model_provider: Option<&str>, ) -> std::io::Result<(OffsetDateTime, Uuid)> { let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); @@ -69,6 +87,9 @@ fn write_session_file( if let Some(source) = source { payload["source"] = serde_json::to_value(source).unwrap(); } + if let Some(provider) = model_provider { + payload["model_provider"] = serde_json::Value::String(provider.to_string()); + } let meta = serde_json::json!({ "timestamp": ts_str, @@ -135,7 +156,7 @@ async fn test_list_conversations_latest_first() { ) .unwrap(); - let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES) + let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES, None) .await .unwrap(); @@ -279,7 +300,7 @@ async fn test_pagination_cursor() { ) .unwrap(); - let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES) + let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES, None) .await .unwrap(); let p5 = home @@ -344,6 +365,7 @@ async fn test_pagination_cursor() { 2, page1.next_cursor.as_ref(), INTERACTIVE_SESSION_SOURCES, + None, ) .await .unwrap(); @@ -409,6 +431,7 @@ async fn test_pagination_cursor() { 2, page2.next_cursor.as_ref(), INTERACTIVE_SESSION_SOURCES, + None, ) .await .unwrap(); @@ -454,7 +477,7 @@ async fn test_get_conversation_contents() { let ts = "2025-04-01T10-30-00"; write_session_file(home, ts, uuid, 2, Some(SessionSource::VSCode)).unwrap(); - let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES) + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES, None) .await .unwrap(); let path = &page.items[0].path; @@ -575,7 +598,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> { } drop(file); - let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?; + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES, None).await?; let item = page.items.first().expect("conversation item"); let tail_len = item.tail.len(); assert_eq!(tail_len, 10usize.min(total_messages)); @@ -658,7 +681,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> { } drop(file); - let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES).await?; + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES, None).await?; let tail = &page.items.first().expect("conversation item").tail; assert_eq!(tail.len(), 3); @@ -757,7 +780,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, INTERACTIVE_SESSION_SOURCES).await?; + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES, None).await?; let tail = &page.items.first().expect("conversation item").tail; let expected: Vec = (0..4) @@ -799,7 +822,7 @@ async fn test_stable_ordering_same_second_pagination() { 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, INTERACTIVE_SESSION_SOURCES) + let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES, None) .await .unwrap(); @@ -856,6 +879,7 @@ async fn test_stable_ordering_same_second_pagination() { 2, page1.next_cursor.as_ref(), INTERACTIVE_SESSION_SOURCES, + None, ) .await .unwrap(); @@ -906,7 +930,7 @@ async fn test_source_filter_excludes_non_matching_sessions() { ) .unwrap(); - let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES) + let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES, None) .await .unwrap(); let paths: Vec<_> = interactive_only @@ -920,7 +944,7 @@ async fn test_source_filter_excludes_non_matching_sessions() { 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) + let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, None) .await .unwrap(); let all_paths: Vec<_> = all_sessions @@ -936,3 +960,68 @@ async fn test_source_filter_excludes_non_matching_sessions() { path.ends_with("rollout-2025-08-01T10-00-00-00000000-0000-0000-0000-00000000004d.jsonl") })); } + +#[tokio::test] +async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<()> { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let alpha_id = Uuid::from_u128(1); + let beta_id = Uuid::from_u128(2); + let none_id = Uuid::from_u128(3); + + write_session_file_with_provider( + home, + "2025-09-01T12-00-00", + alpha_id, + 1, + Some(SessionSource::VSCode), + Some("alpha"), + )?; + write_session_file_with_provider( + home, + "2025-09-01T11-00-00", + beta_id, + 1, + Some(SessionSource::VSCode), + Some("beta"), + )?; + write_session_file_with_provider( + home, + "2025-09-01T10-00-00", + none_id, + 1, + Some(SessionSource::VSCode), + None, + )?; + + let alpha_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, Some("alpha")).await?; + assert_eq!(alpha_sessions.items.len(), 1); + let alpha_id_str = alpha_id.to_string(); + let alpha_head = alpha_sessions.items[0] + .head + .first() + .and_then(|value| value.get("id")) + .and_then(serde_json::Value::as_str); + assert_eq!(alpha_head, Some(alpha_id_str.as_str())); + + let beta_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, Some("beta")).await?; + assert_eq!(beta_sessions.items.len(), 1); + let beta_id_str = beta_id.to_string(); + let beta_head = beta_sessions + .items + .first() + .and_then(|item| item.head.first()) + .and_then(|value| value.get("id")) + .and_then(serde_json::Value::as_str); + assert_eq!(beta_head, Some(beta_id_str.as_str())); + + let unknown_sessions = + get_conversations(home, 10, None, NO_SOURCE_FILTER, Some("unknown")).await?; + assert!(unknown_sessions.items.is_empty()); + + let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER, None).await?; + assert_eq!(all_sessions.items.len(), 3); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 497730926a..ee4d5ecb70 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -75,7 +75,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, &[], None) .await .expect("list conversations"); assert!( diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 2e615df0c6..9089a20b23 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -389,8 +389,14 @@ 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, + &[], + None, + ) + .await { Ok(page) => Ok(page.items.first().map(|it| it.path.clone())), Err(e) => { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 50f6b1a0dd..6ce7fe7231 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -396,6 +396,7 @@ async fn run_ratatui_app( 1, None, INTERACTIVE_SESSION_SOURCES, + None, ) .await { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index b735ff9374..9534911142 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -77,6 +77,7 @@ pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result