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), } }