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
This commit is contained in:
Eric Traut
2026-11-12 21:03:15 +00:00
committed by copyberry
parent c37f817556
commit bbd0d2b2fb
2 changed files with 55 additions and 3 deletions
@@ -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()?;
+9 -3
View File
@@ -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),
}
}