diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 00752e35cd..f2aa2c989c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4743,6 +4743,16 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, + "excludedThreadIds": { + "description": "Thread IDs to exclude before applying the result limit. Up to 100 entries; invalid IDs or a larger list are rejected, never truncated. Send the same exclusions on each page. Omitted, null, or empty means no exclusions.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c105c37195..bc34bb89dd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -23050,6 +23050,16 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, + "excludedThreadIds": { + "description": "Thread IDs to exclude before applying the result limit. Up to 100 entries; invalid IDs or a larger list are rejected, never truncated. Send the same exclusions on each page. Omitted, null, or empty means no exclusions.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 1a38e4fe91..61aac9999f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -20764,6 +20764,16 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, + "excludedThreadIds": { + "description": "Thread IDs to exclude before applying the result limit. Up to 100 entries; invalid IDs or a larger list are rejected, never truncated. Send the same exclusions on each page. Omitted, null, or empty means no exclusions.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index 83d8171483..3f861cdd8d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -72,6 +72,16 @@ ], "description": "Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." }, + "excludedThreadIds": { + "description": "Thread IDs to exclude before applying the result limit. Up to 100 entries; invalid IDs or a larger list are rejected, never truncated. Send the same exclusions on each page. Omitted, null, or empty means no exclusions.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index eef4301820..017b9d4d48 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index eb2ee812b3..c87829f970 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index 854b6ef92c..66f0d1e1cb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -12,6 +12,11 @@ cursor?: string | null, /** * Optional page size; defaults to a reasonable server-side value. */ limit?: number | null, /** + * Thread IDs to exclude before applying the result limit. Up to 100 + * entries; invalid IDs or a larger list are rejected, never truncated. + * Send the same exclusions on each page. Omitted, null, or empty means no exclusions. + */ +excludedThreadIds?: Array | null, /** * Optional sort key; defaults to created_at. */ sortKey?: ThreadSortKey | null, /** diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index c7f22e2930..a1f69125ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1398,6 +1398,11 @@ pub struct ThreadListParams { /// Optional page size; defaults to a reasonable server-side value. #[ts(optional = nullable)] pub limit: Option, + /// Thread IDs to exclude before applying the result limit. Up to 100 + /// entries; invalid IDs or a larger list are rejected, never truncated. + /// Send the same exclusions on each page. Omitted, null, or empty means no exclusions. + #[ts(optional = nullable)] + pub excluded_thread_ids: Option>, /// Optional sort key; defaults to created_at. #[ts(optional = nullable)] pub sort_key: Option, diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 4d3c6efa87..64c82fa5dc 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1301,6 +1301,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3 println!("< initialize response: {initialize:?}"); let response = client.thread_list(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(limit), diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a40c0c9d25..60b80f6b54 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1,3 +1,17 @@ +# Thread list exclusions + +`thread/list` accepts `excludedThreadIds`, an optional array of up to 100 +thread ID strings. Omitted, null, or empty lists do not exclude anything. +Invalid IDs or more than 100 entries return JSON-RPC error `-32602`; +duplicates count toward that bound and no entries are silently truncated. + +Exclusions apply before the returned result limit, along with the existing +source, provider, archive, project, section and cwd filters. Pages refill to +the requested limit (at most 100) unless history is exhausted. Send the same +exclusions and filters with each returned cursor. Clients that display a saved +manual order can load those summaries separately, then exclude the entire saved +ID set when listing the remainder. This field does not save or sort that order. + # Guardian circuit-breaker errors Set `auto_review.circuit_break_action = "strict"` to include `TooManyDenials` in diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 8bdf6bbed3..0b952c23d4 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -36,6 +36,7 @@ use std::ops::ControlFlow; pub(super) const THREAD_LIST_DEFAULT_LIMIT: usize = 25; pub(super) const THREAD_LIST_MAX_LIMIT: usize = 100; +const THREAD_LIST_MAX_EXCLUDED_IDS: usize = 100; const PAGINATED_FULL_HISTORY_DEPRECATION_SUMMARY: &str = "Full-history hydration is deprecated for paginated threads; use `excludeTurns: true`, then page with `thread/turns/list` and `thread/items/list`."; const PAGINATED_THREAD_READ_DEPRECATION_SUMMARY: &str = "Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`."; @@ -84,6 +85,7 @@ struct ThreadListFilters { search_term: Option, use_state_db_only: bool, relation_filter: Option, + excluded_thread_ids: HashSet, } // Persisted inputs that can change while loading configuration without the metadata permit. @@ -2528,6 +2530,7 @@ impl ThreadRequestProcessor { let ThreadListParams { cursor, limit, + excluded_thread_ids, sort_key, sort_direction, model_providers, @@ -2542,6 +2545,19 @@ impl ThreadRequestProcessor { parent_thread_id, ancestor_thread_id, } = params; + let excluded_thread_ids = excluded_thread_ids.unwrap_or_default(); + if excluded_thread_ids.len() > THREAD_LIST_MAX_EXCLUDED_IDS { + return Err(invalid_params(format!( + "excludedThreadIds accepts at most {THREAD_LIST_MAX_EXCLUDED_IDS} entries" + ))); + } + let excluded_thread_ids = excluded_thread_ids + .iter() + .map(|id| { + ThreadId::from_string(id) + .map_err(|err| invalid_params(format!("invalid excluded thread id: {err}"))) + }) + .collect::, _>>()?; if originators .as_ref() .is_some_and(|values| !values.is_empty()) @@ -2620,6 +2636,7 @@ impl ThreadRequestProcessor { search_term, use_state_db_only, relation_filter, + excluded_thread_ids, }, ) .await?; @@ -5483,6 +5500,7 @@ impl ThreadRequestProcessor { search_term, use_state_db_only, relation_filter, + excluded_thread_ids, } = filters; let mut cursor_obj = cursor; let mut last_cursor = cursor_obj.clone(); @@ -5542,9 +5560,10 @@ impl ThreadRequestProcessor { it.agent_nickname.clone(), it.agent_role.clone(), ); - if source_kind_filter - .as_ref() - .is_none_or(|filter| source_kind_matches(&source, filter)) + if !excluded_thread_ids.contains(&it.thread_id) + && source_kind_filter + .as_ref() + .is_none_or(|filter| source_kind_matches(&source, filter)) && cwd_filters.as_ref().is_none_or(|expected_cwds| { expected_cwds.iter().any(|expected_cwd| { path_utils::paths_match_after_normalization(&it.cwd, expected_cwd) diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 4b1306b34c..3620695b5c 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -606,6 +606,7 @@ source = {:?} let request_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: None, @@ -1971,6 +1972,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let request_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: None, @@ -2162,6 +2164,7 @@ required = true let request_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: None, @@ -2248,6 +2251,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res let request_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: None, @@ -2331,6 +2335,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions() let request_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: None, @@ -2460,6 +2465,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f let request_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: None, @@ -2577,6 +2583,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ let request_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: None, diff --git a/codex-rs/app-server/tests/suite/v2/projects.rs b/codex-rs/app-server/tests/suite/v2/projects.rs index cb22954921..c8d2a4a989 100644 --- a/codex-rs/app-server/tests/suite/v2/projects.rs +++ b/codex-rs/app-server/tests/suite/v2/projects.rs @@ -100,6 +100,7 @@ async fn projects_list_by_recency_and_preserve_metadata_timestamps() -> Result<( .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -324,6 +325,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -371,6 +373,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -421,6 +424,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -616,6 +620,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -707,6 +712,7 @@ async fn deleted_project_is_dropped_before_first_durable_thread_persistence() -> .request(|request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -897,6 +903,7 @@ async fn projects_validate_filters_cursors_and_sqlite_less_assignment() -> Resul for project_id in [String::new(), Uuid::now_v7().to_string()] { let request_id = server .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -983,6 +990,7 @@ async fn projects_validate_filters_cursors_and_sqlite_less_assignment() -> Resul assert_eq!(error.error.code, -32601); let list_id = unsupported_projects .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index ee43a62da9..8284d0f137 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -345,6 +345,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist .request(ClientRequest::ThreadList { request_id: RequestId::Integer(3), params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), diff --git a/codex-rs/app-server/tests/suite/v2/sqlite_recovery.rs b/codex-rs/app-server/tests/suite/v2/sqlite_recovery.rs index 70dab94da1..c97a9785f2 100644 --- a/codex-rs/app-server/tests/suite/v2/sqlite_recovery.rs +++ b/codex-rs/app-server/tests/suite/v2/sqlite_recovery.rs @@ -142,6 +142,7 @@ async fn state_recovery_restores_saved_threads() -> Result<()> { .await?; let request = server .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9ce85c98a0..40262b30c4 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -100,6 +100,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs async fn list_threads(mcp: &mut TestAppServer) -> Result { let list_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(50), diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 6a9696bb86..95df4fb098 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -102,6 +102,7 @@ async fn list_threads_with_sort( mcp.request(|request_id| ClientRequest::ThreadList { request_id, params: codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor, limit, @@ -142,6 +143,7 @@ async fn list_threads_for_relation( mcp.request(|request_id| ClientRequest::ThreadList { request_id, params: codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor, limit: Some(limit), @@ -257,6 +259,231 @@ async fn thread_list_basic_empty() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_exclusions_refill_across_storage_paths_and_scopes() -> Result<()> { + let home = TempDir::new()?; + create_minimal_config(home.path())?; + let ids = create_fake_rollouts( + home.path(), + /*count*/ 12, + |i| { + if i == 5 { + "other_provider" + } else { + "mock_provider" + } + }, + |i| { + timestamp_at( + /*year*/ 2025, + /*month*/ 3, + 20 - i as u32, + /*hour*/ 12, + /*minute*/ 0, + /*second*/ 0, + ) + }, + "List exclusions", + )?; + let mut server = init_mcp(home.path()).await?; + // Scan once so both DB-only and scan/repair modes see the same stored history. + let _: ThreadListResponse = server + .request(|request_id| ClientRequest::ThreadList { + request_id, + params: serde_json::from_value(json!({"limit": 100, "modelProviders": []})) + .expect("valid list"), + }) + .await?; + // Pin two rows. A null section scope must continue to exclude both of them. + for id in &ids[8..10] { + let _: ThreadSectionMoveResponse = server + .request(|request_id| ClientRequest::ThreadSectionMove { + request_id, + params: ThreadSectionMoveParams { + thread_id: id.clone(), + section_id: Some(codex_state::PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: None, + }, + }) + .await?; + } + for archived in [false, true] { + if archived { + for id in &ids { + let _: codex_app_server_protocol::ThreadArchiveResponse = server + .request(|request_id| ClientRequest::ThreadArchive { + request_id, + params: codex_app_server_protocol::ThreadArchiveParams { + thread_id: id.clone(), + }, + }) + .await?; + } + } + for (db_only, sort, direction, scoped) in [ + (false, "created_at", "desc", false), + (true, "created_at", "asc", true), + (true, "updated_at", "desc", false), + (false, "updated_at", "asc", true), + (true, "recency_at", "asc", false), + (false, "recency_at", "desc", true), + ] { + let mut query = json!({ + "limit": 100, "archived": archived, + "modelProviders": ["mock_provider"], "sourceKinds": ["cli"], + "sortKey": sort, "sortDirection": direction, "useStateDbOnly": db_only, + }); + if scoped { + query["sectionId"] = serde_json::Value::Null; + query["projectId"] = serde_json::Value::Null; + } + let baseline: ThreadListResponse = server + .request(|request_id| ClientRequest::ThreadList { + request_id, + params: serde_json::from_value(query.clone()).expect("valid list"), + }) + .await?; + let baseline: Vec<_> = baseline.data.into_iter().map(|t| t.id).collect(); + assert!(!baseline.contains(&ids[5])); + assert!(baseline.len() >= 7, "{query}: {baseline:?}"); + if scoped { + assert!(!baseline.contains(&ids[8])); + assert!(!baseline.contains(&ids[9])); + } + // Drop a dense prefix, a later row, a duplicate and an unknown ID. + let excluded = vec![ + baseline[0].clone(), + baseline[1].clone(), + baseline[2].clone(), + baseline[4].clone(), + baseline[0].clone(), + Uuid::new_v4().to_string(), + ]; + let expected: Vec<_> = baseline + .iter() + .filter(|id| !excluded.contains(id)) + .cloned() + .collect(); + query["excludedThreadIds"] = json!(excluded); + query["limit"] = json!(2); + let mut actual = Vec::new(); + for _ in 0..=baseline.len() { + let page: ThreadListResponse = server + .request(|request_id| ClientRequest::ThreadList { + request_id, + params: serde_json::from_value(query.clone()).expect("valid list"), + }) + .await?; + assert_eq!( + page.data.len(), + 2.min(expected.len() - actual.len()), + "must fill every eligible page: {query}" + ); + actual.extend(page.data.into_iter().map(|t| t.id)); + query["cursor"] = json!(page.next_cursor); + if page.next_cursor.is_none() { + break; + } + } + assert_eq!(actual, expected, "{query}"); + assert!(query["cursor"].is_null(), "{query}"); + } + } + Ok(()) +} + +#[tokio::test] +async fn thread_list_exclusions_validate_bound_without_truncating_and_keep_section_order() +-> Result<()> { + let home = TempDir::new()?; + create_minimal_config(home.path())?; + let ids = create_fake_rollouts( + home.path(), + /*count*/ 5, + |_| "mock_provider", + |i| { + timestamp_at( + /*year*/ 2025, + /*month*/ 4, + 10 + i as u32, + /*hour*/ 12, + /*minute*/ 0, + /*second*/ 0, + ) + }, + "Section list", + )?; + let mut server = init_mcp(home.path()).await?; + let _: ThreadListResponse = server + .request(|request_id| ClientRequest::ThreadList { + request_id, + params: serde_json::from_value(json!({"limit": 100, "modelProviders": []})) + .expect("valid list"), + }) + .await?; + for id in &ids { + let _: ThreadSectionMoveResponse = server + .request(|request_id| ClientRequest::ThreadSectionMove { + request_id, + params: ThreadSectionMoveParams { + thread_id: id.clone(), + section_id: Some(codex_state::PINNED_THREAD_SECTION_ID.to_string()), + before_thread_id: None, + }, + }) + .await?; + } + // The last entry must take effect even at the bound. Unknown/deleted IDs are allowed. + let mut excluded: Vec = (0..99).map(|_| Uuid::new_v4().to_string()).collect(); + excluded.push(ids[0].clone()); + let mut query = json!({ + "sectionId": codex_state::PINNED_THREAD_SECTION_ID, + "sortKey": "section_position", "limit": 2, "excludedThreadIds": excluded, + "modelProviders": [], + }); + let mut actual = Vec::new(); + for _ in 0..=ids.len() { + let page: ThreadListResponse = server + .request(|request_id| ClientRequest::ThreadList { + request_id, + params: serde_json::from_value(query.clone()).expect("valid list"), + }) + .await?; + assert_eq!(page.data.len(), 2.min(4 - actual.len())); + actual.extend(page.data.into_iter().map(|t| t.id)); + query["cursor"] = json!(page.next_cursor); + if page.next_cursor.is_none() { + break; + } + } + assert_eq!(actual, ids[1..]); + assert!(query["cursor"].is_null()); + excluded.push(ids[1].clone()); + for (input, expected_message) in [ + ( + json!(excluded), + "excludedThreadIds accepts at most 100 entries", + ), + (json!(["not-a-thread"]), "invalid excluded thread id"), + ] { + let req = server + .send_raw_request("thread/list", Some(json!({"excludedThreadIds": input}))) + .await?; + let err = timeout( + DEFAULT_READ_TIMEOUT, + server.read_stream_until_error_message(RequestId::Integer(req)), + ) + .await??; + assert_eq!(err.error.code, -32602); + assert!( + err.error.message.starts_with(expected_message), + "{}", + err.error.message + ); + } + Ok(()) +} + #[tokio::test] async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Result<()> { let responses = vec![ @@ -548,6 +775,7 @@ async fn thread_list_respects_cwd_filters() -> Result<()> { let mut mcp = init_mcp(codex_home.path()).await?; let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -662,6 +890,7 @@ sqlite = true let mut mcp = init_mcp(codex_home.path()).await?; let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -950,6 +1179,7 @@ sqlite = true let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -987,6 +1217,7 @@ sqlite = true let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -1017,6 +1248,7 @@ sqlite = true let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -1203,6 +1435,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { let mut mcp = init_mcp(codex_home.path()).await?; let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -1230,6 +1463,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { let thread_id = ThreadId::new().to_string(); let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -1517,6 +1751,7 @@ async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result .request(|request_id| ClientRequest::ThreadList { request_id, params: codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -2339,6 +2574,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() } = { let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(1), @@ -2381,6 +2617,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() } = { let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: Some(backwards_cursor), limit: Some(10), @@ -2813,6 +3050,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: Some("not-a-cursor".to_string()), limit: Some(2), diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index ed51accc1f..1a6437b63d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -88,6 +88,7 @@ async fn thread_section_move_pins_and_archives_before_first_turn() -> Result<()> timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(move_id)).await??; let list_params = ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(100), @@ -292,6 +293,7 @@ async fn thread_section_move_pins_and_unpins_with_filtered_recency_pagination() } let list_params = ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(1), @@ -484,6 +486,7 @@ async fn thread_sections_preserve_server_owned_manual_order_across_moves_and_res } let list_params = ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 6b7d924952..a79255bfdb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -402,6 +402,7 @@ async fn paginated_stored_thread_routes_projected_turns() -> Result<()> { let list_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(50), @@ -1132,6 +1133,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> .request(ClientRequest::ThreadList { request_id: RequestId::Integer(1), params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), @@ -1513,6 +1515,7 @@ async fn paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume // List should also surface the name. let list_id = mcp .send_thread_list_request(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(50), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 491207ad77..b27ac1a1b2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -4596,6 +4596,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected( .request(|request_id| ClientRequest::ThreadList { request_id, params: codex_app_server_protocol::ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(10), diff --git a/codex-rs/cli/tests/queue.rs b/codex-rs/cli/tests/queue.rs index b4f5f77a59..a10615d86c 100644 --- a/codex-rs/cli/tests/queue.rs +++ b/codex-rs/cli/tests/queue.rs @@ -405,6 +405,7 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res .request_typed(ClientRequest::ThreadList { request_id: RequestId::Integer(4), params: ThreadListParams { + excluded_thread_ids: None, cursor: None, limit: None, sort_key: None, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 8bf9262480..ee7f379a97 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1865,6 +1865,7 @@ async fn resolve_resume_thread_id( ClientRequest::ThreadList { request_id: RequestId::Integer(0), params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor, limit: Some(100), @@ -1951,6 +1952,7 @@ async fn resolve_resume_thread_id( ClientRequest::ThreadList { request_id: RequestId::Integer(0), params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor, limit: Some(100), diff --git a/codex-rs/tui/src/analytics/chats.rs b/codex-rs/tui/src/analytics/chats.rs index b1ffadbb70..50d7c8167a 100644 --- a/codex-rs/tui/src/analytics/chats.rs +++ b/codex-rs/tui/src/analytics/chats.rs @@ -144,6 +144,7 @@ pub(super) async fn roots( pub(super) fn list_params() -> ThreadListParams { ThreadListParams { + excluded_thread_ids: None, limit: Some(100), sort_key: Some(ThreadSortKey::UpdatedAt), sort_direction: Some(SortDirection::Desc), diff --git a/codex-rs/tui/src/app/agent_picker.rs b/codex-rs/tui/src/app/agent_picker.rs index 4f515392d6..085fc26657 100644 --- a/codex-rs/tui/src/app/agent_picker.rs +++ b/codex-rs/tui/src/app/agent_picker.rs @@ -40,6 +40,7 @@ impl App { .request_typed::(ClientRequest::ThreadList { request_id: RequestId::String(Uuid::new_v4().to_string()), params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor, limit: Some(AGENT_PICKER_PAGE_SIZE), diff --git a/codex-rs/tui/src/app/agents_overview_discovery.rs b/codex-rs/tui/src/app/agents_overview_discovery.rs index d14c4c01ce..af02a2197f 100644 --- a/codex-rs/tui/src/app/agents_overview_discovery.rs +++ b/codex-rs/tui/src/app/agents_overview_discovery.rs @@ -32,6 +32,7 @@ pub(crate) async fn list_pinned_threads( .request_typed::(ClientRequest::ThreadList { request_id: RequestId::String(Uuid::new_v4().to_string()), params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: cursor.clone(), limit: Some(100), @@ -169,6 +170,7 @@ impl SourcePage { .request_typed::(ClientRequest::ThreadList { request_id: RequestId::String(Uuid::new_v4().to_string()), params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: self.cursor.clone(), limit: Some(PAGE_SIZE), diff --git a/codex-rs/tui/src/dynamic_tools.rs b/codex-rs/tui/src/dynamic_tools.rs index 662ceac09e..d042db2580 100644 --- a/codex-rs/tui/src/dynamic_tools.rs +++ b/codex-rs/tui/src/dynamic_tools.rs @@ -349,6 +349,7 @@ async fn execute_inner( request(&handle, |request_id| ClientRequest::ThreadList { request_id, params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: arguments.cursor.clone(), limit: Some(limit), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index dc0fbd0547..226ecd5488 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -879,6 +879,7 @@ fn latest_session_lookup_params( lookup_mode: LatestSessionLookupMode, ) -> ThreadListParams { ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(1), diff --git a/codex-rs/tui/src/named_session_lookup.rs b/codex-rs/tui/src/named_session_lookup.rs index 7fc102cd6f..0f1a1044e9 100644 --- a/codex-rs/tui/src/named_session_lookup.rs +++ b/codex-rs/tui/src/named_session_lookup.rs @@ -64,6 +64,7 @@ pub(super) async fn lookup( loop { let response = app_server .thread_list(ThreadListParams { + excluded_thread_ids: None, originators: None, cursor, limit: Some(100), diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 40b6d74402..1142d40840 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -2090,6 +2090,7 @@ fn thread_list_params( use_state_db_only: bool, ) -> ThreadListParams { ThreadListParams { + excluded_thread_ids: None, originators: None, cursor, limit: Some(PAGE_SIZE as u32), diff --git a/codex-rs/tui/src/task_mentions.rs b/codex-rs/tui/src/task_mentions.rs index 5c1f7900a9..364e33abcd 100644 --- a/codex-rs/tui/src/task_mentions.rs +++ b/codex-rs/tui/src/task_mentions.rs @@ -90,6 +90,7 @@ pub(crate) fn spawn_search( "task-title-search-{request_generation}" )), params: ThreadListParams { + excluded_thread_ids: None, originators: None, cursor: None, limit: Some(MAX_SEARCH_RESULTS), diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 5bbd86342b..0a8d8fa442 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -10137,6 +10137,13 @@ class ThreadListParams(BaseModel): description="Optional cwd filter or filters; when set, only threads whose session cwd exactly matches one of these paths are returned." ), ] = None + excluded_thread_ids: Annotated[ + list[str] | None, + Field( + alias="excludedThreadIds", + description="Thread IDs to exclude before applying the result limit. Up to 100 entries; invalid IDs or a larger list are rejected, never truncated. Send the same exclusions on each page. Omitted, null, or empty means no exclusions.", + ), + ] = None limit: Annotated[ int | None, Field(description="Optional page size; defaults to a reasonable server-side value.", ge=0),