Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/analytics/src/analytics_client_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ fn sample_thread_with_metadata(
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
recency_at: Some(2),
status: AppServerThreadStatus::Idle,
path: None,
cwd: test_path_buf("/tmp").abs(),
Expand Down
1 change: 1 addition & 0 deletions codex-rs/analytics/src/client_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ fn sample_thread(thread_id: &str) -> Thread {
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
recency_at: Some(2),
status: AppServerThreadStatus::Idle,
path: None,
cwd: test_path_buf("/tmp").abs(),
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/app-server-protocol/schema/json/ClientRequest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2501,6 +2501,7 @@ mod tests {
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
recency_at: Some(3),
status: v2::ThreadStatus::Idle,
path: None,
cwd: cwd.clone(),
Expand Down Expand Up @@ -2544,6 +2545,7 @@ mod tests {
"modelProvider": "openai",
"createdAt": 1,
"updatedAt": 2,
"recencyAt": 3,
"status": {
"type": "idle"
},
Expand Down
10 changes: 10 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 1,
recency_at: Some(1),
status: ThreadStatus::Idle,
path: None,
cwd: absolute_path("tmp"),
Expand Down Expand Up @@ -3600,6 +3601,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {

assert_eq!(start.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(start.thread.parent_thread_id, None);
assert_eq!(start.thread.recency_at, None);
assert_eq!(resume.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(start.active_permission_profile, None);
Expand All @@ -3608,6 +3610,14 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {
assert_eq!(fork.active_permission_profile, None);
}

#[test]
fn thread_recency_sort_key_serializes_as_snake_case() {
assert_eq!(
serde_json::to_value(ThreadSortKey::RecencyAt).expect("sort key should serialize"),
json!("recency_at")
);
}

#[test]
fn turn_start_params_preserve_explicit_null_service_tier() {
let params: TurnStartParams = serde_json::from_value(json!({
Expand Down
1 change: 1 addition & 0 deletions codex-rs/app-server-protocol/src/protocol/v2/thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,7 @@ pub enum ThreadSourceKind {
pub enum ThreadSortKey {
CreatedAt,
UpdatedAt,
RecencyAt,
}

#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)]
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ pub struct Thread {
/// Unix timestamp (in seconds) when the thread was last updated.
#[ts(type = "number")]
pub updated_at: i64,
/// Unix timestamp (in seconds) used for thread recency ordering.
#[ts(type = "number | null")]
pub recency_at: Option<i64>,
/// Current runtime status for the thread.
pub status: ThreadStatus,
/// [UNSTABLE] Path to the thread on disk.
Expand Down
7 changes: 4 additions & 3 deletions codex-rs/app-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,8 @@ Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thr

- `cursor` — opaque string from a prior response; omit for the first page.
- `limit` — server defaults to a reasonable page size if unset.
- `sortKey` — `created_at` (default) or `updated_at`.
- `sortKey` — `created_at` (default), `updated_at`, or `recency_at`.
- `recencyAt` is initialized when the thread is created and advances when a turn starts. Unlike `updatedAt`, background output and other persisted mutations do not advance it.
- `sortDirection` — `desc` (default) or `asc`.
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
Expand All @@ -386,8 +387,8 @@ Example:
} }
{ "id": 20, "result": {
"data": [
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" },
{ "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } }
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "recencyAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" },
{ "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "recencyAt": 1730750000, "status": { "type": "notLoaded" } }
],
"nextCursor": "opaque-token-or-null",
"backwardsCursor": "opaque-token-or-null"
Expand Down
1 change: 1 addition & 0 deletions codex-rs/app-server/src/bespoke_event_handling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2261,6 +2261,7 @@ mod tests {
reasoning_effort: None,
created_at,
updated_at: created_at,
recency_at: created_at,
archived_at: None,
cwd: test_path_buf("/tmp").abs().into(),
cli_version: "0.0.0".to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1897,6 +1897,7 @@ impl ThreadRequestProcessor {
let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) {
ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt,
ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt,
ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt,
};
let sort_direction = sort_direction.unwrap_or(SortDirection::Desc);
let (stored_threads, next_cursor) = self
Expand Down Expand Up @@ -1978,6 +1979,7 @@ impl ThreadRequestProcessor {
let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) {
ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt,
ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt,
ThreadSortKey::RecencyAt => StoreThreadSortKey::RecencyAt,
};
let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc);
let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds);
Expand Down Expand Up @@ -3695,6 +3697,7 @@ fn thread_backwards_cursor_for_sort_key(
let timestamp = match sort_key {
StoreThreadSortKey::CreatedAt => thread.created_at,
StoreThreadSortKey::UpdatedAt => thread.updated_at,
StoreThreadSortKey::RecencyAt => thread.recency_at,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve recency tie-breakers in backwards cursors

When clients request sortKey: recency_at and use backwardsCursor for forward delta sync, this path collapses the cursor to a timestamp-only offset. Recency pagination now depends on the (recency_at, thread_id) tie-breaker; if two rows share a recency millisecond (for example rows inserted by an older binary), the subsequent ASC query seeded from this cursor includes same-timestamp threads that sort behind the watermark, so clients can see already-old threads as new. Build the recency backwards cursor with the thread id tie-breaker instead of using the timestamp-only offset.

AGENTS.md reference: AGENTS.md:L103-L107

Useful? React with 👍 / 👎.

};
// The state DB stores unique millisecond timestamps. Offset the reverse cursor by one
// millisecond so the opposite-direction query includes the page anchor.
Expand Down Expand Up @@ -4139,6 +4142,7 @@ pub(crate) fn thread_from_stored_thread(
},
created_at: thread.created_at.timestamp(),
updated_at: thread.updated_at.timestamp(),
recency_at: Some(thread.recency_at.timestamp()),
status: ThreadStatus::NotLoaded,
path,
cwd,
Expand Down Expand Up @@ -4344,6 +4348,7 @@ fn build_thread_from_snapshot(
model_provider: config_snapshot.model_provider_id.clone(),
created_at: now,
updated_at: now,
recency_at: Some(now),
status: ThreadStatus::NotLoaded,
path,
cwd: config_snapshot.cwd().clone(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ mod thread_processor_behavior_tests {
reasoning_effort: None,
created_at: created_at.with_timezone(&Utc),
updated_at: updated_at.with_timezone(&Utc),
recency_at: updated_at.with_timezone(&Utc),
archived_at: None,
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
Expand Down
Loading
Loading