diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 110d274118..934de59603 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5203,6 +5203,16 @@ "null" ] }, + "includedThreadIds": { + "description": "Restrict results to up to 100 thread IDs before pagination. Omit or send null for unrestricted results. An empty list matches nothing. Exclusions still apply. Repeat the same IDs on each page. The local store uses indexed metadata for this filter.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -5279,7 +5289,7 @@ ] }, "useStateDbOnly": { - "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "description": "If true, the local store returns indexed metadata without scanning JSONL rollouts for repairs. Requests with `includedThreadIds` always use indexed metadata.", "type": "boolean" } }, 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 c3dd12e050..7db5e5af8b 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 @@ -24984,6 +24984,16 @@ "null" ] }, + "includedThreadIds": { + "description": "Restrict results to up to 100 thread IDs before pagination. Omit or send null for unrestricted results. An empty list matches nothing. Exclusions still apply. Repeat the same IDs on each page. The local store uses indexed metadata for this filter.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -25060,7 +25070,7 @@ ] }, "useStateDbOnly": { - "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "description": "If true, the local store returns indexed metadata without scanning JSONL rollouts for repairs. Requests with `includedThreadIds` always use indexed metadata.", "type": "boolean" } }, 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 4951cb455f..7957f73971 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 @@ -22680,6 +22680,16 @@ "null" ] }, + "includedThreadIds": { + "description": "Restrict results to up to 100 thread IDs before pagination. Omit or send null for unrestricted results. An empty list matches nothing. Exclusions still apply. Repeat the same IDs on each page. The local store uses indexed metadata for this filter.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -22756,7 +22766,7 @@ ] }, "useStateDbOnly": { - "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "description": "If true, the local store returns indexed metadata without scanning JSONL rollouts for repairs. Requests with `includedThreadIds` always use indexed metadata.", "type": "boolean" } }, 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 3d1b11d876..44e5c75f96 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -86,6 +86,16 @@ "null" ] }, + "includedThreadIds": { + "description": "Restrict results to up to 100 thread IDs before pagination. Omit or send null for unrestricted results. An empty list matches nothing. Exclusions still apply. Repeat the same IDs on each page. The local store uses indexed metadata for this filter.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "limit": { "description": "Optional page size; defaults to a reasonable server-side value.", "format": "uint32", @@ -162,7 +172,7 @@ ] }, "useStateDbOnly": { - "description": "If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + "description": "If true, the local store returns indexed metadata without scanning JSONL rollouts for repairs. Requests with `includedThreadIds` always use indexed metadata.", "type": "boolean" } }, 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 33a8250602..e82ec412f1 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 a64fb047f4..772c99e365 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 114fe4f4ef..1479515b1c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -15,6 +15,11 @@ cursor?: string | null, /** * Optional page size; defaults to a reasonable server-side value. */ limit?: number | null, /** + * Restrict results to up to 100 thread IDs before pagination. Omit or send null + * for unrestricted results. An empty list matches nothing. Exclusions still apply. + * Repeat the same IDs on each page. The local store uses indexed metadata for this filter. + */ +includedThreadIds?: Array | 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. @@ -51,9 +56,8 @@ sectionId?: string | null, /** * exactly matches one of these paths are returned. */ cwd?: string | Array | null, /** - * If true, return from the state DB without scanning JSONL rollouts to - * repair thread metadata. Omitted or false preserves scan-and-repair - * behavior. + * If true, the local store returns indexed metadata without scanning JSONL rollouts + * for repairs. Requests with `includedThreadIds` always use indexed metadata. */ useStateDbOnly?: boolean, /** * Optional substring filter for the extracted thread title. 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 821eff1733..0d66998c90 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1461,6 +1461,11 @@ pub struct ThreadListParams { /// Optional page size; defaults to a reasonable server-side value. #[ts(optional = nullable)] pub limit: Option, + /// Restrict results to up to 100 thread IDs before pagination. Omit or send null + /// for unrestricted results. An empty list matches nothing. Exclusions still apply. + /// Repeat the same IDs on each page. The local store uses indexed metadata for this filter. + #[ts(optional = nullable)] + pub included_thread_ids: 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. @@ -1514,9 +1519,8 @@ pub struct ThreadListParams { /// exactly matches one of these paths are returned. #[ts(optional = nullable, type = "string | Array | null")] pub cwd: Option, - /// If true, return from the state DB without scanning JSONL rollouts to - /// repair thread metadata. Omitted or false preserves scan-and-repair - /// behavior. + /// If true, the local store returns indexed metadata without scanning JSONL rollouts + /// for repairs. Requests with `includedThreadIds` always use indexed metadata. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub use_state_db_only: bool, /// Optional substring filter for the extracted thread title. diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 97cb1fe5ec..e9a9e1f52e 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1169,6 +1169,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3 let response = client.thread_list(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 957d4885c6..4daa368c98 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -33,19 +33,23 @@ across account switches. Resume and revert preserve that distinction; forks are Scoped requests skip candidates whose rollout header cannot be read. API-key logins see all local history. Direct access by thread ID remains available. -# Thread list exclusions +# Thread list ID filters -`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. +`thread/list` accepts `includedThreadIds` to list specific saved threads and +`excludedThreadIds` to omit them. Each accepts up to 100 thread ID strings. +Omitting an array or sending null leaves that filter unrestricted. An empty +inclusion matches nothing. An empty exclusion removes nothing. Invalid IDs +or more than 100 entries in either array return JSON-RPC error `-32602`. +Duplicates count toward each bound. -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. +Both ID filters apply alongside the other list filters, including `currentAccountOnly`, +before pagination. Pages fill to the requested limit (up to 100) or exhaust the matches. +Exclusions win when an ID appears in both arrays. Send the same filters with +each cursor. The regular list sort determines the order of matches. +The local store uses indexed metadata when an inclusion array is present, even +when `useStateDbOnly` is false. IDs absent from its index produce no match. +Older App Servers ignore `includedThreadIds`. Clients that reconcile saved IDs +must establish server support before treating missing matches as removals. DB-only requests (`useStateDbOnly: true`) return JSON-RPC error `-32603` when the local state DB cannot serve the query, even for an empty cwd filter. 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 a1e74cb2f8..7b81d75021 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -56,7 +56,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 THREAD_LIST_MAX_FILTER_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`."; @@ -106,6 +106,7 @@ struct ThreadListFilters { search_term: Option, use_state_db_only: bool, relation_filter: Option, + included_thread_ids: Option>, excluded_thread_ids: HashSet, } @@ -2755,6 +2756,7 @@ impl ThreadRequestProcessor { current_account_only, cursor, limit, + included_thread_ids, excluded_thread_ids, sort_key, sort_direction, @@ -2771,19 +2773,11 @@ impl ThreadRequestProcessor { ancestor_thread_id, attached_to_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::, _>>()?; + let included_thread_ids = included_thread_ids + .map(|ids| parse_thread_list_ids(ids, "included")) + .transpose()?; + let excluded_thread_ids = + parse_thread_list_ids(excluded_thread_ids.unwrap_or_default(), "excluded")?; if originators .as_ref() .is_some_and(|values| !values.is_empty()) @@ -2896,6 +2890,7 @@ impl ThreadRequestProcessor { search_term, use_state_db_only, relation_filter, + included_thread_ids, excluded_thread_ids, }, ) @@ -6072,6 +6067,7 @@ impl ThreadRequestProcessor { search_term, use_state_db_only, relation_filter, + included_thread_ids, excluded_thread_ids, } = filters; let mut cursor_obj = cursor; @@ -6110,6 +6106,7 @@ impl ThreadRequestProcessor { .thread_store .list_threads(StoreListThreadsParams { page_size, + included_thread_ids: included_thread_ids.clone(), cursor: cursor_obj.clone(), sort_key, sort_direction: store_sort_direction, @@ -6185,6 +6182,21 @@ impl ThreadRequestProcessor { } } +fn parse_thread_list_ids>( + ids: Vec, + kind: &str, +) -> Result { + if ids.len() > THREAD_LIST_MAX_FILTER_IDS { + return Err(invalid_params(format!( + "{kind}ThreadIds accepts at most {THREAD_LIST_MAX_FILTER_IDS} entries" + ))); + } + ids.into_iter() + .map(ThreadId::try_from) + .collect::>() + .map_err(|err| invalid_params(format!("invalid {kind} thread id: {err}"))) +} + const THREAD_TURNS_DEFAULT_LIMIT: usize = 25; const THREAD_TURNS_MAX_LIMIT: usize = 100; const THREAD_ITEMS_DEFAULT_LIMIT: usize = 25; 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 4b74133e8f..2648edda2d 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 @@ -612,6 +612,7 @@ source = {:?} .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -2132,6 +2133,7 @@ env_key = "TEST_CATALOG_KEY""# .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -2340,6 +2342,7 @@ required = true .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -2430,6 +2433,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -2517,6 +2521,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions() .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -2650,6 +2655,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -2771,6 +2777,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/app-server/tests/suite/v2/projects.rs b/codex-rs/app-server/tests/suite/v2/projects.rs index fb89279ea9..4bf1f0a348 100644 --- a/codex-rs/app-server/tests/suite/v2/projects.rs +++ b/codex-rs/app-server/tests/suite/v2/projects.rs @@ -102,6 +102,7 @@ async fn projects_list_by_recency_and_preserve_metadata_timestamps() -> Result<( params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -330,6 +331,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -381,6 +383,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -435,6 +438,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -634,6 +638,7 @@ async fn projects_persist_and_assign_threads() -> Result<()> { params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -729,6 +734,7 @@ async fn deleted_project_is_dropped_before_first_durable_thread_persistence() -> params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -923,6 +929,7 @@ async fn projects_validate_filters_cursors_and_sqlite_less_assignment() -> Resul .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -1013,6 +1020,7 @@ async fn projects_validate_filters_cursors_and_sqlite_less_assignment() -> Resul .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, 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 11496ca85a..6252312336 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 @@ -257,6 +257,7 @@ async fn thread_list_repeated_cursor_returns_internal_error() -> Result<()> { params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -318,6 +319,7 @@ async fn thread_list_repeated_cursor_returns_internal_error() -> Result<()> { params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -503,6 +505,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, 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 a59eff0be6..5d438e1fa6 100644 --- a/codex-rs/app-server/tests/suite/v2/sqlite_recovery.rs +++ b/codex-rs/app-server/tests/suite/v2/sqlite_recovery.rs @@ -237,6 +237,7 @@ async fn state_recovery_restores_saved_threads() -> Result<()> { .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: 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 e8111e7fb4..7da78ead50 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -118,6 +118,7 @@ async fn list_threads(mcp: &mut TestAppServer) -> Result { .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, 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 6ad3d4bd72..d8c652aed7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; @@ -14,6 +15,7 @@ use chrono::Utc; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::ProjectImportResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::SortDirection; @@ -50,6 +52,7 @@ use codex_state::DirectionalThreadSpawnEdgeStatus; use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; use std::cmp::Reverse; use std::collections::HashMap; @@ -90,6 +93,17 @@ async fn list_threads( .await } +async fn list_ids_and_cursor( + server: &mut TestAppServer, + params: Value, +) -> Result<(Vec, Option)> { + let request = server.send_raw_request("thread/list", Some(params)).await?; + let page: ThreadListResponse = + timeout(DEFAULT_READ_TIMEOUT, server.read_response(request)).await??; + let ids = page.data.into_iter().map(|thread| thread.id).collect(); + Ok((ids, page.next_cursor)) +} + async fn list_threads_with_sort( mcp: &mut TestAppServer, cursor: Option, @@ -104,6 +118,7 @@ async fn list_threads_with_sort( params: codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -148,6 +163,7 @@ async fn list_threads_for_relation( params: codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -265,6 +281,89 @@ async fn thread_list_basic_empty() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_inclusions_reconcile_old_moved_and_archived_ids() -> 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, + 1 + i as u32, + /*hour*/ 12, + /*minute*/ 0, + /*second*/ 0, + ) + }, + "Saved thread", + )?; + let (old, moved, archived, newest) = (&ids[0], &ids[1], &ids[2], &ids[4]); + let mut server = init_mcp(home.path()).await?; + // Index the fixture rollouts before the project imports and filtered queries. + list_ids_and_cursor(&mut server, json!({"modelProviders": []})).await?; + + let request = server + .send_raw_request( + "project/import", + Some(json!({ + "name": "Saved", "roots": [], "threads": ids, "idempotencyKey": "saved" + })), + ) + .await?; + let saved: ProjectImportResponse = server.read_response(request).await?; + let request = server + .send_raw_request( + "project/import", + Some(json!({ + "name": "Other", "roots": [], "threads": [moved], "idempotencyKey": "other" + })), + ) + .await?; + let _: ProjectImportResponse = server.read_response(request).await?; + let request = server + .send_raw_request("thread/archive", Some(json!({"threadId": archived}))) + .await?; + let _: codex_app_server_protocol::ThreadArchiveResponse = server.read_response(request).await?; + + let candidates = json!([old, moved, archived, old, Uuid::new_v4()]); + for (mut query, expected, has_next) in [ + (json!({"includedThreadIds": null}), Some(newest), true), + (json!({"includedThreadIds": candidates}), Some(old), false), + ( + json!({"includedThreadIds": candidates, "archived": true}), + Some(archived), + false, + ), + ( + json!({"includedThreadIds": candidates, "excludedThreadIds": [old]}), + None, + false, + ), + (json!({"includedThreadIds": []}), None, false), + ] { + query["projectId"] = json!(saved.project.id); + query["modelProviders"] = json!([]); + query["limit"] = json!(1); + let (listed, next_cursor) = list_ids_and_cursor(&mut server, query.clone()).await?; + assert_eq!(listed, expected.cloned().as_slice(), "{query}"); + assert_eq!(next_cursor.is_some(), has_next, "{query}"); + } + let mut query = json!({ + "projectId": saved.project.id, "includedThreadIds": [old, newest], + "modelProviders": [], "limit": 1, + }); + let (listed, cursor) = list_ids_and_cursor(&mut server, query.clone()).await?; + assert_eq!(listed, [newest.as_str()]); + query["cursor"] = json!(cursor.context("another included thread should remain")?); + let page = list_ids_and_cursor(&mut server, query).await?; + assert_eq!(page, (vec![old.clone()], None)); + Ok(()) +} + #[tokio::test] async fn thread_list_exclusions_refill_across_storage_paths_and_scopes() -> Result<()> { let home = TempDir::new()?; @@ -399,8 +498,7 @@ async fn thread_list_exclusions_refill_across_storage_paths_and_scopes() -> Resu } #[tokio::test] -async fn thread_list_exclusions_validate_bound_without_truncating_and_keep_section_order() --> Result<()> { +async fn thread_list_validates_id_filter_bounds_and_preserves_section_order() -> Result<()> { let home = TempDir::new()?; create_minimal_config(home.path())?; let ids = create_fake_rollouts( @@ -464,16 +562,37 @@ async fn thread_list_exclusions_validate_bound_without_truncating_and_keep_secti } assert_eq!(actual, ids[1..]); assert!(query["cursor"].is_null()); + let mut included = vec![Uuid::new_v4().to_string(); 99]; + included.push(ids[4].clone()); + query["includedThreadIds"] = json!(included); + query["limit"] = json!(1); + let page = list_ids_and_cursor(&mut server, query).await?; + assert_eq!(page, (vec![ids[4].clone()], None)); excluded.push(ids[1].clone()); - for (input, expected_message) in [ + for (field, input, expected_message) in [ ( + "excludedThreadIds", json!(excluded), "excludedThreadIds accepts at most 100 entries", ), - (json!(["not-a-thread"]), "invalid excluded thread id"), + ( + "excludedThreadIds", + json!(["not-a-thread"]), + "invalid excluded thread id", + ), + ( + "includedThreadIds", + json!(vec![&ids[0]; 101]), + "includedThreadIds accepts at most 100 entries", + ), + ( + "includedThreadIds", + json!(["not-a-thread"]), + "invalid included thread id", + ), ] { let req = server - .send_raw_request("thread/list", Some(json!({"excludedThreadIds": input}))) + .send_raw_request("thread/list", Some(json!({field: input}))) .await?; let err = timeout( DEFAULT_READ_TIMEOUT, @@ -783,6 +902,7 @@ async fn thread_list_respects_cwd_filters() -> Result<()> { .send_thread_list_request(codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -838,7 +958,7 @@ async fn thread_list_respects_search_term_filter() -> Result<()> { Some("mock_provider"), /*git_info*/ None, )?; - let _non_match = create_fake_rollout( + let non_match = create_fake_rollout( codex_home.path(), "2025-01-02T11-00-00", "2025-01-02T11:00:00Z", @@ -915,6 +1035,10 @@ async fn thread_list_respects_search_term_filter() -> Result<()> { json!({"includeAttachedThreads": true}), vec![&newer_match, &older_match], ), + ( + json!({"includeAttachedThreads": true, "includedThreadIds": [&older_match, &non_match]}), + vec![&older_match], + ), ( json!({"includeAttachedThreads": true, "sourceKinds": ["exec"]}), vec![&exec_match], @@ -1323,6 +1447,7 @@ sqlite = true .send_thread_list_request(codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -1364,6 +1489,7 @@ sqlite = true .send_thread_list_request(codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -1394,10 +1520,18 @@ sqlite = true .collect(); assert_eq!(ids, vec![thread_id.as_str()]); + let included = list_ids_and_cursor( + &mut mcp, + json!({"includedThreadIds": [thread_id], "cwd": stale_cwd, "modelProviders": ["mock_provider"]}), + ) + .await?; + assert_eq!(included, (vec![thread_id.clone()], None)); + let request_id = mcp .send_thread_list_request(codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -1432,6 +1566,7 @@ async fn thread_list_relation_filters_read_spawn_graph_from_state_db() -> Result create_minimal_config(codex_home.path())?; let mut mcp = init_mcp(codex_home.path()).await?; let parent_id = ThreadId::new(); + let unrelated_id = ThreadId::new(); let older_child_id = ThreadId::new(); let newer_child_id = ThreadId::new(); let grandchild_id = ThreadId::new(); @@ -1441,6 +1576,12 @@ async fn thread_list_relation_filters_read_spawn_graph_from_state_db() -> Result ) .await?; for (thread_id, created_at, source, model_provider) in [ + ( + unrelated_id, + "2025-02-01T09:00:00Z", + CoreSessionSource::Cli, + "mock_provider", + ), ( older_child_id, "2025-02-01T10:00:00Z", @@ -1476,6 +1617,7 @@ async fn thread_list_relation_filters_read_spawn_graph_from_state_db() -> Result state_db.upsert_thread(&metadata).await?; } for (parent_thread_id, child_thread_id) in [ + (ThreadId::new(), unrelated_id), (parent_id, older_child_id), (parent_id, newer_child_id), (newer_child_id, grandchild_id), @@ -1576,6 +1718,17 @@ async fn thread_list_relation_filters_read_spawn_graph_from_state_db() -> Result ] ); assert_eq!(descendants.next_cursor, None); + let request = mcp + .send_raw_request( + "thread/list", + Some(json!({ + "ancestorThreadId": parent_id, "includedThreadIds": [grandchild_id, unrelated_id] + })), + ) + .await?; + let selected: ThreadListResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request)).await??; + assert_eq!(selected.data, descendants.data[..1]); Ok(()) } @@ -1588,6 +1741,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { .send_thread_list_request(codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -1619,6 +1773,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { .send_thread_list_request(codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -1916,6 +2071,7 @@ async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result params: codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -2789,6 +2945,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() .send_thread_list_request(codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -2835,6 +2992,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<() .send_thread_list_request(codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -3271,6 +3429,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> { .send_thread_list_request(codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -3368,6 +3527,16 @@ async fn current_account_history_refills_list_and_search() -> Result<()> { ); } + let page = list_ids_and_cursor( + &mut server, + json!({ + "currentAccountOnly": true, "includedThreadIds": [ids[0], ids[4]], + "sortKey": "created_at", "limit": 1, + }), + ) + .await?; + assert_eq!(page, (vec![ids[4].clone()], None)); + // The foreign header matches the raw search, but reading its snippet would hit invalid UTF-8. let foreign = rollout_path(home.path(), &stamp, &ids[0]); let intact = fs::read_to_string(&foreign)?; 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 04a7479158..11197159eb 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 @@ -90,6 +90,7 @@ async fn thread_section_move_pins_and_archives_before_first_turn() -> Result<()> let list_params = ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -298,6 +299,7 @@ async fn thread_section_move_pins_and_unpins_with_filtered_recency_pagination() let list_params = ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -494,6 +496,7 @@ async fn thread_sections_preserve_server_owned_manual_order_across_moves_and_res let list_params = ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, 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 3a624d5d52..7979de7396 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -417,6 +417,7 @@ async fn paginated_stored_thread_routes_projected_turns() -> Result<()> { .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -1188,6 +1189,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -1672,6 +1674,7 @@ async fn paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, 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 44014ab340..5419761db5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -4724,6 +4724,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected( params: codex_app_server_protocol::ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/cli/tests/queue.rs b/codex-rs/cli/tests/queue.rs index 13f2554b98..39fa718fef 100644 --- a/codex-rs/cli/tests/queue.rs +++ b/codex-rs/cli/tests/queue.rs @@ -476,6 +476,7 @@ async fn remote_session_commands_with_workload_identity_use_server_auth() -> Res params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, cursor: None, diff --git a/codex-rs/core/src/realtime_context.rs b/codex-rs/core/src/realtime_context.rs index dfbbccf6f9..e2768ee6db 100644 --- a/codex-rs/core/src/realtime_context.rs +++ b/codex-rs/core/src/realtime_context.rs @@ -132,6 +132,7 @@ async fn load_recent_threads(sess: &Session) -> Vec { .services .thread_store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: MAX_RECENT_THREADS, cursor: None, sort_key: ThreadSortKey::UpdatedAt, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index be28295458..33b0f2546d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -2009,6 +2009,7 @@ async fn resolve_resume_thread_id( params: ThreadListParams { include_attached_threads: false, current_account_only: true, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, @@ -2099,6 +2100,7 @@ async fn resolve_resume_thread_id( params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index b9062c6d6b..7469620457 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -577,6 +577,7 @@ impl RolloutRecorder { allowed_sources, model_providers, cwd_filters, + /*included_thread_ids*/ None, relation_filter, archived, /*section*/ None, @@ -699,6 +700,7 @@ impl RolloutRecorder { allowed_sources, model_providers, cwd_filters, + /*included_thread_ids*/ None, relation_filter, archived, /*section*/ None, @@ -730,6 +732,7 @@ impl RolloutRecorder { allowed_sources, model_providers, cwd_filters, + /*included_thread_ids*/ None, relation_filter, archived, /*section*/ None, @@ -772,6 +775,7 @@ impl RolloutRecorder { allowed_sources, model_providers, cwd_filters, + /*included_thread_ids*/ None, relation_filter, archived, /*section*/ None, @@ -853,6 +857,7 @@ impl RolloutRecorder { allowed_sources, model_providers, cwd_filter.as_ref().map(std::slice::from_ref), + /*included_thread_ids*/ None, /*relation_filter*/ None, /*archived*/ false, /*section*/ None, diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index 82ec525896..4cf78a279b 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -359,6 +359,7 @@ pub async fn list_threads_db( allowed_sources: &[SessionSource], model_providers: Option<&[String]>, cwd_filters: Option<&[PathBuf]>, + included_thread_ids: Option<&[ThreadId]>, relation_filter: Option, archived: bool, section: Option>, @@ -406,6 +407,7 @@ pub async fn list_threads_db( let mut num_scanned_rows = 0usize; while items.len() < page_size { let filters = codex_state::ThreadFilterOptions { + included_thread_ids, archived_only: archived, section, allowed_sources: allowed_sources.as_slice(), diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index 00c8d541ad..276b718ae5 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -81,6 +81,7 @@ async fn list_threads_db_rejects_mismatched_sqlite_config_without_cleanup() -> a &[], /*model_providers*/ None, /*cwd_filters*/ None, + /*included_thread_ids*/ None, /*relation_filter*/ None, /*archived*/ false, /*section*/ None, diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index 18c32ea49b..bfef462af3 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -228,6 +228,7 @@ FROM threads push_thread_filters( &mut builder, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources, model_providers: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 2bf9777151..2f67a26c3a 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -460,6 +460,7 @@ ON CONFLICT(child_thread_id) DO NOTHING push_thread_filters( &mut builder, ThreadFilterOptions { + included_thread_ids: None, archived_only, allowed_sources, model_providers, @@ -587,6 +588,7 @@ ON CONFLICT(child_thread_id) DO NOTHING push_thread_filters( &mut builder, ThreadFilterOptions { + included_thread_ids: None, archived_only, allowed_sources, model_providers, @@ -1341,7 +1343,17 @@ fn push_list_threads_query( relation_filter: Option, limit: usize, ) { - if let Some(crate::ThreadRelationFilter::DescendantsOf(ancestor_thread_id)) = relation_filter { + if let Some(ids) = filters.included_thread_ids { + // Materialize only the requested primary keys before SQLite considers a scope or sort index. + builder.push("WITH threads AS MATERIALIZED (SELECT * FROM main.threads WHERE id IN ("); + let mut separated = builder.separated(", "); + for id in ids { + separated.push_bind(id.to_string()); + } + builder.push(")) "); + } else if let Some(crate::ThreadRelationFilter::DescendantsOf(ancestor_thread_id)) = + relation_filter + { builder.push( r#" WITH RECURSIVE subtree(child_thread_id, parent_thread_id) AS ( @@ -1362,9 +1374,12 @@ WITH RECURSIVE subtree(child_thread_id, parent_thread_id) AS ( ); } push_thread_select_columns(builder); - // SQLite may otherwise reorder these joins and scan the global timestamp index before - // checking the relationship. CROSS JOIN keeps the selective edge/subtree traversal first. + // CROSS JOIN keeps the requested IDs, direct children, or subtree first in the query plan. match relation_filter { + Some(crate::ThreadRelationFilter::DirectChildrenOf(_) | crate::ThreadRelationFilter::DescendantsOf(_)) + if filters.included_thread_ids.is_some() => builder.push( + ", listed_edge.parent_thread_id AS parent_thread_id\nFROM threads\nCROSS JOIN thread_spawn_edges AS listed_edge ON listed_edge.child_thread_id = threads.id", + ), Some(crate::ThreadRelationFilter::DirectChildrenOf(_)) => builder.push( ", listed_edge.parent_thread_id AS parent_thread_id\nFROM thread_spawn_edges AS listed_edge\nCROSS JOIN threads ON threads.id = listed_edge.child_thread_id", ), @@ -1397,7 +1412,18 @@ WITH RECURSIVE subtree(child_thread_id, parent_thread_id) AS ( builder.push_bind(parent_thread_id.to_string()); } Some(crate::ThreadRelationFilter::DescendantsOf(ancestor_thread_id)) => { - builder.push(" AND subtree.child_thread_id != "); + if filters.included_thread_ids.is_some() { + builder.push( + " AND EXISTS (WITH RECURSIVE ancestors(id) AS ( + SELECT listed_edge.parent_thread_id + UNION + SELECT edge.parent_thread_id FROM ancestors + JOIN thread_spawn_edges AS edge ON edge.child_thread_id = ancestors.id + ) SELECT 1 FROM ancestors WHERE id = ", + ); + builder.push_bind(ancestor_thread_id.to_string()).push(")"); + } + builder.push(" AND threads.id != "); builder.push_bind(ancestor_thread_id.to_string()); } Some(crate::ThreadRelationFilter::AttachedThreadsOf(parent)) => { @@ -1508,6 +1534,7 @@ fn thread_spawn_parent_thread_id_from_source_str(source: &str) -> Option { + pub included_thread_ids: Option<&'a [ThreadId]>, pub archived_only: bool, pub allowed_sources: &'a [String], pub model_providers: Option<&'a [String]>, @@ -1540,6 +1567,7 @@ fn push_thread_filters_with_preview<'a>( relation_filter: Option, ) { let ThreadFilterOptions { + included_thread_ids: _, archived_only, allowed_sources, model_providers, @@ -1851,6 +1879,7 @@ mod tests { runtime.upsert_thread(&parent_metadata).await?; assert!(runtime.insert_thread_if_absent(&child_metadata).await?); let filters = ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -1922,6 +1951,7 @@ mod tests { metadata.first_user_message = None; runtime.upsert_thread(&metadata).await.unwrap(); let filters = |archived_only| ThreadFilterOptions { + included_thread_ids: None, archived_only, allowed_sources: &[], model_providers: None, @@ -2008,6 +2038,7 @@ mod tests { } let filters = |anchor, section| ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -2141,6 +2172,7 @@ mod tests { } let filters = |anchor| ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -2389,6 +2421,7 @@ mod tests { .list_threads( /*page_size*/ 1, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: Some(&model_providers), @@ -2419,6 +2452,7 @@ mod tests { .list_threads( /*page_size*/ 1, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: Some(&model_providers), @@ -2479,6 +2513,7 @@ mod tests { .list_threads( /*page_size*/ 1, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -2513,6 +2548,7 @@ mod tests { .list_threads( /*page_size*/ 1, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -2540,6 +2576,7 @@ mod tests { .list_threads( /*page_size*/ 10, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -2574,6 +2611,7 @@ mod tests { .list_threads( /*page_size*/ 1, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -2595,6 +2633,64 @@ mod tests { assert_eq!(page.next_anchor, None); } + #[tokio::test] + async fn included_thread_ids_use_primary_key_lookups_across_scopes() -> Result<()> { + let home = unique_temp_dir(); + let runtime = StateRuntime::init( + crate::SqliteConfig::new_for_testing(home.as_path().abs()), + "test-provider".into(), + ) + .await?; + let ids: Vec<_> = (0..100).map(|_| ThreadId::new()).collect(); + for relation in [ + None, + Some(crate::ThreadRelationFilter::DescendantsOf(ids[0])), + ] { + let mut builder = QueryBuilder::::new("EXPLAIN QUERY PLAN "); + push_list_threads_query( + &mut builder, + ThreadFilterOptions { + included_thread_ids: Some(&ids), + archived_only: false, + allowed_sources: &[], + model_providers: None, + cwd_filters: Some(std::slice::from_ref(&home)), + section: Some(Some(CUSTOM_THREAD_SECTION_ID)), + project_id: Some(Some("project")), + anchor: None, + sort_key: SortKey::SectionPosition, + sort_direction: SortDirection::Asc, + search_term: None, + }, + relation, + /*limit*/ 2, + ); + let plan: Vec = builder + .build() + .fetch_all(runtime.pool.as_ref()) + .await? + .iter() + .map(|row| row.get("detail")) + .collect(); + assert!( + plan.iter() + .any(|step| step.contains("SEARCH main.threads") && step.contains("(id=?)")), + "{plan:?}" + ); + if relation.is_some() { + for edge in ["listed_edge", "edge"] { + assert!( + plan.iter() + .any(|step| step.starts_with(&format!("SEARCH {edge} ")) + && step.contains("child_thread_id=?")), + "{plan:?}" + ); + } + } + } + Ok(()) + } + #[tokio::test] async fn list_threads_uses_indexes_matching_cwd_filters() { let codex_home = unique_temp_dir(); @@ -2647,6 +2743,7 @@ mod tests { push_list_threads_query( &mut builder, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: Some(&model_providers), @@ -2736,6 +2833,7 @@ mod tests { push_list_threads_query( &mut builder, ThreadFilterOptions { + included_thread_ids: None, archived_only: true, allowed_sources: &[], model_providers: None, @@ -2833,6 +2931,7 @@ mod tests { push_list_threads_query( &mut builder, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -2863,6 +2962,7 @@ mod tests { ); let filters = |anchor| ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -3656,6 +3756,7 @@ mod tests { .list_threads( /*page_size*/ 1, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -3690,6 +3791,7 @@ mod tests { .list_threads( /*page_size*/ 1, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, @@ -3724,6 +3826,7 @@ mod tests { .list_threads( /*page_size*/ 1, ThreadFilterOptions { + included_thread_ids: None, archived_only: false, allowed_sources: &[], model_providers: None, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 56e79c338c..35d71b5f93 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -210,6 +210,7 @@ mod tests { let page = ThreadStore::list_threads( &store, ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -239,6 +240,7 @@ mod tests { let page = ThreadStore::list_threads( &store, ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -268,6 +270,7 @@ mod tests { let page = ThreadStore::list_threads( &store, ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -297,6 +300,7 @@ mod tests { let page = ThreadStore::list_threads( &store, ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -1055,6 +1059,9 @@ impl ThreadStore for InMemoryThreadStore { thread.section.as_ref().map(|section| section.id.as_str()) == section.as_deref() }); } + if let Some(ids) = ¶ms.included_thread_ids { + page.items.retain(|thread| ids.contains(&thread.thread_id)); + } if params.sort_key == ThreadSortKey::SectionPosition { page.items.sort_by_key(|thread| { ( diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index 4fd105d538..928c8cbdb6 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -283,6 +283,7 @@ mod tests { let archived = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index 3d54f1b385..e645558ca1 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -181,6 +181,7 @@ async fn list_section_threads( .collect::>() }); let filters = ThreadFilterOptions { + included_thread_ids: params.included_thread_ids.as_deref(), archived_only: params.archived, allowed_sources: allowed_sources.as_slice(), model_providers: params.model_providers.as_deref(), @@ -242,6 +243,7 @@ pub(super) async fn list_rollout_threads( sort_direction: codex_rollout::SortDirection, ) -> ThreadStoreResult { if params.use_state_db_only + || params.included_thread_ids.is_some() || params .relation_filter .is_some_and(ThreadRelationFilter::is_parent_scoped) @@ -258,6 +260,7 @@ pub(super) async fn list_rollout_threads( params.allowed_sources.as_slice(), params.model_providers.as_deref(), params.cwd_filters.as_deref(), + params.included_thread_ids.as_deref(), params.relation_filter.map(state_relation_filter), params.archived, params.section.as_ref().map(Option::as_deref), @@ -365,6 +368,7 @@ mod tests { let page = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -427,6 +431,7 @@ mod tests { let page = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -501,6 +506,7 @@ mod tests { let page = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -539,6 +545,7 @@ mod tests { let active = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -557,6 +564,7 @@ mod tests { .expect("active listing"); let archived = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -616,6 +624,7 @@ mod tests { write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); let params = ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, @@ -713,6 +722,7 @@ mod tests { .expect("remove rollout without invalidating SQLite metadata"); let params = ListThreadsParams { + included_thread_ids: None, page_size: 2, cursor: None, sort_key: ThreadSortKey::SectionPosition, @@ -767,6 +777,7 @@ mod tests { let err = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: Some("not-a-cursor".to_string()), sort_key: ThreadSortKey::CreatedAt, diff --git a/codex-rs/thread-store/src/local/rollout_migration_tests.rs b/codex-rs/thread-store/src/local/rollout_migration_tests.rs index eb531aa4b0..11a26265bd 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -2675,6 +2675,7 @@ async fn migration_preserves_legacy_displayed_thread_names() { } let page = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, diff --git a/codex-rs/thread-store/src/local/search_threads.rs b/codex-rs/thread-store/src/local/search_threads.rs index c9bd137038..a579d42b95 100644 --- a/codex-rs/thread-store/src/local/search_threads.rs +++ b/codex-rs/thread-store/src/local/search_threads.rs @@ -95,6 +95,7 @@ pub(super) async fn search_threads( let mut page_cursor = cursor; let scan_page_size = params.page_size.saturating_mul(8).clamp(256, 2048); let scan_params = ListThreadsParams { + included_thread_ids: None, page_size: scan_page_size, cursor: None, sort_key: params.sort_key, diff --git a/codex-rs/thread-store/src/local/search_threads_tests.rs b/codex-rs/thread-store/src/local/search_threads_tests.rs index 65148e2816..636e288a83 100644 --- a/codex-rs/thread-store/src/local/search_threads_tests.rs +++ b/codex-rs/thread-store/src/local/search_threads_tests.rs @@ -198,6 +198,7 @@ async fn write_named_rollout(home: &Path, id: Uuid, attached_to: Option) - fn inclusive_title_search() -> ListThreadsParams { ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, diff --git a/codex-rs/thread-store/src/local/tests/delegated_preview_tests.rs b/codex-rs/thread-store/src/local/tests/delegated_preview_tests.rs index bb278d4f71..e0c09cb788 100644 --- a/codex-rs/thread-store/src/local/tests/delegated_preview_tests.rs +++ b/codex-rs/thread-store/src/local/tests/delegated_preview_tests.rs @@ -50,6 +50,7 @@ async fn delegated_thread_is_listed_before_user_followup() { let listed = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index 14799e401f..c4a4fd7be9 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -173,6 +173,7 @@ async fn split_homes_support_backfill_listing_and_paginated_history() { let threads = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::CreatedAt, diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index e58f4e8472..56c5bba7ce 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -1084,6 +1084,7 @@ mod tests { assert_eq!(pinned_metadata.source, "cli"); let pinned_page = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::RecencyAt, @@ -2303,6 +2304,7 @@ mod tests { assert_eq!(metadata.cwd, normalized_cwd); let page = store .list_threads(ListThreadsParams { + included_thread_ids: None, page_size: 10, cursor: None, sort_key: ThreadSortKey::UpdatedAt, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 3a3bad01e1..88bbd75a63 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -360,6 +360,8 @@ impl ThreadRelationFilter { /// Parameters for listing threads. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ListThreadsParams { + /// Restrict the list to these IDs. An empty list matches nothing. + pub included_thread_ids: Option>, /// Maximum number of threads to return. pub page_size: usize, /// Opaque cursor returned by a previous list call. diff --git a/codex-rs/tui/src/analytics/chats.rs b/codex-rs/tui/src/analytics/chats.rs index 9c32e57835..3fea700319 100644 --- a/codex-rs/tui/src/analytics/chats.rs +++ b/codex-rs/tui/src/analytics/chats.rs @@ -146,6 +146,7 @@ pub(super) fn list_params() -> ThreadListParams { ThreadListParams { include_attached_threads: false, current_account_only: true, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, limit: Some(100), diff --git a/codex-rs/tui/src/app/agent_picker.rs b/codex-rs/tui/src/app/agent_picker.rs index 681d123a68..2b70239d28 100644 --- a/codex-rs/tui/src/app/agent_picker.rs +++ b/codex-rs/tui/src/app/agent_picker.rs @@ -42,6 +42,7 @@ impl App { params: ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/tui/src/app/agents_overview_discovery.rs b/codex-rs/tui/src/app/agents_overview_discovery.rs index 75e35dbcf3..37404300c9 100644 --- a/codex-rs/tui/src/app/agents_overview_discovery.rs +++ b/codex-rs/tui/src/app/agents_overview_discovery.rs @@ -50,6 +50,7 @@ pub(crate) async fn list_pinned_threads( params: ThreadListParams { include_attached_threads: false, current_account_only: true, + included_thread_ids: None, excluded_thread_ids: None, originators: None, cursor: cursor.clone(), @@ -204,6 +205,7 @@ impl SourcePage { params: ThreadListParams { include_attached_threads: false, current_account_only: true, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/tui/src/app_server_session/side_chat_history.rs b/codex-rs/tui/src/app_server_session/side_chat_history.rs index 72a92e2a2d..dbce2e2921 100644 --- a/codex-rs/tui/src/app_server_session/side_chat_history.rs +++ b/codex-rs/tui/src/app_server_session/side_chat_history.rs @@ -142,6 +142,7 @@ impl AppServerSession { ancestor_thread_id: None, search_term: None, originators: None, + included_thread_ids: None, excluded_thread_ids: None, }; if matches!(self.request_handle, AppServerRequestHandle::InProcess(_)) { diff --git a/codex-rs/tui/src/dynamic_tools.rs b/codex-rs/tui/src/dynamic_tools.rs index 065c9d3622..7245c6a339 100644 --- a/codex-rs/tui/src/dynamic_tools.rs +++ b/codex-rs/tui/src/dynamic_tools.rs @@ -353,6 +353,7 @@ async fn execute_inner( params: ThreadListParams { include_attached_threads: false, current_account_only: true, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index bdb8f2423f..98f472307b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -946,6 +946,7 @@ fn latest_session_lookup_params( ThreadListParams { include_attached_threads: false, current_account_only: true, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/tui/src/named_session_lookup.rs b/codex-rs/tui/src/named_session_lookup.rs index 40f724e837..5a58e55d6e 100644 --- a/codex-rs/tui/src/named_session_lookup.rs +++ b/codex-rs/tui/src/named_session_lookup.rs @@ -66,6 +66,7 @@ pub(super) async fn lookup( .thread_list(ThreadListParams { include_attached_threads: false, current_account_only: false, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index ab05955668..6cfd50c66b 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -2589,6 +2589,7 @@ fn thread_list_params( ThreadListParams { include_attached_threads: false, current_account_only: true, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/codex-rs/tui/src/task_mentions.rs b/codex-rs/tui/src/task_mentions.rs index 2d3cc54906..7d10605e8e 100644 --- a/codex-rs/tui/src/task_mentions.rs +++ b/codex-rs/tui/src/task_mentions.rs @@ -94,6 +94,7 @@ pub(crate) fn spawn_search( params: ThreadListParams { include_attached_threads: false, current_account_only: true, + included_thread_ids: None, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 6f8b9607f5..2aeb4b0595 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -11157,6 +11157,13 @@ class ThreadListParams(BaseModel): 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 + included_thread_ids: Annotated[ + list[str] | None, + Field( + alias="includedThreadIds", + description="Restrict results to up to 100 thread IDs before pagination. Omit or send null for unrestricted results. An empty list matches nothing. Exclusions still apply. Repeat the same IDs on each page. The local store uses indexed metadata for this filter.", + ), + ] = None limit: Annotated[ int | None, Field(description="Optional page size; defaults to a reasonable server-side value.", ge=0), @@ -11210,7 +11217,7 @@ class ThreadListParams(BaseModel): bool | None, Field( alias="useStateDbOnly", - description="If true, return from the state DB without scanning JSONL rollouts to repair thread metadata. Omitted or false preserves scan-and-repair behavior.", + description="If true, the local store returns indexed metadata without scanning JSONL rollouts for repairs. Requests with `includedThreadIds` always use indexed metadata.", ), ] = None