Support multiple cwd filters for thread list (#18502)

## Summary

- Teach app-server `thread/list` to accept either a single `cwd` or an
array of cwd filters, returning threads whose recorded session cwd
matches any requested path
- Add `useStateDbOnly` as an explicit opt-in fast path for callers that
want to answer `thread/list` from SQLite without scanning JSONL rollout
files
- Preserve backwards compatibility: by default, `thread/list` still
scans JSONL rollouts and repairs SQLite state
- Wire the new cwd array and SQLite-only options through app-server,
local/remote thread-store, rollout listing, generated TypeScript/schema
fixtures, proto output, and docs

## Test Plan

- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-rollout`
- `cargo test -p codex-thread-store`
- `cargo test -p codex-app-server thread_list`
- `just fmt`
- `just fix -p codex-app-server-protocol -p codex-rollout -p
codex-thread-store -p codex-app-server`
- `cargo build -p codex-cli --bin codex`
This commit is contained in:
acrognale-oai
2026-04-22 06:10:09 -04:00
committed by GitHub
parent b04ffeee4c
commit 4f8c58f737
32 changed files with 1183 additions and 133 deletions

View File

@@ -548,6 +548,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
})
.await?;

View File

@@ -15,6 +15,7 @@ use codex_app_server_protocol::JSONRPCResponse;
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::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
@@ -90,6 +91,7 @@ async fn list_threads_with_sort(
source_kinds,
archived,
cwd: None,
use_state_db_only: false,
search_term: None,
})
.await?;
@@ -468,15 +470,23 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
}
#[tokio::test]
async fn thread_list_respects_cwd_filter() -> Result<()> {
async fn thread_list_respects_cwd_filters() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let filtered_id = create_fake_rollout(
let first_filtered_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"filtered",
"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,
)?;
@@ -489,11 +499,22 @@ async fn thread_list_respects_cwd_filter() -> Result<()> {
/*git_info*/ None,
)?;
let target_cwd = codex_home.path().join("target-cwd");
fs::create_dir_all(&target_cwd)?;
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", &filtered_id).as_path(),
&target_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?;
@@ -506,7 +527,11 @@ async fn thread_list_respects_cwd_filter() -> Result<()> {
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: Some(target_cwd.to_string_lossy().into_owned()),
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,
})
.await?;
@@ -520,10 +545,14 @@ async fn thread_list_respects_cwd_filter() -> Result<()> {
} = to_response::<ThreadListResponse>(resp)?;
assert_eq!(next_cursor, None);
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, filtered_id);
assert_ne!(data[0].id, unfiltered_id);
assert_eq!(data[0].cwd.as_path(), target_cwd.as_path());
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(())
}
@@ -592,6 +621,7 @@ sqlite = true
codex_core::SortDirection::Desc,
&[],
/*model_providers*/ None,
/*cwd_filters*/ None,
"mock_provider",
/*search_term*/ None,
)
@@ -609,6 +639,7 @@ sqlite = true
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: Some("needle".to_string()),
})
.await?;
@@ -628,6 +659,129 @@ sqlite = true
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_home.path().to_path_buf(), "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 {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let repaired_response = to_response::<ThreadListResponse>(resp)?;
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 {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: Some(ThreadListCwdFilter::One(
stale_cwd.to_string_lossy().into_owned(),
)),
use_state_db_only: true,
search_term: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let state_db_only_response = to_response::<ThreadListResponse>(resp)?;
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 {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: Some(ThreadListCwdFilter::One(
stale_cwd.to_string_lossy().into_owned(),
)),
use_state_db_only: false,
search_term: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let scanned_response = to_response::<ThreadListResponse>(resp)?;
assert_eq!(scanned_response.data.len(), 0);
Ok(())
}
#[tokio::test]
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1307,6 +1461,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
})
.await?;
@@ -1348,6 +1503,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
})
.await?;
@@ -1585,6 +1741,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
})
.await?;

View File

@@ -548,6 +548,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
})
.await?;