Add sorting/backwardsCursor to thread/list and new thread/turns/list api (#17305)

To improve performance of UI loads from the app, add two main
improvements:
1. The `thread/list` api now gets a `sortDirection` request field and a
`backwardsCursor` to the response, which lets you paginate forwards and
backwards from a window. This lets you fetch the first few items to
display immediately while you paginate to fill in history, then can
paginate "backwards" on future loads to catch up with any changes since
the last UI load without a full reload of the entire data set.
2. Added a new `thread/turns/list` api which also has sortDirection and
backwardsCursor for the same behavior as `thread/list`, allowing you the
same small-fetch for immediate display followed by background fill-in
and resync catchup.
This commit is contained in:
David de Regt
2026-04-17 11:49:02 -07:00
committed by GitHub
parent 29bc2ad2f4
commit eaf78e43f2
54 changed files with 3510 additions and 219 deletions
+1
View File
@@ -34,6 +34,7 @@ pub use config::Config;
pub use config::RolloutConfig;
pub use config::RolloutConfigView;
pub use list::Cursor;
pub use list::SortDirection;
pub use list::ThreadItem;
pub use list::ThreadListConfig;
pub use list::ThreadListLayout;
+27 -25
View File
@@ -111,6 +111,12 @@ pub enum ThreadSortKey {
UpdatedAt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Asc,
Desc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThreadListLayout {
NestedByDate,
@@ -124,26 +130,28 @@ pub struct ThreadListConfig<'a> {
pub layout: ThreadListLayout,
}
/// Pagination cursor identifying a file by timestamp and UUID.
/// Pagination cursor identifying the timestamp of the last item in a page.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cursor {
ts: OffsetDateTime,
id: Uuid,
}
impl Cursor {
fn new(ts: OffsetDateTime, id: Uuid) -> Self {
Self { ts, id }
fn new(ts: OffsetDateTime) -> Self {
Self { ts }
}
pub(crate) fn timestamp(&self) -> OffsetDateTime {
self.ts
}
}
/// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest,
/// it ignores everything until it reaches the last seen item from the previous page, then
/// it ignores everything until it passes the last seen timestamp from the previous page, then
/// starts returning results after that. This makes paging stable even if new files show up during
/// pagination.
struct AnchorState {
ts: OffsetDateTime,
id: Uuid,
passed: bool,
}
@@ -152,22 +160,20 @@ impl AnchorState {
match anchor {
Some(cursor) => Self {
ts: cursor.ts,
id: cursor.id,
passed: false,
},
None => Self {
ts: OffsetDateTime::UNIX_EPOCH,
id: Uuid::nil(),
passed: true,
},
}
}
fn should_skip(&mut self, ts: OffsetDateTime, id: Uuid) -> bool {
fn should_skip(&mut self, ts: OffsetDateTime, _id: Uuid) -> bool {
if self.passed {
return false;
}
if ts < self.ts || (ts == self.ts && id < self.id) {
if ts < self.ts {
self.passed = true;
false
} else {
@@ -275,7 +281,7 @@ impl serde::Serialize for Cursor {
.ts
.format(&Rfc3339)
.map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?;
serializer.serialize_str(&format!("{ts_str}|{}", self.id))
serializer.serialize_str(&ts_str)
}
}
@@ -296,14 +302,14 @@ impl From<codex_state::Anchor> for Cursor {
.timestamp_nanos_opt()
.and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok())
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
Self::new(ts, anchor.id)
Self::new(ts)
}
}
/// Retrieve recorded thread file paths with token pagination. The returned `next_cursor`
/// can be supplied on the next call to resume after the last returned item, resilient to
/// concurrent new sessions being appended. Ordering is stable by the requested sort key
/// (timestamp desc, then UUID desc).
/// (timestamp desc).
pub async fn get_threads(
codex_home: &Path,
page_size: usize,
@@ -657,31 +663,27 @@ async fn traverse_flat_paths_updated(
})
}
/// Pagination cursor token format: "<ts>|<uuid>" where `ts` uses
/// YYYY-MM-DDThh-mm-ss (UTC, second precision).
/// The cursor orders files by the requested sort key (timestamp desc, then UUID desc).
/// Pagination cursor token format: an RFC3339 timestamp.
pub fn parse_cursor(token: &str) -> Option<Cursor> {
let (file_ts, uuid_str) = token.split_once('|')?;
let Ok(uuid) = Uuid::parse_str(uuid_str) else {
if token.contains('|') {
return None;
};
}
let ts = OffsetDateTime::parse(file_ts, &Rfc3339).ok().or_else(|| {
let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| {
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
PrimitiveDateTime::parse(file_ts, format)
PrimitiveDateTime::parse(token, format)
.ok()
.map(PrimitiveDateTime::assume_utc)
})?;
Some(Cursor::new(ts, uuid))
Some(Cursor::new(ts))
}
fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option<Cursor> {
let last = items.last()?;
let file_name = last.path.file_name()?.to_string_lossy();
let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?;
let ts = match sort_key {
ThreadSortKey::CreatedAt => created_ts,
ThreadSortKey::UpdatedAt => {
@@ -689,7 +691,7 @@ fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option<Cu
OffsetDateTime::parse(updated_at, &Rfc3339).ok()?
}
};
Some(Cursor::new(ts, id))
Some(Cursor::new(ts))
}
async fn build_thread_item(
+284 -28
View File
@@ -1,5 +1,6 @@
//! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later.
use std::collections::HashSet;
use std::fs;
use std::fs::File;
use std::io::Error as IoError;
@@ -32,6 +33,7 @@ use tracing::warn;
use super::ARCHIVED_SESSIONS_SUBDIR;
use super::SESSIONS_SUBDIR;
use super::list::Cursor;
use super::list::SortDirection;
use super::list::ThreadItem;
use super::list::ThreadListConfig;
use super::list::ThreadListLayout;
@@ -44,6 +46,7 @@ use super::list::parse_timestamp_uuid_from_filename;
use super::metadata;
use super::policy::EventPersistenceMode;
use super::policy::is_persisted_response_item;
use super::session_index::find_thread_names_by_ids;
use crate::config::RolloutConfigView;
use crate::default_client::originator;
use crate::state_db;
@@ -219,6 +222,7 @@ impl RolloutRecorder {
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
sort_direction: SortDirection,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
@@ -229,6 +233,7 @@ impl RolloutRecorder {
page_size,
cursor,
sort_key,
sort_direction,
allowed_sources,
model_providers,
default_provider,
@@ -245,6 +250,7 @@ impl RolloutRecorder {
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
sort_direction: SortDirection,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
@@ -255,6 +261,7 @@ impl RolloutRecorder {
page_size,
cursor,
sort_key,
sort_direction,
allowed_sources,
model_providers,
default_provider,
@@ -270,6 +277,7 @@ impl RolloutRecorder {
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
sort_direction: SortDirection,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
@@ -290,6 +298,7 @@ impl RolloutRecorder {
page_size,
cursor,
sort_key,
sort_direction,
allowed_sources,
model_providers,
archived,
@@ -304,38 +313,44 @@ impl RolloutRecorder {
// Filesystem-first listing intentionally overfetches so we can repair stale/missing
// SQLite rollout paths before the final DB-backed page is returned.
let fs_page_size = page_size.saturating_mul(2).max(page_size);
let fs_page = if archived {
let root = codex_home.join(ARCHIVED_SESSIONS_SUBDIR);
get_threads_in_root(
root,
fs_page_size,
cursor,
sort_key,
ThreadListConfig {
let fs_page = match sort_direction {
SortDirection::Asc => {
list_threads_from_files_asc(
codex_home,
page_size,
cursor,
sort_key,
allowed_sources,
model_providers,
default_provider,
layout: ThreadListLayout::Flat,
},
)
.await?
} else {
get_threads(
codex_home,
fs_page_size,
cursor,
sort_key,
allowed_sources,
model_providers,
default_provider,
)
.await?
archived,
search_term,
)
.await?
}
SortDirection::Desc => {
list_threads_from_files_desc(
codex_home,
fs_page_size,
cursor,
sort_key,
allowed_sources,
model_providers,
default_provider,
archived,
search_term,
)
.await?
}
};
if state_db_ctx.is_none() {
// Keep legacy behavior when SQLite is unavailable: return filesystem results
// at the requested page size.
return Ok(truncate_fs_page(fs_page, page_size, sort_key));
return Ok(match sort_direction {
SortDirection::Asc => fs_page,
SortDirection::Desc => truncate_fs_page(fs_page, page_size, sort_key),
});
}
// Warm the DB by repairing every filesystem hit before querying SQLite.
@@ -355,6 +370,7 @@ impl RolloutRecorder {
page_size,
cursor,
sort_key,
sort_direction,
allowed_sources,
model_providers,
archived,
@@ -367,7 +383,10 @@ impl RolloutRecorder {
// If SQLite listing still fails, return the filesystem page rather than failing the list.
tracing::error!("Falling back on rollout system");
tracing::warn!("state db discrepancy during list_threads_with_db_fallback: falling_back");
Ok(truncate_fs_page(fs_page, page_size, sort_key))
Ok(match sort_direction {
SortDirection::Asc => fs_page,
SortDirection::Desc => truncate_fs_page(fs_page, page_size, sort_key),
})
}
/// Find the newest recorded thread path, optionally filtering to a matching cwd.
@@ -393,6 +412,7 @@ impl RolloutRecorder {
page_size,
db_cursor.as_ref(),
sort_key,
SortDirection::Desc,
allowed_sources,
model_providers,
/*archived*/ false,
@@ -766,16 +786,252 @@ fn truncate_fs_page(
page.items.truncate(page_size);
page.next_cursor = page.items.last().and_then(|item| {
let file_name = item.path.file_name()?.to_str()?;
let (created_at, id) = parse_timestamp_uuid_from_filename(file_name)?;
let (created_at, _id) = parse_timestamp_uuid_from_filename(file_name)?;
let cursor_token = match sort_key {
ThreadSortKey::CreatedAt => format!("{}|{id}", created_at.format(&Rfc3339).ok()?),
ThreadSortKey::UpdatedAt => format!("{}|{id}", item.updated_at.as_deref()?),
ThreadSortKey::CreatedAt => created_at.format(&Rfc3339).ok()?,
ThreadSortKey::UpdatedAt => item.updated_at.as_deref()?.to_string(),
};
parse_cursor(cursor_token.as_str())
});
page
}
#[allow(clippy::too_many_arguments)]
async fn list_threads_from_files_desc(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
archived: bool,
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
if let Some(search_term) = search_term {
let mut matching_items = Vec::new();
let mut scanned_files = 0usize;
let mut reached_scan_cap = false;
let mut page_cursor = cursor.cloned();
let scan_page_size = page_size.saturating_mul(8).clamp(256, 2048);
loop {
let mut page = list_threads_from_files_desc_unfiltered(
codex_home,
scan_page_size,
page_cursor.as_ref(),
sort_key,
allowed_sources,
model_providers,
default_provider,
archived,
)
.await?;
scanned_files = scanned_files.saturating_add(page.num_scanned_files);
reached_scan_cap |= page.reached_scan_cap;
filter_thread_items_by_search_term(codex_home, &mut page.items, Some(search_term))
.await?;
matching_items.extend(page.items);
page_cursor = page.next_cursor;
if matching_items.len() > page_size || page_cursor.is_none() {
break;
}
}
let more_matches_available =
matching_items.len() > page_size || page_cursor.is_some() || reached_scan_cap;
matching_items.truncate(page_size);
let next_cursor = if more_matches_available {
matching_items
.last()
.and_then(|item| cursor_from_thread_item(item, sort_key))
} else {
None
};
return Ok(ThreadsPage {
items: matching_items,
next_cursor,
num_scanned_files: scanned_files,
reached_scan_cap,
});
}
list_threads_from_files_desc_unfiltered(
codex_home,
page_size,
cursor,
sort_key,
allowed_sources,
model_providers,
default_provider,
archived,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn list_threads_from_files_desc_unfiltered(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
archived: bool,
) -> std::io::Result<ThreadsPage> {
if archived {
let root = codex_home.join(ARCHIVED_SESSIONS_SUBDIR);
get_threads_in_root(
root,
page_size,
cursor,
sort_key,
ThreadListConfig {
allowed_sources,
model_providers,
default_provider,
layout: ThreadListLayout::Flat,
},
)
.await
} else {
get_threads(
codex_home,
page_size,
cursor,
sort_key,
allowed_sources,
model_providers,
default_provider,
)
.await
}
}
#[allow(clippy::too_many_arguments)]
async fn list_threads_from_files_asc(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
archived: bool,
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
let mut all_items = Vec::new();
let mut scanned_files = 0usize;
let mut reached_scan_cap = false;
let mut page_cursor = None;
let scan_page_size = page_size.saturating_mul(8).clamp(256, 2048);
loop {
let page = list_threads_from_files_desc(
codex_home,
scan_page_size,
page_cursor.as_ref(),
sort_key,
allowed_sources,
model_providers,
default_provider,
archived,
/*search_term*/ None,
)
.await?;
scanned_files = scanned_files.saturating_add(page.num_scanned_files);
reached_scan_cap |= page.reached_scan_cap;
all_items.extend(page.items);
page_cursor = page.next_cursor;
if page_cursor.is_none() {
break;
}
}
filter_thread_items_by_search_term(codex_home, &mut all_items, search_term).await?;
let mut keyed_items = all_items
.into_iter()
.filter_map(|item| thread_item_sort_key(&item, sort_key).map(|key| (key, item)))
.collect::<Vec<_>>();
keyed_items.sort_by_key(|(key, _)| *key);
let mut all_items = keyed_items
.into_iter()
.map(|(_, item)| item)
.collect::<Vec<_>>();
if let Some(cursor) = cursor {
let anchor = cursor.timestamp();
all_items
.retain(|item| thread_item_sort_key(item, sort_key).is_some_and(|key| key.0 > anchor));
}
let more_matches_available = all_items.len() > page_size || reached_scan_cap;
all_items.truncate(page_size);
let next_cursor = if more_matches_available {
all_items
.last()
.and_then(|item| cursor_from_thread_item(item, sort_key))
} else {
None
};
Ok(ThreadsPage {
items: all_items,
next_cursor,
num_scanned_files: scanned_files,
reached_scan_cap,
})
}
async fn filter_thread_items_by_search_term(
codex_home: &Path,
items: &mut Vec<ThreadItem>,
search_term: Option<&str>,
) -> std::io::Result<()> {
let Some(search_term) = search_term else {
return Ok(());
};
// The file-backed fallback only has the thread title in the sidecar session index.
// Match the SQLite path's title substring filter so search pagination behaves the same
// whether the state DB is available or not.
let thread_ids = items
.iter()
.filter_map(|item| item.thread_id)
.collect::<HashSet<_>>();
let thread_names = find_thread_names_by_ids(codex_home, &thread_ids).await?;
items.retain(|item| {
item.thread_id
.and_then(|thread_id| thread_names.get(&thread_id))
.is_some_and(|title| title.contains(search_term))
});
Ok(())
}
fn thread_item_sort_key(
item: &ThreadItem,
sort_key: ThreadSortKey,
) -> Option<(OffsetDateTime, uuid::Uuid)> {
let file_name = item.path.file_name()?.to_str()?;
let (created_at, id) = parse_timestamp_uuid_from_filename(file_name)?;
let timestamp = match sort_key {
ThreadSortKey::CreatedAt => created_at,
ThreadSortKey::UpdatedAt => {
let updated_at = item.updated_at.as_deref().or(item.created_at.as_deref())?;
OffsetDateTime::parse(updated_at, &Rfc3339).ok()?
}
};
Some((timestamp, id))
}
fn cursor_from_thread_item(item: &ThreadItem, sort_key: ThreadSortKey) -> Option<Cursor> {
let (timestamp, _id) = thread_item_sort_key(item, sort_key)?;
let cursor_token = timestamp.format(&Rfc3339).ok()?;
parse_cursor(cursor_token.as_str())
}
struct LogFileInfo {
/// Full path to the rollout file.
path: PathBuf,
+4
View File
@@ -372,6 +372,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re
/*page_size*/ 1,
/*cursor*/ None,
ThreadSortKey::CreatedAt,
SortDirection::Desc,
&[],
/*model_providers*/ None,
default_provider.as_str(),
@@ -387,6 +388,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re
/*page_size*/ 1,
Some(&cursor),
ThreadSortKey::CreatedAt,
SortDirection::Desc,
&[],
/*model_providers*/ None,
default_provider.as_str(),
@@ -444,6 +446,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul
/*page_size*/ 10,
/*cursor*/ None,
ThreadSortKey::CreatedAt,
SortDirection::Desc,
&[],
/*model_providers*/ None,
default_provider.as_str(),
@@ -506,6 +509,7 @@ async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Resul
/*page_size*/ 1,
/*cursor*/ None,
ThreadSortKey::CreatedAt,
SortDirection::Desc,
&[],
/*model_providers*/ None,
default_provider.as_str(),
+20 -25
View File
@@ -1,10 +1,10 @@
use crate::config::RolloutConfig;
use crate::config::RolloutConfigView;
use crate::list::Cursor;
use crate::list::SortDirection;
use crate::list::ThreadSortKey;
use crate::metadata;
use chrono::DateTime;
use chrono::NaiveDateTime;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolSpec;
@@ -18,7 +18,6 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::warn;
use uuid::Uuid;
/// Core-facing handle to the SQLite-backed state runtime.
pub type StateDbHandle = Arc<codex_state::StateRuntime>;
@@ -117,21 +116,10 @@ async fn require_backfill_complete(
fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option<codex_state::Anchor> {
let cursor = cursor?;
let value = serde_json::to_value(cursor).ok()?;
let cursor_str = value.as_str()?;
let (ts_str, id_str) = cursor_str.split_once('|')?;
if id_str.contains('|') {
return None;
}
let id = Uuid::parse_str(id_str).ok()?;
let ts = if let Ok(naive) = NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%dT%H-%M-%S") {
DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc)
} else if let Ok(dt) = DateTime::parse_from_rfc3339(ts_str) {
dt.with_timezone(&Utc)
} else {
return None;
};
Some(codex_state::Anchor { ts, id })
let millis = cursor.timestamp().unix_timestamp_nanos() / 1_000_000;
let millis = i64::try_from(millis).ok()?;
let ts = chrono::DateTime::<Utc>::from_timestamp_millis(millis)?;
Some(codex_state::Anchor { ts })
}
pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf {
@@ -200,6 +188,7 @@ pub async fn list_threads_db(
page_size: usize,
cursor: Option<&Cursor>,
sort_key: ThreadSortKey,
sort_direction: SortDirection,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
archived: bool,
@@ -227,15 +216,21 @@ pub async fn list_threads_db(
match ctx
.list_threads(
page_size,
anchor.as_ref(),
match sort_key {
ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
codex_state::ThreadFilterOptions {
archived_only: archived,
allowed_sources: allowed_sources.as_slice(),
model_providers: model_providers.as_deref(),
anchor: anchor.as_ref(),
sort_key: match sort_key {
ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
},
sort_direction: match sort_direction {
SortDirection::Asc => codex_state::SortDirection::Asc,
SortDirection::Desc => codex_state::SortDirection::Desc,
},
search_term,
},
allowed_sources.as_slice(),
model_providers.as_deref(),
archived,
search_term,
)
.await
{
+1 -5
View File
@@ -7,14 +7,11 @@ use chrono::NaiveDateTime;
use chrono::Timelike;
use chrono::Utc;
use pretty_assertions::assert_eq;
use uuid::Uuid;
#[test]
fn cursor_to_anchor_normalizes_timestamp_format() {
let uuid = Uuid::new_v4();
let ts_str = "2026-01-27T12-34-56";
let token = format!("{ts_str}|{uuid}");
let cursor = parse_cursor(token.as_str()).expect("cursor should parse");
let cursor = parse_cursor(ts_str).expect("cursor should parse");
let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse");
let naive =
@@ -23,6 +20,5 @@ fn cursor_to_anchor_normalizes_timestamp_format() {
.with_nanosecond(0)
.expect("nanosecond");
assert_eq!(anchor.id, uuid);
assert_eq!(anchor.ts, expected_ts);
}
+8 -31
View File
@@ -707,8 +707,7 @@ async fn test_pagination_cursor() {
.join(format!("rollout-2025-03-04T09-00-00-{u4}.jsonl"));
let updated_page1: Vec<Option<String>> =
page1.items.iter().map(|i| i.updated_at.clone()).collect();
let expected_cursor1: Cursor =
serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap();
let expected_cursor1: Cursor = serde_json::from_str("\"2025-03-04T09-00-00\"").unwrap();
let expected_page1 = ThreadsPage {
items: vec![
ThreadItem {
@@ -775,8 +774,7 @@ async fn test_pagination_cursor() {
.join(format!("rollout-2025-03-02T09-00-00-{u2}.jsonl"));
let updated_page2: Vec<Option<String>> =
page2.items.iter().map(|i| i.updated_at.clone()).collect();
let expected_cursor2: Cursor =
serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap();
let expected_cursor2: Cursor = serde_json::from_str("\"2025-03-02T09-00-00\"").unwrap();
let expected_page2 = ThreadsPage {
items: vec![
ThreadItem {
@@ -1207,7 +1205,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
}
#[tokio::test]
async fn test_stable_ordering_same_second_pagination() {
async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() {
let temp = TempDir::new().unwrap();
let home = temp.path();
@@ -1268,7 +1266,7 @@ async fn test_stable_ordering_same_second_pagination() {
.join(format!("rollout-2025-07-01T00-00-00-{u2}.jsonl"));
let updated_page1: Vec<Option<String>> =
page1.items.iter().map(|i| i.updated_at.clone()).collect();
let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap();
let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}\"")).unwrap();
let expected_page1 = ThreadsPage {
items: vec![
ThreadItem {
@@ -1321,33 +1319,12 @@ async fn test_stable_ordering_same_second_pagination() {
)
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
.join("07")
.join("01")
.join(format!("rollout-2025-07-01T00-00-00-{u1}.jsonl"));
let updated_page2: Vec<Option<String>> =
page2.items.iter().map(|i| i.updated_at.clone()).collect();
// The filesystem fallback only has second-precision timestamps in filenames. The primary
// SQLite-backed listing uses unique millisecond timestamps and does not have this tie.
let expected_page2 = ThreadsPage {
items: vec![ThreadItem {
path: p1,
thread_id: Some(thread_id_from_uuid(u1)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some(ts.to_string()),
updated_at: updated_page2.first().cloned().flatten(),
}],
items: Vec::new(),
next_cursor: None,
num_scanned_files: 3, // scanned u3, u2 (anchor), u1
num_scanned_files: 3,
reached_scan_cap: false,
};
assert_eq!(page2, expected_page2);