[codex] Add rollout-backed thread content search (#23519)

## Summary
- add experimental `thread/search` for local rollout-backed thread
search using `rg` over JSONL rollouts
- return search-specific result rows with optional previews instead of
storing preview data on `StoredThread` or ordinary `Thread` responses
- keep `thread/list` separate from full-content search and document the
new app-server surface

## Testing
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-app-server
thread_search_returns_content_and_title_matches -- --nocapture`
This commit is contained in:
Francis Chalissery
2026-05-21 11:52:24 -07:00
committed by GitHub
parent 4acb456bfe
commit ac0bff27e7
22 changed files with 935 additions and 2 deletions
@@ -88,6 +88,7 @@ use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStopParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSearchParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadShellCommandParams;
@@ -508,6 +509,15 @@ impl McpProcess {
self.send_request("thread/list", params).await
}
/// Send a `thread/search` JSON-RPC request.
pub async fn send_thread_search_request(
&mut self,
params: ThreadSearchParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/search", params).await
}
/// Send a `thread/loaded/list` JSON-RPC request.
pub async fn send_thread_loaded_list_request(
&mut self,
@@ -17,6 +17,7 @@ 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::ThreadSearchResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
@@ -660,6 +661,161 @@ sqlite = true
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 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,
)?;
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 resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchResponse {
data, next_cursor, ..
} = to_response::<ThreadSearchResponse>(resp)?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data
.iter()
.map(|result| result.thread.id.as_str())
.collect();
assert_eq!(ids, vec![newer_match, older_match]);
assert_eq!(data[0].snippet, "needle suffix");
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 resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchResponse { data, .. } = to_response::<ThreadSearchResponse>(resp)?;
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 resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchResponse { data, .. } = to_response::<ThreadSearchResponse>(resp)?;
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()?;