mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## What changed - Add the creation-time `originator` to app-server thread responses and `thread/started` notifications. - Persist originators in thread metadata and preserve the first recorded value across list, read, resume, rollout, and SQLite paths. - Add an `originators` allowlist to `thread/list` for hosted backends. The local app-server accepts omitted or empty values but rejects nonempty filters. ## Testing - Cover originator persistence across server restarts and list, read, and resume requests. - Verify local `thread/list` handling for nonempty, omitted, null, and empty originator filters. - Heap-allocate large TUI dispatcher futures in affected tests to keep them within the Windows test-thread stack. GitOrigin-RevId: 73ad6e61b8fdd2d93a22d1f971723a19ec7be3fc
2781 lines
84 KiB
Rust
2781 lines
84 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 {
|
|
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 {
|
|
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_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 {
|
|
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 {
|
|
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_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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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_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 {
|
|
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(())
|
|
}
|