Files
codex/codex-rs/app-server/tests/suite/v2/thread_list.rs
T
Brent Traut 0ada5d8806 Expose experimental durable thread read state in the app server (#52350)
## What changed

- Add experimental `readState` to `thread/read` and a thread-ID-keyed `readStates` map to `thread/list` for eligible durable local user threads. Each receipt contains `firstUnread` and an opaque `revision`.
- Publish new visible terminal results before `turn/completed`, after flushing the rollout. Reading snapshots leaves activity unread; replayed history does not publish new activity.
- Clear unread positions removed by a paginated thread revert while preserving explicit thread-start marks and concurrent receipt updates.

## Testing

Add an integration test covering completed-turn receipts, repeated reads without acknowledgement, matching list receipts, and revert cleanup with a changed revision.

GitOrigin-RevId: e7f6ddfbb1fc2fa95d1ef2876d7f0f7b9f99ff65
2026-10-09 01:22:30 +00:00

3198 lines
99 KiB
Rust

use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_fake_parented_rollout_with_source;
use app_test_support::create_fake_rollout;
use app_test_support::create_fake_rollout_with_source;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::rollout_path;
use app_test_support::test_absolute_path;
use chrono::DateTime;
use chrono::Utc;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadSearchResponse;
use codex_app_server_protocol::ThreadSectionMoveParams;
use codex_app_server_protocol::ThreadSectionMoveResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_features::Feature;
use codex_git_utils::GitSha;
use codex_protocol::SanitizedGitUrl;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_rollout::RolloutItem;
use codex_rollout::append_rollout_item_to_path;
use codex_rollout::read_session_meta_line;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use codex_utils_absolute_path::test_support::PathExt;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::fs;
use std::fs::FileTimes;
use std::fs::OpenOptions;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
use uuid::Uuid;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
async fn init_mcp(codex_home: &Path) -> Result<TestAppServer> {
TestAppServer::builder()
.with_codex_home(codex_home)
.build_initialized()
.await
}
async fn list_threads(
mcp: &mut TestAppServer,
cursor: Option<String>,
limit: Option<u32>,
providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
archived: Option<bool>,
) -> Result<ThreadListResponse> {
list_threads_with_sort(
mcp,
cursor,
limit,
providers,
source_kinds,
/*sort_key*/ None,
archived,
)
.await
}
async fn list_threads_with_sort(
mcp: &mut TestAppServer,
cursor: Option<String>,
limit: Option<u32>,
providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
sort_key: Option<ThreadSortKey>,
archived: Option<bool>,
) -> Result<ThreadListResponse> {
mcp.request(|request_id| ClientRequest::ThreadList {
request_id,
params: codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor,
limit,
sort_key,
sort_direction: None,
model_providers: providers,
source_kinds,
archived,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
},
})
.await
}
enum ThreadListRelation {
DirectChildrenOf(ThreadId),
DescendantsOf(ThreadId),
}
async fn list_threads_for_relation(
mcp: &mut TestAppServer,
relation: ThreadListRelation,
cursor: Option<String>,
limit: u32,
model_providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
) -> Result<ThreadListResponse> {
let (parent_thread_id, ancestor_thread_id) = match relation {
ThreadListRelation::DirectChildrenOf(thread_id) => (Some(thread_id.to_string()), None),
ThreadListRelation::DescendantsOf(thread_id) => (None, Some(thread_id.to_string())),
};
mcp.request(|request_id| ClientRequest::ThreadList {
request_id,
params: codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor,
limit: Some(limit),
sort_key: None,
sort_direction: None,
model_providers,
source_kinds,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id,
ancestor_thread_id,
},
})
.await
}
fn create_fake_rollouts<F, G>(
codex_home: &Path,
count: usize,
provider_for_index: F,
timestamp_for_index: G,
preview: &str,
) -> Result<Vec<String>>
where
F: Fn(usize) -> &'static str,
G: Fn(usize) -> (String, String),
{
let mut ids = Vec::with_capacity(count);
for i in 0..count {
let (ts_file, ts_rfc) = timestamp_for_index(i);
ids.push(create_fake_rollout(
codex_home,
&ts_file,
&ts_rfc,
preview,
Some(provider_for_index(i)),
/*git_info*/ None,
)?);
}
Ok(ids)
}
fn timestamp_at(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> (String, String) {
(
format!("{year:04}-{month:02}-{day:02}T{hour:02}-{minute:02}-{second:02}"),
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"),
)
}
#[allow(dead_code)]
fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> {
let parsed = DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc);
let times = FileTimes::new().set_modified(parsed.into());
OpenOptions::new()
.append(true)
.open(path)?
.set_times(times)?;
Ok(())
}
fn set_rollout_cwd(path: &Path, cwd: &Path) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let first_line = lines
.first_mut()
.ok_or_else(|| anyhow::anyhow!("rollout at {} is empty", path.display()))?;
let mut rollout_line = codex_rollout::parse_rollout_line(first_line)?;
let RolloutItem::SessionMeta(mut session_meta_line) = rollout_line.item else {
return Err(anyhow::anyhow!(
"rollout at {} does not start with session metadata",
path.display()
));
};
session_meta_line.meta.cwd = cwd.to_path_buf();
rollout_line.item = RolloutItem::SessionMeta(session_meta_line);
*first_line = serde_json::to_string(&rollout_line)?;
fs::write(path, lines.join("\n") + "\n")?;
Ok(())
}
#[tokio::test]
async fn thread_list_basic_empty() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse {
data, next_cursor, ..
} = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
assert!(data.is_empty());
assert_eq!(next_cursor, None);
Ok(())
}
#[tokio::test]
async fn thread_list_exclusions_refill_across_storage_paths_and_scopes() -> Result<()> {
let home = TempDir::new()?;
create_minimal_config(home.path())?;
let ids = create_fake_rollouts(
home.path(),
/*count*/ 12,
|i| {
if i == 5 {
"other_provider"
} else {
"mock_provider"
}
},
|i| {
timestamp_at(
/*year*/ 2025,
/*month*/ 3,
20 - i as u32,
/*hour*/ 12,
/*minute*/ 0,
/*second*/ 0,
)
},
"List exclusions",
)?;
let mut server = init_mcp(home.path()).await?;
// Scan once so both DB-only and scan/repair modes see the same stored history.
let _: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: serde_json::from_value(json!({"limit": 100, "modelProviders": []}))
.expect("valid list"),
})
.await?;
// Pin two rows. A null section scope must continue to exclude both of them.
for id in &ids[8..10] {
let _: ThreadSectionMoveResponse = server
.request(|request_id| ClientRequest::ThreadSectionMove {
request_id,
params: ThreadSectionMoveParams {
thread_id: id.clone(),
section_id: Some(codex_state::PINNED_THREAD_SECTION_ID.to_string()),
before_thread_id: None,
},
})
.await?;
}
for archived in [false, true] {
if archived {
for id in &ids {
let _: codex_app_server_protocol::ThreadArchiveResponse = server
.request(|request_id| ClientRequest::ThreadArchive {
request_id,
params: codex_app_server_protocol::ThreadArchiveParams {
thread_id: id.clone(),
},
})
.await?;
}
}
for (db_only, sort, direction, scoped) in [
(false, "created_at", "desc", false),
(true, "created_at", "asc", true),
(true, "updated_at", "desc", false),
(false, "updated_at", "asc", true),
(true, "recency_at", "asc", false),
(false, "recency_at", "desc", true),
] {
let mut query = json!({
"limit": 100, "archived": archived,
"modelProviders": ["mock_provider"], "sourceKinds": ["cli"],
"sortKey": sort, "sortDirection": direction, "useStateDbOnly": db_only,
});
if scoped {
query["sectionId"] = serde_json::Value::Null;
query["projectId"] = serde_json::Value::Null;
}
let baseline: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: serde_json::from_value(query.clone()).expect("valid list"),
})
.await?;
let baseline: Vec<_> = baseline.data.into_iter().map(|t| t.id).collect();
assert!(!baseline.contains(&ids[5]));
assert!(baseline.len() >= 7, "{query}: {baseline:?}");
if scoped {
assert!(!baseline.contains(&ids[8]));
assert!(!baseline.contains(&ids[9]));
}
// Drop a dense prefix, a later row, a duplicate and an unknown ID.
let excluded = vec![
baseline[0].clone(),
baseline[1].clone(),
baseline[2].clone(),
baseline[4].clone(),
baseline[0].clone(),
Uuid::new_v4().to_string(),
];
let expected: Vec<_> = baseline
.iter()
.filter(|id| !excluded.contains(id))
.cloned()
.collect();
query["excludedThreadIds"] = json!(excluded);
query["limit"] = json!(2);
let mut actual = Vec::new();
for _ in 0..=baseline.len() {
let page: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: serde_json::from_value(query.clone()).expect("valid list"),
})
.await?;
assert_eq!(
page.data.len(),
2.min(expected.len() - actual.len()),
"must fill every eligible page: {query}"
);
actual.extend(page.data.into_iter().map(|t| t.id));
query["cursor"] = json!(page.next_cursor);
if page.next_cursor.is_none() {
break;
}
}
assert_eq!(actual, expected, "{query}");
assert!(query["cursor"].is_null(), "{query}");
}
}
Ok(())
}
#[tokio::test]
async fn thread_list_exclusions_validate_bound_without_truncating_and_keep_section_order()
-> Result<()> {
let home = TempDir::new()?;
create_minimal_config(home.path())?;
let ids = create_fake_rollouts(
home.path(),
/*count*/ 5,
|_| "mock_provider",
|i| {
timestamp_at(
/*year*/ 2025,
/*month*/ 4,
10 + i as u32,
/*hour*/ 12,
/*minute*/ 0,
/*second*/ 0,
)
},
"Section list",
)?;
let mut server = init_mcp(home.path()).await?;
let _: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: serde_json::from_value(json!({"limit": 100, "modelProviders": []}))
.expect("valid list"),
})
.await?;
for id in &ids {
let _: ThreadSectionMoveResponse = server
.request(|request_id| ClientRequest::ThreadSectionMove {
request_id,
params: ThreadSectionMoveParams {
thread_id: id.clone(),
section_id: Some(codex_state::PINNED_THREAD_SECTION_ID.to_string()),
before_thread_id: None,
},
})
.await?;
}
// The last entry must take effect even at the bound. Unknown/deleted IDs are allowed.
let mut excluded: Vec<String> = (0..99).map(|_| Uuid::new_v4().to_string()).collect();
excluded.push(ids[0].clone());
let mut query = json!({
"sectionId": codex_state::PINNED_THREAD_SECTION_ID,
"sortKey": "section_position", "limit": 2, "excludedThreadIds": excluded,
"modelProviders": [],
});
let mut actual = Vec::new();
for _ in 0..=ids.len() {
let page: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: serde_json::from_value(query.clone()).expect("valid list"),
})
.await?;
assert_eq!(page.data.len(), 2.min(4 - actual.len()));
actual.extend(page.data.into_iter().map(|t| t.id));
query["cursor"] = json!(page.next_cursor);
if page.next_cursor.is_none() {
break;
}
}
assert_eq!(actual, ids[1..]);
assert!(query["cursor"].is_null());
excluded.push(ids[1].clone());
for (input, expected_message) in [
(
json!(excluded),
"excludedThreadIds accepts at most 100 entries",
),
(json!(["not-a-thread"]), "invalid excluded thread id"),
] {
let req = server
.send_raw_request("thread/list", Some(json!({"excludedThreadIds": input})))
.await?;
let err = timeout(
DEFAULT_READ_TIMEOUT,
server.read_stream_until_error_message(RequestId::Integer(req)),
)
.await??;
assert_eq!(err.error.code, -32602);
assert!(
err.error.message.starts_with(expected_message),
"{}",
err.error.message
);
}
Ok(())
}
#[tokio::test]
async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Result<()> {
let responses = vec![
create_final_assistant_message_sse_response("seeded")?,
responses::sse_failed("resp-2", "server_error", "simulated failure"),
];
let server = create_mock_responses_server_sequence(responses).await;
let codex_home = TempDir::new()?;
create_runtime_config(codex_home.path(), &server.uri())?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let seed_turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![UserInput::Text {
text: "seed history".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let _: TurnStartResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(seed_turn_id)).await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let failed_turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![UserInput::Text {
text: "fail turn".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let _: TurnStartResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(failed_turn_id)).await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("error"),
)
.await??;
let ThreadListResponse { data, .. } = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![
ThreadSourceKind::AppServer,
ThreadSourceKind::Cli,
ThreadSourceKind::VsCode,
]),
/*archived*/ None,
)
.await?;
let listed = data
.iter()
.find(|candidate| candidate.id == thread.id)
.expect("expected started thread to be listed");
assert_eq!(listed.status, ThreadStatus::SystemError,);
Ok(())
}
// Minimal config.toml for listing.
fn create_minimal_config(codex_home: &std::path::Path) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
r#"
model = "mock-model"
approval_policy = "never"
"#,
)
}
fn create_runtime_config(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
MockResponsesConfig::new(server_uri).write(codex_home)
}
#[tokio::test]
async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
// Create three rollouts so we can paginate with limit=2.
let _a = create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let _b = create_fake_rollout(
codex_home.path(),
"2025-01-01T13-00-00",
"2025-01-01T13:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let _c = create_fake_rollout(
codex_home.path(),
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
// Page 1: limit 2 → expect next_cursor Some.
let ThreadListResponse {
data: data1,
next_cursor: cursor1,
..
} = list_threads(
&mut mcp,
/*cursor*/ None,
Some(2),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
assert_eq!(data1.len(), 2);
for thread in &data1 {
assert_eq!(thread.preview, "Hello");
assert_eq!(thread.model_provider, "mock_provider");
assert!(thread.created_at > 0);
assert_eq!(thread.updated_at, thread.created_at);
assert_eq!(thread.cwd, test_absolute_path("/"));
assert_eq!(thread.cli_version, "0.0.0");
assert_eq!(thread.source, SessionSource::Cli);
assert_eq!(thread.git_info, None);
assert_eq!(thread.status, ThreadStatus::NotLoaded);
}
let cursor1 = cursor1.expect("expected nextCursor on first page");
// Page 2: with cursor → expect next_cursor None when no more results.
let ThreadListResponse {
data: data2,
next_cursor: cursor2,
..
} = list_threads(
&mut mcp,
Some(cursor1),
Some(2),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
assert!(data2.len() <= 2);
for thread in &data2 {
assert_eq!(thread.preview, "Hello");
assert_eq!(thread.model_provider, "mock_provider");
assert!(thread.created_at > 0);
assert_eq!(thread.updated_at, thread.created_at);
assert_eq!(thread.cwd, test_absolute_path("/"));
assert_eq!(thread.cli_version, "0.0.0");
assert_eq!(thread.source, SessionSource::Cli);
assert_eq!(thread.git_info, None);
assert_eq!(thread.status, ThreadStatus::NotLoaded);
}
assert_eq!(cursor2, None, "expected nextCursor to be null on last page");
Ok(())
}
#[tokio::test]
async fn thread_list_respects_provider_filter() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
// Create rollouts under two providers.
let _a = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"X",
Some("mock_provider"),
/*git_info*/ None,
)?; // mock_provider
let _b = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-00-00",
"2025-01-02T11:00:00Z",
"X",
Some("other_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
// Filter to only other_provider; expect 1 item, nextCursor None.
let ThreadListResponse {
data, next_cursor, ..
} = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["other_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
assert_eq!(data.len(), 1);
assert_eq!(next_cursor, None);
let thread = &data[0];
assert_eq!(thread.preview, "X");
assert_eq!(thread.model_provider, "other_provider");
let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp();
assert_eq!(thread.created_at, expected_ts);
assert_eq!(thread.updated_at, expected_ts);
assert_eq!(thread.cwd, test_absolute_path("/"));
assert_eq!(thread.cli_version, "0.0.0");
assert_eq!(thread.source, SessionSource::Cli);
assert_eq!(thread.git_info, None);
Ok(())
}
#[tokio::test]
async fn thread_list_respects_cwd_filters() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let first_filtered_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"first filtered",
Some("mock_provider"),
/*git_info*/ None,
)?;
let second_filtered_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"second filtered",
Some("mock_provider"),
/*git_info*/ None,
)?;
let unfiltered_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-00-00",
"2025-01-02T11:00:00Z",
"unfiltered",
Some("mock_provider"),
/*git_info*/ None,
)?;
let first_target_cwd = codex_home.path().join("first-target-cwd");
let second_target_cwd = codex_home.path().join("second-target-cwd");
fs::create_dir_all(&first_target_cwd)?;
fs::create_dir_all(&second_target_cwd)?;
set_rollout_cwd(
rollout_path(codex_home.path(), "2025-01-02T10-00-00", &first_filtered_id).as_path(),
&first_target_cwd,
)?;
set_rollout_cwd(
rollout_path(
codex_home.path(),
"2025-01-02T12-00-00",
&second_filtered_id,
)
.as_path(),
&second_target_cwd,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: Some(ThreadListCwdFilter::Many(vec![
first_target_cwd.to_string_lossy().into_owned(),
second_target_cwd.to_string_lossy().into_owned(),
])),
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let ThreadListResponse {
data, next_cursor, ..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(next_cursor, None);
let filtered_ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(
filtered_ids,
vec![second_filtered_id.as_str(), first_filtered_id.as_str()]
);
assert!(!filtered_ids.contains(&unfiltered_id.as_str()));
assert_eq!(data[0].cwd.as_path(), second_target_cwd.as_path());
assert_eq!(data[1].cwd.as_path(), first_target_cwd.as_path());
Ok(())
}
#[tokio::test]
async fn thread_list_respects_search_term_filter() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
model = "mock-model"
approval_policy = "never"
suppress_unstable_features_warning = true
[features]
sqlite = true
"#,
)?;
let older_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"match: needle",
Some("mock_provider"),
/*git_info*/ None,
)?;
let _non_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-00-00",
"2025-01-02T11:00:00Z",
"no hit here",
Some("mock_provider"),
/*git_info*/ None,
)?;
let newer_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"needle suffix",
Some("mock_provider"),
/*git_info*/ None,
)?;
// `thread/list` applies `search_term` on the sqlite fast path. This test creates
// rollouts manually, so mark the DB backfill complete and then run an unsearched
// list large enough to repair every rollout the searched list should find.
let state_db = codex_state::StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
"mock_provider".into(),
)
.await?;
state_db
.mark_backfill_complete(/*last_watermark*/ None)
.await?;
let rollout_config = codex_rollout::RolloutConfig {
codex_home: codex_home.path().to_path_buf(),
sqlite: codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
cwd: codex_home.path().to_path_buf(),
model_provider_id: "mock_provider".to_string(),
generate_memories: false,
};
let repaired_page = codex_core::RolloutRecorder::list_threads(
Some(state_db.clone()),
&rollout_config,
/*page_size*/ 10,
/*cursor*/ None,
codex_core::ThreadSortKey::CreatedAt,
codex_core::SortDirection::Desc,
&[],
/*model_providers*/ None,
/*cwd_filters*/ None,
"mock_provider",
/*search_term*/ None,
)
.await?;
assert_eq!(repaired_page.items.len(), 3);
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: Some("needle".to_string()),
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let ThreadListResponse {
data, next_cursor, ..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![newer_match, older_match]);
Ok(())
}
#[tokio::test]
async fn thread_search_returns_content_matches() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let older_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"match: needle",
Some("mock_provider"),
/*git_info*/ None,
)?;
let _non_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-00-00",
"2025-01-02T11:00:00Z",
"no hit here",
Some("mock_provider"),
/*git_info*/ None,
)?;
let unsectioned_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-30-00",
"2025-01-02T11:30:00Z",
"unsectioned needle",
Some("mock_provider"),
/*git_info*/ None,
)?;
let newer_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"mixed NEEDLE suffix",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: None,
archived: None,
search_term: "needle".to_string(),
})
.await?;
let ThreadSearchResponse {
data, next_cursor, ..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data
.iter()
.map(|result| result.thread.id.as_str())
.collect();
assert_eq!(
ids,
vec![
newer_match.as_str(),
unsectioned_match.as_str(),
older_match.as_str(),
]
);
assert_eq!(data[0].snippet, "mixed NEEDLE suffix");
let mut pinned_threads = Vec::new();
for thread_id in [&older_match, &newer_match] {
let request_id = mcp
.send_thread_section_move_request(ThreadSectionMoveParams {
thread_id: thread_id.clone(),
section_id: Some(codex_state::PINNED_THREAD_SECTION_ID.to_string()),
before_thread_id: None,
})
.await?;
let _: ThreadSectionMoveResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let request_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread_id.clone(),
include_turns: false,
})
.await?;
let ThreadReadResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
pinned_threads.push(thread);
}
let [older_pinned, newer_pinned] = pinned_threads.as_slice() else {
unreachable!("two matching threads were pinned");
};
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: None,
archived: None,
search_term: "needle".to_string(),
})
.await?;
let ThreadSearchResponse {
data, next_cursor, ..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let actual = data
.iter()
.map(|result| {
(
result.thread.id.as_str(),
result.thread.section.clone(),
result.thread.section_entered_at,
)
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![
(
newer_match.as_str(),
newer_pinned.section.clone(),
newer_pinned.section_entered_at,
),
(unsectioned_match.as_str(), None, None),
(
older_match.as_str(),
older_pinned.section.clone(),
older_pinned.section_entered_at,
),
]
);
assert_eq!(next_cursor, None);
Ok(())
}
#[tokio::test]
async fn thread_search_matches_json_escaped_content() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let search_term = r#"quoted "needle" \ path"#;
let thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
search_term,
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: None,
archived: None,
search_term: search_term.to_string(),
})
.await?;
let ThreadSearchResponse { data, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(data.len(), 1);
assert_eq!(data[0].thread.id, thread_id);
assert_eq!(data[0].snippet, search_term);
Ok(())
}
#[tokio::test]
async fn thread_search_filters_by_source_kind() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"shared needle",
Some("mock_provider"),
/*git_info*/ None,
)?;
let exec_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"shared needle",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::Exec,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: Some(vec![ThreadSourceKind::Exec]),
archived: None,
search_term: "needle".to_string(),
})
.await?;
let ThreadSearchResponse { data, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let ids: Vec<_> = data
.iter()
.map(|result| result.thread.id.as_str())
.collect();
assert_eq!(ids, vec![exec_id.as_str()]);
assert_ne!(cli_id, exec_id);
Ok(())
}
#[tokio::test]
async fn thread_list_db_only_errors_distinguish_unavailable_database_from_empty_history()
-> Result<()> {
use codex_app_server::in_process;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeParams;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_core::config::ConfigBuilder;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use std::sync::Arc;
let home = TempDir::new()?;
let other_home = TempDir::new()?;
create_minimal_config(home.path())?;
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = Arc::new(
ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.fallback_cwd(Some(home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?,
);
let right_db = codex_state::StateRuntime::init(
config.sqlite_config().clone(),
config.model_provider_id.clone(),
)
.await?;
let wrong_db = codex_state::StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(other_home.path().abs()),
config.model_provider_id.clone(),
)
.await?;
// A rollout file must not conceal a failed database-only read.
create_fake_rollout(
home.path(),
"2025-01-03T12-00-00",
"2025-01-03T12:00:00Z",
"only in the rollout",
Some("mock_provider"),
/*git_info*/ None,
)?;
for (state_db, should_succeed) in [
(None, false),
(Some(wrong_db), false),
(Some(right_db), true),
] {
let client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::clone(&config),
cli_overrides: Vec::new(),
loader_overrides: loader_overrides.clone(),
strict_config: false,
cloud_config_bundle: CloudConfigBundleLoader::default(),
embedded_network_policy: Default::default(),
thread_config_loader: Arc::new(NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: CoreSessionSource::Cli,
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".into(),
title: None,
version: "0.1.0".into(),
},
capabilities: None,
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
for archived in [false, true] {
for cwd in [json!(null), json!([])] {
let result = client
.request(ClientRequest::ThreadList {
request_id: RequestId::Integer(1),
params: serde_json::from_value(json!({
"useStateDbOnly": true,
"modelProviders": [],
"archived": archived,
"cwd": cwd,
}))?,
})
.await?;
if should_succeed {
let page: ThreadListResponse = serde_json::from_value(
result.expect("a healthy, empty database should list successfully"),
)?;
assert_eq!(
page,
ThreadListResponse {
data: vec![],
read_states: Some(HashMap::new()),
next_cursor: None,
backwards_cursor: None
}
);
} else {
let error =
result.expect_err("an unavailable database must not look exhausted");
assert_eq!(error.code, -32603);
}
}
}
}
Ok(())
}
#[tokio::test]
async fn thread_list_state_db_only_returns_sqlite_without_jsonl_repair() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
model = "mock-model"
approval_policy = "never"
suppress_unstable_features_warning = true
[features]
sqlite = true
"#,
)?;
let thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"state db only should not see this before repair",
Some("mock_provider"),
/*git_info*/ None,
)?;
let state_db = codex_state::StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
"mock_provider".into(),
)
.await?;
state_db
.mark_backfill_complete(/*last_watermark*/ None)
.await?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let repaired_response: ThreadListResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let ids: Vec<_> = repaired_response
.data
.iter()
.map(|thread| thread.id.as_str())
.collect();
assert_eq!(ids, vec![thread_id.as_str()]);
let thread_uuid = ThreadId::from_string(&thread_id)?;
let stale_cwd = codex_home.path().join("stale-cwd");
let mut metadata = state_db
.get_thread(thread_uuid)
.await?
.expect("thread should be repaired into sqlite");
metadata.cwd = stale_cwd.clone();
state_db.upsert_thread(&metadata).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: Some(ThreadListCwdFilter::One(
stale_cwd.to_string_lossy().into_owned(),
)),
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let state_db_only_response: ThreadListResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let ids: Vec<_> = state_db_only_response
.data
.iter()
.map(|thread| thread.id.as_str())
.collect();
assert_eq!(ids, vec![thread_id.as_str()]);
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: Some(ThreadListCwdFilter::One(
stale_cwd.to_string_lossy().into_owned(),
)),
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let scanned_response: ThreadListResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(scanned_response.data.len(), 0);
Ok(())
}
#[tokio::test]
async fn thread_list_relation_filters_read_spawn_graph_from_state_db() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let mut mcp = init_mcp(codex_home.path()).await?;
let parent_id = ThreadId::new();
let older_child_id = ThreadId::new();
let newer_child_id = ThreadId::new();
let grandchild_id = ThreadId::new();
let state_db = codex_state::StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
"mock_provider".to_string(),
)
.await?;
for (thread_id, created_at, source, model_provider) in [
(
older_child_id,
"2025-02-01T10:00:00Z",
CoreSessionSource::SubAgent(SubAgentSource::Other("custom:worker-1".to_string())),
"other_provider",
),
(
newer_child_id,
"2025-02-01T11:00:00Z",
CoreSessionSource::Cli,
"mock_provider",
),
(
grandchild_id,
"2025-02-01T12:00:00Z",
CoreSessionSource::SubAgent(SubAgentSource::Other("custom:worker-2".to_string())),
"mock_provider",
),
] {
let created_at = DateTime::parse_from_rfc3339(created_at)?.with_timezone(&Utc);
let mut builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
codex_home.path().join(format!("{thread_id}.jsonl")),
created_at,
source,
);
builder.model_provider = Some(model_provider.to_string());
builder.cwd = codex_home.path().to_path_buf();
builder.cli_version = Some("0.0.0".to_string());
let mut metadata = builder.build(model_provider);
metadata.preview = Some("child thread".to_string());
metadata.first_user_message = metadata.preview.clone();
state_db.upsert_thread(&metadata).await?;
}
for (parent_thread_id, child_thread_id) in [
(parent_id, older_child_id),
(parent_id, newer_child_id),
(newer_child_id, grandchild_id),
] {
state_db
.upsert_thread_spawn_edge(
parent_thread_id,
child_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await?;
}
state_db
.mark_backfill_complete(/*last_watermark*/ None)
.await?;
let first_page = list_threads_for_relation(
&mut mcp,
ThreadListRelation::DirectChildrenOf(parent_id),
/*cursor*/ None,
/*limit*/ 1,
/*model_providers*/ None,
/*source_kinds*/ None,
)
.await?;
let second_page = list_threads_for_relation(
&mut mcp,
ThreadListRelation::DirectChildrenOf(parent_id),
first_page.next_cursor.clone(),
/*limit*/ 1,
/*model_providers*/ None,
/*source_kinds*/ None,
)
.await?;
assert_eq!(
first_page
.data
.iter()
.map(|thread| thread.id.clone())
.collect::<Vec<_>>(),
vec![newer_child_id.to_string()]
);
assert_eq!(
second_page
.data
.iter()
.map(|thread| thread.id.clone())
.collect::<Vec<_>>(),
vec![older_child_id.to_string()]
);
assert_eq!(second_page.next_cursor, None);
let expected_parent_id = parent_id.to_string();
assert!(
first_page
.data
.iter()
.chain(&second_page.data)
.all(|thread| thread.parent_thread_id.as_deref() == Some(expected_parent_id.as_str()))
);
let interactive_only = list_threads_for_relation(
&mut mcp,
ThreadListRelation::DirectChildrenOf(parent_id),
/*cursor*/ None,
/*limit*/ 10,
/*model_providers*/ None,
/*source_kinds*/ Some(Vec::new()),
)
.await?;
assert_eq!(
interactive_only
.data
.iter()
.map(|thread| thread.id.clone())
.collect::<Vec<_>>(),
vec![newer_child_id.to_string()]
);
let descendants = list_threads_for_relation(
&mut mcp,
ThreadListRelation::DescendantsOf(parent_id),
/*cursor*/ None,
/*limit*/ 10,
/*model_providers*/ None,
/*source_kinds*/ None,
)
.await?;
assert_eq!(
descendants
.data
.iter()
.map(|thread| (thread.id.clone(), thread.parent_thread_id.clone()))
.collect::<Vec<_>>(),
vec![
(grandchild_id.to_string(), Some(newer_child_id.to_string())),
(newer_child_id.to_string(), Some(parent_id.to_string())),
(older_child_id.to_string(), Some(parent_id.to_string())),
]
);
assert_eq!(descendants.next_cursor, None);
Ok(())
}
#[tokio::test]
async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: None,
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: Some("not-a-thread-id".to_string()),
ancestor_thread_id: None,
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
let thread_id = ThreadId::new().to_string();
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: None,
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: Some(thread_id.clone()),
ancestor_thread_id: Some(thread_id),
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert_eq!(
error.error.message,
"parentThreadId and ancestorThreadId are mutually exclusive"
);
Ok(())
}
#[tokio::test]
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"CLI",
Some("mock_provider"),
/*git_info*/ None,
)?;
let exec_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"Exec",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::Exec,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse {
data, next_cursor, ..
} = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(Vec::new()),
/*archived*/ None,
)
.await?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![cli_id.as_str()]);
assert_ne!(cli_id, exec_id);
assert_eq!(data[0].source, SessionSource::Cli);
Ok(())
}
#[tokio::test]
async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri())
.disable_feature(Feature::MultiAgentV2)
.enable_feature(Feature::Collab)
.write(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T09-00-00",
"2025-02-01T09:00:00Z",
"CLI",
Some("mock_provider"),
/*git_info*/ None,
)?;
let parent_thread_id = ThreadId::from_string(&cli_id)?;
let parent_rollout_path = rollout_path(codex_home.path(), "2025-02-01T09-00-00", &cli_id);
let mut parent_meta = read_session_meta_line(&parent_rollout_path).await?;
parent_meta.meta.multi_agent_version = Some(MultiAgentVersion::V2);
append_rollout_item_to_path(&parent_rollout_path, &RolloutItem::SessionMeta(parent_meta))
.await?;
// Legacy children resume before the root restores the V2 registry from persisted spawn edges.
let mut expected = vec![(cli_id.clone(), None, false)];
let mut threads_to_resume = vec![(cli_id.clone(), SessionSource::Cli, Some(true))];
for (filename_ts, timestamp, version, capability, should_resume) in [
(
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
Some(MultiAgentVersion::V1),
Some(true),
true,
),
(
"2025-02-01T10-30-00",
"2025-02-01T10:30:00Z",
None,
Some(true),
true,
),
(
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
Some(MultiAgentVersion::V2),
Some(false),
true,
),
(
"2025-02-01T12-00-00",
"2025-02-01T12:00:00Z",
Some(MultiAgentVersion::V2),
None,
false,
),
] {
let thread_id = create_fake_parented_rollout_with_source(
codex_home.path(),
filename_ts,
timestamp,
"Subagent",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
parent_thread_id.into(),
parent_thread_id,
)?;
let path = rollout_path(codex_home.path(), filename_ts, &thread_id);
let mut session_meta = read_session_meta_line(&path).await?;
let source = SessionSource::from(session_meta.meta.source.clone());
if let Some(version) = version {
session_meta.meta.multi_agent_version = Some(version);
append_rollout_item_to_path(&path, &RolloutItem::SessionMeta(session_meta)).await?;
}
if should_resume {
let resume = (thread_id.clone(), source, capability);
if version == Some(MultiAgentVersion::V2) {
threads_to_resume.push(resume);
} else {
threads_to_resume.insert(/*index*/ 0, resume);
}
}
expected.push((thread_id, capability, !should_resume));
}
let mut mcp = init_mcp(codex_home.path()).await?;
let mut loaded_settings = HashMap::new();
for (thread_id, source, capability) in threads_to_resume {
let (model, effort) = if thread_id == cli_id {
("gpt-5.2", "high")
} else {
("gpt-5.4", "low")
};
let request_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.clone(),
model: Some(model.to_string()),
config: Some([("model_reasoning_effort".to_string(), json!(effort))].into()),
..Default::default()
})
.await?;
let ThreadResumeResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(
(&thread.id, &thread.source, thread.can_accept_direct_input),
(&thread_id, &source, capability)
);
loaded_settings.insert(thread_id, (thread.model, thread.reasoning_effort));
}
let response = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![
ThreadSourceKind::Cli,
ThreadSourceKind::SubAgentThreadSpawn,
]),
/*archived*/ None,
)
.await?;
for thread in &response.data {
let expected_settings = loaded_settings
.get(&thread.id)
.map(|(model, effort)| (model.as_deref(), effort.clone()))
.unwrap_or((None, None));
assert_eq!(
(thread.model.as_deref(), thread.reasoning_effort.clone()),
expected_settings
);
}
expected.reverse();
assert_eq!(
response
.data
.into_iter()
.map(|thread| {
(
thread.id,
thread.can_accept_direct_input,
matches!(thread.status, ThreadStatus::NotLoaded),
)
})
.collect::<Vec<_>>(),
expected
);
assert_eq!(response.next_cursor, None);
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
archived: None,
search_term: "Subagent".to_string(),
})
.await?;
let response: ThreadSearchResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let expected_subagents: Vec<_> = expected
.into_iter()
.filter(|(thread_id, _, _)| thread_id != &cli_id)
.collect();
assert_eq!(
response
.data
.into_iter()
.map(|result| {
let thread = result.thread;
(
thread.id,
thread.can_accept_direct_input,
matches!(thread.status, ThreadStatus::NotLoaded),
)
})
.collect::<Vec<_>>(),
expected_subagents
);
let state_db = codex_state::StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
"mock_provider".to_string(),
)
.await?;
for (thread_id, _, _) in &expected_subagents {
state_db
.upsert_thread_spawn_edge(
parent_thread_id,
ThreadId::from_string(thread_id)?,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await?;
}
state_db
.mark_backfill_complete(/*last_watermark*/ None)
.await?;
let response: ThreadListResponse = mcp
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: Some(parent_thread_id.to_string()),
},
})
.await?;
assert!(
response
.data
.iter()
.all(|thread| thread.parent_thread_id.as_deref() == Some(cli_id.as_str()))
);
assert_eq!(
response
.data
.into_iter()
.map(|thread| {
(
thread.id,
thread.can_accept_direct_input,
matches!(thread.status, ThreadStatus::NotLoaded),
)
})
.collect::<Vec<_>>(),
expected_subagents
);
assert_eq!(response.next_cursor, None);
Ok(())
}
#[tokio::test]
async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"CLI",
Some("mock_provider"),
/*git_info*/ None,
)?;
let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let subagent_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"SubAgent",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse {
data, next_cursor, ..
} = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
/*archived*/ None,
)
.await?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![subagent_id.as_str()]);
assert_ne!(cli_id, subagent_id);
assert!(matches!(data[0].source, SessionSource::SubAgent(_)));
assert_eq!(data[0].session_id, subagent_id);
Ok(())
}
#[tokio::test]
async fn thread_list_filters_by_subagent_variant() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
let review_id = create_fake_parented_rollout_with_source(
codex_home.path(),
"2025-02-02T09-00-00",
"2025-02-02T09:00:00Z",
"Review",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::SubAgent(SubAgentSource::Review),
parent_thread_id.into(),
parent_thread_id,
)?;
let compact_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T10-00-00",
"2025-02-02T10:00:00Z",
"Compact",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::SubAgent(SubAgentSource::Compact),
)?;
let spawn_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T11-00-00",
"2025-02-02T11:00:00Z",
"Spawn",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
)?;
let other_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-02T12-00-00",
"2025-02-02T12:00:00Z",
"Other",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::SubAgent(SubAgentSource::Other("custom".to_string())),
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let review = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentReview]),
/*archived*/ None,
)
.await?;
let review_ids: Vec<_> = review
.data
.iter()
.map(|thread| thread.id.as_str())
.collect();
assert_eq!(review_ids, vec![review_id.as_str()]);
assert_eq!(
review.data[0].parent_thread_id,
Some(parent_thread_id.to_string())
);
let compact = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentCompact]),
/*archived*/ None,
)
.await?;
let compact_ids: Vec<_> = compact
.data
.iter()
.map(|thread| thread.id.as_str())
.collect();
assert_eq!(compact_ids, vec![compact_id.as_str()]);
let spawn = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
/*archived*/ None,
)
.await?;
let spawn_ids: Vec<_> = spawn.data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(spawn_ids, vec![spawn_id.as_str()]);
let other = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
Some(vec![ThreadSourceKind::SubAgentOther]),
/*archived*/ None,
)
.await?;
let other_ids: Vec<_> = other.data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(other_ids, vec![other_id.as_str()]);
Ok(())
}
#[tokio::test]
async fn thread_list_fetches_until_limit_or_exhausted() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
// Newest 16 conversations belong to a different provider; the older 8 are the
// only ones that match the filter. We request 8 so the server must keep
// paging past the first two pages to reach the desired count.
create_fake_rollouts(
codex_home.path(),
/*count*/ 24,
|i| {
if i < 16 {
"skip_provider"
} else {
"target_provider"
}
},
|i| {
timestamp_at(
/*year*/ 2025,
/*month*/ 3,
30 - i as u32,
/*hour*/ 12,
/*minute*/ 0,
/*second*/ 0,
)
},
"Hello",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
// Request 8 threads for the target provider; the matches only start on the
// third page so we rely on pagination to reach the limit.
let ThreadListResponse {
data, next_cursor, ..
} = list_threads(
&mut mcp,
/*cursor*/ None,
Some(8),
Some(vec!["target_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
assert_eq!(
data.len(),
8,
"should keep paging until the requested count is filled"
);
assert!(
data.iter()
.all(|thread| thread.model_provider == "target_provider"),
"all returned threads must match the requested provider"
);
assert_eq!(
next_cursor, None,
"once the requested count is satisfied on the final page, nextCursor should be None"
);
Ok(())
}
#[tokio::test]
async fn thread_list_enforces_max_limit() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
create_fake_rollouts(
codex_home.path(),
/*count*/ 105,
|_| "mock_provider",
|i| {
let month = 5 + (i / 28);
let day = (i % 28) + 1;
timestamp_at(
/*year*/ 2025,
month as u32,
day as u32,
/*hour*/ 0,
/*minute*/ 0,
/*second*/ 0,
)
},
"Hello",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse {
data, next_cursor, ..
} = list_threads(
&mut mcp,
/*cursor*/ None,
Some(200),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
assert_eq!(
data.len(),
100,
"limit should be clamped to the maximum page size"
);
assert!(
next_cursor.is_some(),
"when more than the maximum exist, nextCursor should continue pagination"
);
Ok(())
}
#[tokio::test]
async fn thread_list_stops_when_not_enough_filtered_results_exist() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
// Only the last 7 conversations match the provider filter; we ask for 10 to
// ensure the server exhausts pagination without looping forever.
create_fake_rollouts(
codex_home.path(),
/*count*/ 22,
|i| {
if i < 15 {
"skip_provider"
} else {
"target_provider"
}
},
|i| {
timestamp_at(
/*year*/ 2025,
/*month*/ 4,
28 - i as u32,
/*hour*/ 8,
/*minute*/ 0,
/*second*/ 0,
)
},
"Hello",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
// Request more threads than exist after filtering; expect all matches to be
// returned with nextCursor None.
let ThreadListResponse {
data, next_cursor, ..
} = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["target_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
assert_eq!(
data.len(),
7,
"all available filtered threads should be returned"
);
assert!(
data.iter()
.all(|thread| thread.model_provider == "target_provider"),
"results should still respect the provider filter"
);
assert_eq!(
next_cursor, None,
"when results are exhausted before reaching the limit, nextCursor should be None"
);
Ok(())
}
#[tokio::test]
async fn thread_list_includes_git_info() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let git_info = CoreGitInfo {
commit_hash: Some(GitSha::new("abc123")),
branch: Some("main".to_string()),
repository_url: Some(
SanitizedGitUrl::try_from("https://example.com/repo.git")
.expect("repository URL should be valid"),
),
};
let conversation_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T09-00-00",
"2025-02-01T09:00:00Z",
"Git info preview",
Some("mock_provider"),
Some(git_info),
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
let thread = data
.iter()
.find(|t| t.id == conversation_id)
.expect("expected thread for created rollout");
let expected_git = ApiGitInfo {
sha: Some("abc123".to_string()),
branch: Some("main".to_string()),
origin_url: Some("https://example.com/repo.git".to_string()),
};
assert_eq!(thread.git_info, Some(expected_git));
assert_eq!(thread.source, SessionSource::Cli);
assert_eq!(thread.cwd, test_absolute_path("/"));
assert_eq!(thread.cli_version, "0.0.0");
Ok(())
}
/// Legacy rollout credentials must be sanitized before thread/list returns Git metadata.
#[tokio::test]
async fn thread_list_sanitizes_git_info_from_existing_rollouts() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let git_info = CoreGitInfo {
commit_hash: Some(GitSha::new("abc123")),
branch: Some("main".to_string()),
repository_url: Some(
SanitizedGitUrl::try_from("https://example.com/repo.git")
.expect("repository URL should be valid"),
),
};
let conversation_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T09-00-00",
"2025-02-01T09:00:00Z",
"Git info preview",
Some("mock_provider"),
Some(git_info),
)?;
let path = rollout_path(codex_home.path(), "2025-02-01T09-00-00", &conversation_id);
let rollout = fs::read_to_string(&path)?;
fs::write(
path,
rollout.replace(
"https://example.com/repo.git",
"https://alice:synthetic-rollout-secret@example.com/repo.git",
),
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
let thread = data
.iter()
.find(|thread| thread.id == conversation_id)
.expect("expected thread for created rollout");
assert_eq!(
thread.git_info,
Some(ApiGitInfo {
sha: Some("abc123".to_string()),
branch: Some("main".to_string()),
origin_url: Some("https://example.com/repo.git".to_string()),
})
);
Ok(())
}
#[tokio::test]
async fn thread_list_default_sorts_by_created_at() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_a = create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_b = create_fake_rollout(
codex_home.path(),
"2025-01-01T13-00-00",
"2025-01-01T13:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_c = create_fake_rollout(
codex_home.path(),
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads_with_sort(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
/*sort_key*/ None,
/*archived*/ None,
)
.await?;
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
Ok(())
}
#[tokio::test]
async fn thread_list_sort_updated_at_orders_by_mtime() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_old = create_fake_rollout(
codex_home.path(),
"2025-01-01T10-00-00",
"2025-01-01T10:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_mid = create_fake_rollout(
codex_home.path(),
"2025-01-01T11-00-00",
"2025-01-01T11:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_new = create_fake_rollout(
codex_home.path(),
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(),
"2025-01-03T00:00:00Z",
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-01-01T11-00-00", &id_mid).as_path(),
"2025-01-02T00:00:00Z",
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-01-01T12-00-00", &id_new).as_path(),
"2025-01-01T00:00:00Z",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads_with_sort(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
Some(ThreadSortKey::UpdatedAt),
/*archived*/ None,
)
.await?;
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![id_old.as_str(), id_mid.as_str(), id_new.as_str()]);
Ok(())
}
#[tokio::test]
async fn thread_list_sort_recency_at_uses_state_db_order_with_provider_filter() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_old = create_fake_rollout(
codex_home.path(),
"2025-01-01T10-00-00",
"2025-01-01T10:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_new = create_fake_rollout(
codex_home.path(),
"2025-01-01T11-00-00",
"2025-01-01T11:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-01-01T10-00-00", &id_old).as_path(),
"2025-01-03T00:00:00Z",
)?;
let state_db = codex_state::StateRuntime::init(
codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
"mock_provider".into(),
)
.await?;
state_db
.mark_backfill_complete(/*last_watermark*/ None)
.await?;
let rollout_config = codex_rollout::RolloutConfig {
codex_home: codex_home.path().to_path_buf(),
sqlite: codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()),
cwd: codex_home.path().to_path_buf(),
model_provider_id: "mock_provider".to_string(),
generate_memories: false,
};
codex_core::RolloutRecorder::list_threads(
Some(state_db.clone()),
&rollout_config,
/*page_size*/ 10,
/*cursor*/ None,
codex_core::ThreadSortKey::CreatedAt,
codex_core::SortDirection::Desc,
codex_core::INTERACTIVE_SESSION_SOURCES.as_slice(),
/*model_providers*/ None,
/*cwd_filters*/ None,
"mock_provider",
/*search_term*/ None,
)
.await?;
state_db
.touch_thread_recency_at(
ThreadId::from_string(&id_new)?,
DateTime::<Utc>::from_timestamp(1_800_000_000, 0).expect("timestamp"),
)
.await?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads_with_sort(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
Some(ThreadSortKey::RecencyAt),
/*archived*/ None,
)
.await?;
assert_eq!(
data.iter()
.map(|thread| thread.id.as_str())
.collect::<Vec<_>>(),
vec![id_new.as_str(), id_old.as_str()]
);
assert!(data.iter().all(|thread| thread.recency_at.is_some()));
Ok(())
}
#[tokio::test]
async fn thread_list_updated_at_paginates_with_cursor() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_a = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_b = create_fake_rollout(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_c = create_fake_rollout(
codex_home.path(),
"2025-02-01T12-00-00",
"2025-02-01T12:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(),
"2025-02-03T00:00:00Z",
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(),
"2025-02-02T00:00:00Z",
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T12-00-00", &id_c).as_path(),
"2025-02-01T00:00:00Z",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse {
data: page1,
next_cursor: cursor1,
..
} = list_threads_with_sort(
&mut mcp,
/*cursor*/ None,
Some(2),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
Some(ThreadSortKey::UpdatedAt),
/*archived*/ None,
)
.await?;
let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids_page1, vec![id_a.as_str(), id_b.as_str()]);
let cursor1 = cursor1.expect("expected nextCursor on first page");
let ThreadListResponse {
data: page2,
next_cursor: cursor2,
..
} = list_threads_with_sort(
&mut mcp,
Some(cursor1),
Some(2),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
Some(ThreadSortKey::UpdatedAt),
/*archived*/ None,
)
.await?;
let ids_page2: Vec<_> = page2.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids_page2, vec![id_c.as_str()]);
assert_eq!(cursor2, None);
Ok(())
}
#[tokio::test]
async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_old = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_watermark = create_fake_rollout(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_old).as_path(),
"2025-02-02T00:00:00Z",
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_watermark).as_path(),
"2025-02-03T00:00:00Z",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse {
data: page1,
backwards_cursor,
..
} = {
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: None,
limit: Some(1),
sort_key: Some(ThreadSortKey::UpdatedAt),
sort_direction: Some(SortDirection::Desc),
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??
};
let ids_page1: Vec<_> = page1.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids_page1, vec![id_watermark.as_str()]);
let backwards_cursor = backwards_cursor.expect("expected backwardsCursor on first page");
assert_eq!(backwards_cursor, "2025-02-02T23:59:59.999Z");
let id_new = create_fake_rollout(
codex_home.path(),
"2025-02-01T12-00-00",
"2025-02-01T12:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T12-00-00", &id_new).as_path(),
"2025-02-04T00:00:00Z",
)?;
let ThreadListResponse {
data: delta_page, ..
} = {
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: Some(backwards_cursor),
limit: Some(10),
sort_key: Some(ThreadSortKey::UpdatedAt),
sort_direction: Some(SortDirection::Asc),
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??
};
let ids_delta: Vec<_> = delta_page.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids_delta, vec![id_watermark.as_str(), id_new.as_str()]);
Ok(())
}
#[tokio::test]
async fn thread_list_created_at_tie_breaks_by_uuid() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_a = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_b = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
let mut expected = [id_a, id_b];
expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse")));
let expected: Vec<_> = expected.iter().map(String::as_str).collect();
assert_eq!(ids, expected);
Ok(())
}
#[tokio::test]
async fn thread_list_updated_at_tie_breaks_by_uuid() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let id_a = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let id_b = create_fake_rollout(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
let updated_at = "2025-02-03T00:00:00Z";
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T10-00-00", &id_a).as_path(),
updated_at,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T11-00-00", &id_b).as_path(),
updated_at,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads_with_sort(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
Some(ThreadSortKey::UpdatedAt),
/*archived*/ None,
)
.await?;
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
let mut expected = [id_a, id_b];
expected.sort_by_key(|id| Reverse(Uuid::parse_str(id).expect("uuid should parse")));
let expected: Vec<_> = expected.iter().map(String::as_str).collect();
assert_eq!(ids, expected);
Ok(())
}
#[tokio::test]
async fn thread_list_updated_at_uses_mtime() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let thread_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"Hello",
Some("mock_provider"),
/*git_info*/ None,
)?;
set_rollout_mtime(
rollout_path(codex_home.path(), "2025-02-01T10-00-00", &thread_id).as_path(),
"2025-02-05T00:00:00Z",
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads_with_sort(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
Some(ThreadSortKey::UpdatedAt),
/*archived*/ None,
)
.await?;
let thread = data
.iter()
.find(|item| item.id == thread_id)
.expect("expected thread for created rollout");
let expected_created =
chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z")?.timestamp();
let expected_updated =
chrono::DateTime::parse_from_rfc3339("2025-02-05T00:00:00Z")?.timestamp();
assert_eq!(thread.created_at, expected_created);
assert_eq!(thread.updated_at, expected_updated);
Ok(())
}
#[tokio::test]
async fn thread_list_archived_filter() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let active_id = create_fake_rollout(
codex_home.path(),
"2025-03-01T10-00-00",
"2025-03-01T10:00:00Z",
"Active",
Some("mock_provider"),
/*git_info*/ None,
)?;
let archived_id = create_fake_rollout(
codex_home.path(),
"2025-03-01T09-00-00",
"2025-03-01T09:00:00Z",
"Archived",
Some("mock_provider"),
/*git_info*/ None,
)?;
let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR);
fs::create_dir_all(&archived_dir)?;
let archived_source = rollout_path(codex_home.path(), "2025-03-01T09-00-00", &archived_id);
let archived_dest = archived_dir.join(
archived_source
.file_name()
.expect("archived rollout should have a file name"),
);
fs::rename(&archived_source, &archived_dest)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadListResponse { data, .. } = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
/*archived*/ None,
)
.await?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, active_id);
let ThreadListResponse { data, .. } = list_threads(
&mut mcp,
/*cursor*/ None,
Some(10),
Some(vec!["mock_provider".to_string()]),
/*source_kinds*/ None,
Some(true),
)
.await?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, archived_id);
Ok(())
}
#[tokio::test]
async fn thread_list_keeps_archived_threads_without_previews() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let mut archived_ids = Vec::new();
for (timestamp, metadata_timestamp) in [
("2025-03-01T09-00-00", "2025-03-01T09:00:00Z"),
("2025-03-01T10-00-00", "2025-03-01T10:00:00Z"),
] {
archived_ids.push(create_fake_rollout(
codex_home.path(),
timestamp,
metadata_timestamp,
"",
Some("mock_provider"),
/*git_info*/ None,
)?);
}
let mut mcp = init_mcp(codex_home.path()).await?;
for thread_id in &archived_ids {
let _: codex_app_server_protocol::ThreadArchiveResponse = mcp
.request(|request_id| ClientRequest::ThreadArchive {
request_id,
params: codex_app_server_protocol::ThreadArchiveParams {
thread_id: thread_id.clone(),
},
})
.await?;
}
archived_ids.reverse();
for use_state_db_only in [true, false] {
let mut cursor = None;
let mut found_ids = Vec::new();
loop {
let page: ThreadListResponse = mcp
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: serde_json::from_value(json!({
"archived": true,
"modelProviders": [],
"limit": 1,
"cursor": cursor,
"useStateDbOnly": use_state_db_only,
}))
.expect("valid list params"),
})
.await?;
for thread in page.data {
assert_eq!(thread.preview, "");
found_ids.push(thread.id);
}
cursor = page.next_cursor;
if cursor.is_none() {
break;
}
assert!(found_ids.len() <= archived_ids.len());
}
assert_eq!(found_ids, archived_ids);
}
Ok(())
}
#[tokio::test]
async fn thread_list_rejects_originator_filter_but_accepts_empty_allowlist() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(serde_json::from_value(json!({
"originators": ["future_client"]
}))?)
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(
(error.error.code, error.error.message),
(
-32602,
"originator filtering is not supported by the local app-server".to_string()
),
);
for params in [
json!({}),
json!({"originators": null}),
json!({"originators": []}),
] {
let response: ThreadListResponse = mcp
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: serde_json::from_value(params).expect("valid list params"),
})
.await?;
assert_eq!(response.data, Vec::new());
}
Ok(())
}
#[test_case::test_case("codex_work_desktop")]
#[tokio::test]
async fn thread_originator_is_preserved_in_list_read_and_resume(originator: &str) -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_runtime_config(codex_home.path(), &server.uri())?;
let mut mcp = init_mcp(codex_home.path()).await?;
let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams {
service_name: Some(originator.to_string()),
..Default::default()
})
.await?;
assert_eq!(thread.originator.as_deref(), Some(originator));
let started: codex_app_server_protocol::ThreadStartedNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_notification("thread/started"),
)
.await??;
assert_eq!(started.thread, thread);
let thread_id = thread.id;
// The first turn exercises live metadata persistence, not rollout-file backfill.
let _: TurnStartResponse = mcp
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread_id.clone(),
input: vec![UserInput::Text {
text: "Persist this thread".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
},
})
.await?;
let completed: codex_app_server_protocol::TurnCompletedNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_notification("turn/completed"),
)
.await??;
assert_eq!(
completed.turn.status,
codex_app_server_protocol::TurnStatus::Completed
);
for restart in [false, true] {
if restart {
assert!(
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully())
.await??
.success()
);
mcp = init_mcp(codex_home.path()).await?;
}
let response: ThreadListResponse = mcp
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: serde_json::from_value(json!({"useStateDbOnly": true}))
.expect("valid list params"),
})
.await?;
assert_eq!(
response
.data
.into_iter()
.map(|thread| (thread.id, thread.originator))
.collect::<Vec<_>>(),
vec![(thread_id.clone(), Some(originator.to_string()))],
);
let read: ThreadReadResponse = mcp
.request(|request_id| ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: thread_id.clone(),
include_turns: false,
},
})
.await?;
assert_eq!(read.thread.originator.as_deref(), Some(originator));
}
let resumed: ThreadResumeResponse = mcp
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
thread_id: thread_id.clone(),
..Default::default()
},
})
.await?;
assert_eq!(resumed.thread.originator.as_deref(), Some(originator));
Ok(())
}
#[tokio::test]
async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
excluded_thread_ids: None,
originators: None,
cursor: Some("not-a-cursor".to_string()),
limit: Some(2),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert_eq!(error.error.message, "invalid cursor: not-a-cursor");
Ok(())
}