From bbd0d2b2fbc573e4d58670833d9649bfbe3598dd Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 12 Nov 2026 19:14:20 +0000 Subject: [PATCH] Fix pagination for unindexed threads sorted by ascending recency (#61181) ## Why Listing threads by ascending `recency_at` can omit `next_cursor` when rollout files have not yet been indexed in SQLite, preventing clients from fetching the remaining threads. ## What changed Scan and index one extra rollout for ascending recency listings when SQLite is available, so the database can detect another page. Trim filesystem results to the requested page size for this sort order. ## Testing Add a regression test that creates two unindexed rollouts after startup backfill and lists them with a provider filter and a page size of one. Verify both threads are returned in order, with a cursor only on the first page. GitOrigin-RevId: 09354055b6e60666a493d9411ed3eb9778b73c0a --- .../app-server/tests/suite/v2/thread_list.rs | 46 +++++++++++++++++++ codex-rs/rollout/src/recorder.rs | 12 +++-- 2 files changed, 55 insertions(+), 3 deletions(-) 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 e3d1f33b4c..6ad3d4bd72 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -2617,6 +2617,52 @@ async fn thread_list_sort_recency_at_uses_state_db_order_with_provider_filter() Ok(()) } +#[tokio::test] +async fn thread_list_cold_ascending_recency_returns_next_cursor() -> Result<()> { + let home = TempDir::new()?; + create_minimal_config(home.path())?; + let mut server = init_mcp(home.path()).await?; + // Finish startup backfill before adding the two unindexed rollouts. + list_threads( + &mut server, + /*cursor*/ None, + Some(1), + /*providers*/ None, + /*source_kinds*/ None, + /*archived*/ None, + ) + .await?; + let ids = create_fake_rollouts( + home.path(), + /*count*/ 2, + |_| "mock_provider", + |i| { + timestamp_at( + /*year*/ 2025, /*month*/ 1, /*day*/ 1, /*hour*/ 10, i as u32, + /*second*/ 0, + ) + }, + "Cold thread", + )?; + + let mut params: codex_app_server_protocol::ThreadListParams = serde_json::from_value(json!({ + "limit": 1, "sortKey": "recency_at", "sortDirection": "asc", "modelProviders": ["mock_provider"] + }))?; + for (index, id) in ids.into_iter().enumerate() { + let page: ThreadListResponse = server + .request(|request_id| ClientRequest::ThreadList { + request_id, + params: params.clone(), + }) + .await?; + let page_ids: Vec<_> = page.data.into_iter().map(|thread| thread.id).collect(); + assert_eq!(page_ids, [id]); + assert_eq!(page.next_cursor.is_some(), index == 0); + params.cursor = page.next_cursor; + } + Ok(()) +} + #[tokio::test] async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index abee7efcfa..b9062c6d6b 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -612,9 +612,15 @@ impl RolloutRecorder { }; let fs_page = match sort_direction { SortDirection::Asc => { + // SQLite needs one extra indexed row to produce a recency cursor. + let scan_size = if sort_key == ThreadSortKey::RecencyAt && state_db_ctx.is_some() { + page_size.saturating_add(1) + } else { + page_size + }; list_threads_from_files_asc( codex_home, - page_size, + scan_size, cursor, sort_key, file_config, @@ -1377,8 +1383,8 @@ fn page_from_filesystem_scan( sort_key: ThreadSortKey, ) -> ThreadsPage { match sort_direction { - SortDirection::Asc => page, - SortDirection::Desc => truncate_fs_page(page, page_size, sort_key), + SortDirection::Asc if sort_key != ThreadSortKey::RecencyAt => page, + SortDirection::Asc | SortDirection::Desc => truncate_fs_page(page, page_size, sort_key), } }