Add thread ID inclusion filters to thread/list (#61446)

## Why

Clients reconciling saved thread IDs need to retrieve matching summaries within the current list filters, including threads beyond the first page of history.

## What changed

- Add `includedThreadIds` with up to 100 entries, applied before pagination alongside existing filters. Exclusions take precedence, and results retain the requested list sort.
- Treat omitted or null inclusions as unrestricted and an empty array as matching nothing. Reject invalid IDs and oversized arrays without truncating them.
- Use indexed metadata and primary-key lookups for local inclusion queries, without scanning rollouts for repairs. IDs absent from the index do not match.
- Update protocol schemas, generated bindings, and documentation, including guidance to verify server support before treating missing saved IDs as removals.

## Testing

Add coverage for saved IDs across project moves and archiving, pagination, exclusions, validation limits, and combinations with search, account, and relationship filters. Add a SQLite query-plan test for primary-key lookups across scopes.

GitOrigin-RevId: 90b02a12aee5716f872a9d5d6168d23cf86c6333
This commit is contained in:
Brent Traut
2026-11-14 02:23:37 +00:00
committed by copyberry
parent e9919f4b4e
commit 32cec881b7
48 changed files with 468 additions and 46 deletions
+11 -1
View File
@@ -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"
}
},
@@ -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"
}
},
@@ -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"
}
},
@@ -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"
}
},
@@ -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<string> | 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<string> | 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.
@@ -1461,6 +1461,11 @@ pub struct ThreadListParams {
/// Optional page size; defaults to a reasonable server-side value.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// 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<Vec<String>>,
/// 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<string> | null")]
pub cwd: Option<ThreadListCwdFilter>,
/// 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.
@@ -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,
+15 -11
View File
@@ -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.
@@ -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<String>,
use_state_db_only: bool,
relation_filter: Option<StoreThreadRelationFilter>,
included_thread_ids: Option<Vec<ThreadId>>,
excluded_thread_ids: HashSet<ThreadId>,
}
@@ -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::<Result<HashSet<_>, _>>()?;
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<C: FromIterator<ThreadId>>(
ids: Vec<String>,
kind: &str,
) -> Result<C, JSONRPCErrorError> {
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::<Result<_, _>>()
.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;
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -118,6 +118,7 @@ async fn list_threads(mcp: &mut TestAppServer) -> Result<ThreadListResponse> {
.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,
@@ -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<String>, Option<String>)> {
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<String>,
@@ -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)?;
@@ -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,
@@ -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,
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -132,6 +132,7 @@ async fn load_recent_threads(sess: &Session) -> Vec<StoredThread> {
.services
.thread_store
.list_threads(ListThreadsParams {
included_thread_ids: None,
page_size: MAX_RECENT_THREADS,
cursor: None,
sort_key: ThreadSortKey::UpdatedAt,
+2
View File
@@ -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,
+5
View File
@@ -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,
+2
View File
@@ -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<codex_state::ThreadRelationFilter>,
archived: bool,
section: Option<Option<&str>>,
@@ -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(),
+1
View File
@@ -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,
+1
View File
@@ -228,6 +228,7 @@ FROM threads
push_thread_filters(
&mut builder,
ThreadFilterOptions {
included_thread_ids: None,
archived_only: false,
allowed_sources,
model_providers: None,
+107 -4
View File
@@ -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<crate::ThreadRelationFilter>,
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<ThreadI
#[derive(Clone, Copy)]
pub struct ThreadFilterOptions<'a> {
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<crate::ThreadRelationFilter>,
) {
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::<Sqlite>::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<String> = 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,
+7
View File
@@ -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) = &params.included_thread_ids {
page.items.retain(|thread| ids.contains(&thread.thread_id));
}
if params.sort_key == ThreadSortKey::SectionPosition {
page.items.sort_by_key(|thread| {
(
@@ -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,
@@ -181,6 +181,7 @@ async fn list_section_threads(
.collect::<Vec<_>>()
});
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<codex_rollout::ThreadsPage> {
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,
@@ -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,
@@ -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,
@@ -198,6 +198,7 @@ async fn write_named_rollout(home: &Path, id: Uuid, attached_to: Option<Uuid>) -
fn inclusive_title_search() -> ListThreadsParams {
ListThreadsParams {
included_thread_ids: None,
page_size: 10,
cursor: None,
sort_key: ThreadSortKey::CreatedAt,
@@ -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,
@@ -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,
@@ -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,
+2
View File
@@ -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<Vec<ThreadId>>,
/// Maximum number of threads to return.
pub page_size: usize,
/// Opaque cursor returned by a previous list call.
+1
View File
@@ -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),
+1
View File
@@ -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,
@@ -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,
@@ -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(_)) {
+1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
@@ -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