feat: add search term to thread list (#12578)

Add `searchTerm` to `thread/list` that will search for a match in the
titles (the condition being `searchTerm` $$\in$$ `title`)
This commit is contained in:
jif-oai 2026-02-25 09:59:41 +00:00 committed by GitHub
parent a046849438
commit f46b767b7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 156 additions and 3 deletions

1
codex-rs/Cargo.lock generated
View file

@ -1379,6 +1379,7 @@ dependencies = [
"codex-protocol",
"codex-rmcp-client",
"codex-shell-command",
"codex-state",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-cli",

View file

@ -1920,6 +1920,13 @@
"null"
]
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
"string",
"null"
]
},
"sortKey": {
"anyOf": [
{

View file

@ -12896,6 +12896,13 @@
"null"
]
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
"string",
"null"
]
},
"sortKey": {
"anyOf": [
{

View file

@ -65,6 +65,13 @@
"null"
]
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
"string",
"null"
]
},
"sortKey": {
"anyOf": [
{

View file

@ -36,4 +36,8 @@ archived?: boolean | null,
* Optional cwd filter; when set, only threads whose session cwd exactly
* matches this path are returned.
*/
cwd?: string | null, };
cwd?: string | null,
/**
* Optional substring filter for the extracted thread title.
*/
searchTerm?: string | null, };

View file

@ -1963,6 +1963,9 @@ pub struct ThreadListParams {
/// matches this path are returned.
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Optional substring filter for the extracted thread title.
#[ts(optional = nullable)]
pub search_term: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]

View file

@ -916,6 +916,7 @@ fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u32) ->
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
})?;
println!("< thread/list response: {response:?}");

View file

@ -65,6 +65,7 @@ axum = { workspace = true, default-features = false, features = [
base64 = { workspace = true }
codex-execpolicy = { workspace = true }
core_test_support = { workspace = true }
codex-state = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
os_info = { workspace = true }
pretty_assertions = { workspace = true }

View file

@ -122,7 +122,7 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` and auto-subscribes you to turn/item events for the new thread.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, and `cwd` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/status/changed` — notification emitted when a loaded threads status changes (`threadId` + new `status`).
@ -230,6 +230,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default).
- `cwd` — restrict results to threads whose session cwd exactly matches this path.
- `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive).
- Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
Example:

View file

@ -290,6 +290,7 @@ struct ThreadListFilters {
source_kinds: Option<Vec<ThreadSourceKind>>,
archived: bool,
cwd: Option<PathBuf>,
search_term: Option<String>,
}
// Duration before a ChatGPT login attempt is abandoned.
@ -2537,6 +2538,7 @@ impl CodexMessageProcessor {
source_kinds,
archived,
cwd,
search_term,
} = params;
let requested_page_size = limit
@ -2557,6 +2559,7 @@ impl CodexMessageProcessor {
source_kinds,
archived: archived.unwrap_or(false),
cwd: cwd.map(PathBuf::from),
search_term,
},
)
.await
@ -3618,6 +3621,7 @@ impl CodexMessageProcessor {
source_kinds: None,
archived: false,
cwd: None,
search_term: None,
},
)
.await
@ -3644,6 +3648,7 @@ impl CodexMessageProcessor {
source_kinds,
archived,
cwd,
search_term,
} = filters;
let mut cursor_obj: Option<RolloutCursor> = match cursor.as_ref() {
Some(cursor_str) => {
@ -3686,6 +3691,7 @@ impl CodexMessageProcessor {
allowed_sources,
model_provider_filter.as_deref(),
fallback_provider.as_str(),
search_term.as_deref(),
)
.await
.map_err(|err| JSONRPCErrorError {
@ -3702,6 +3708,7 @@ impl CodexMessageProcessor {
allowed_sources,
model_provider_filter.as_deref(),
fallback_provider.as_str(),
search_term.as_deref(),
)
.await
.map_err(|err| JSONRPCErrorError {

View file

@ -78,6 +78,7 @@ async fn list_threads_with_sort(
source_kinds,
archived,
cwd: None,
search_term: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@ -491,6 +492,7 @@ async fn thread_list_respects_cwd_filter() -> Result<()> {
source_kinds: None,
archived: None,
cwd: Some(target_cwd.to_string_lossy().into_owned()),
search_term: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@ -511,6 +513,86 @@ async fn thread_list_respects_cwd_filter() -> Result<()> {
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"),
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"),
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"),
None,
)?;
// `thread/list` only applies `search_term` on the sqlite path. In this test we
// create rollouts manually, so we must also create the sqlite DB and mark backfill
// complete; otherwise app-server will permanently use filesystem fallback.
let state_db = codex_state::StateRuntime::init(
codex_home.path().to_path_buf(),
"mock_provider".into(),
None,
)
.await?;
state_db.mark_backfill_complete(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,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: None,
search_term: Some("needle".to_string()),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadListResponse {
data, next_cursor, ..
} = to_response::<ThreadListResponse>(resp)?;
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_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
let codex_home = TempDir::new()?;
@ -1335,6 +1417,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
})
.await?;
let error: JSONRPCError = timeout(

View file

@ -289,6 +289,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
})
.await?;
let list_resp: JSONRPCResponse = timeout(

View file

@ -160,6 +160,7 @@ fn sanitize_rollout_item_for_persistence(
impl RolloutRecorder {
/// List threads (rollout files) under the provided Codex home directory.
#[allow(clippy::too_many_arguments)]
pub async fn list_threads(
config: &Config,
page_size: usize,
@ -168,6 +169,7 @@ impl RolloutRecorder {
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
Self::list_threads_with_db_fallback(
config,
@ -178,11 +180,13 @@ impl RolloutRecorder {
model_providers,
default_provider,
false,
search_term,
)
.await
}
/// List archived threads (rollout files) under the archived sessions directory.
#[allow(clippy::too_many_arguments)]
pub async fn list_archived_threads(
config: &Config,
page_size: usize,
@ -191,6 +195,7 @@ impl RolloutRecorder {
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
Self::list_threads_with_db_fallback(
config,
@ -201,6 +206,7 @@ impl RolloutRecorder {
model_providers,
default_provider,
true,
search_term,
)
.await
}
@ -215,6 +221,7 @@ impl RolloutRecorder {
model_providers: Option<&[String]>,
default_provider: &str,
archived: bool,
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
let codex_home = config.codex_home.as_path();
// Filesystem-first listing intentionally overfetches so we can repair stale/missing
@ -275,6 +282,7 @@ impl RolloutRecorder {
allowed_sources,
model_providers,
archived,
search_term,
)
.await
{
@ -312,6 +320,7 @@ impl RolloutRecorder {
allowed_sources,
model_providers,
false,
None,
)
.await
else {
@ -1154,6 +1163,7 @@ mod tests {
&[],
None,
default_provider.as_str(),
None,
)
.await?;
assert_eq!(page1.items.len(), 1);
@ -1168,6 +1178,7 @@ mod tests {
&[],
None,
default_provider.as_str(),
None,
)
.await?;
assert_eq!(page2.items.len(), 1);
@ -1229,6 +1240,7 @@ mod tests {
&[],
None,
default_provider.as_str(),
None,
)
.await?;
assert_eq!(page.items.len(), 0);
@ -1295,6 +1307,7 @@ mod tests {
&[],
None,
default_provider.as_str(),
None,
)
.await?;
assert_eq!(page.items.len(), 1);

View file

@ -226,6 +226,7 @@ pub async fn list_threads_db(
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
archived: bool,
search_term: Option<&str>,
) -> Option<codex_state::ThreadsPage> {
let ctx = context?;
if ctx.codex_home() != codex_home {
@ -257,6 +258,7 @@ pub async fn list_threads_db(
allowed_sources.as_slice(),
model_providers.as_deref(),
archived,
search_term,
)
.await
{

View file

@ -181,6 +181,7 @@ impl AppServerClient {
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
},
};
self.send(&request)?;

View file

@ -81,7 +81,8 @@ Interrupt a running turn: `interruptConversation`.
List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`.
For v2 threads, use `thread/list` with filters such as `archived: true` or `cwd: "/path"` to
For v2 threads, use `thread/list` with filters such as `archived: true`, `cwd: "/path"`, or
`searchTerm: "needle"` to
narrow results, and `thread/unarchive` to restore archived rollouts to the active sessions
directory (it returns the restored thread summary).

View file

@ -302,6 +302,7 @@ ORDER BY position ASC
}
/// List threads using the underlying database.
#[allow(clippy::too_many_arguments)]
pub async fn list_threads(
&self,
page_size: usize,
@ -310,6 +311,7 @@ ORDER BY position ASC
allowed_sources: &[String],
model_providers: Option<&[String]>,
archived_only: bool,
search_term: Option<&str>,
) -> anyhow::Result<crate::ThreadsPage> {
let limit = page_size.saturating_add(1);
@ -345,6 +347,7 @@ FROM threads
model_providers,
anchor,
sort_key,
search_term,
);
push_thread_order_and_limit(&mut builder, sort_key, limit);
@ -682,6 +685,7 @@ WHERE id IN (
model_providers,
anchor,
sort_key,
None,
);
push_thread_order_and_limit(&mut builder, sort_key, limit);
@ -1654,6 +1658,7 @@ fn push_thread_filters<'a>(
model_providers: Option<&'a [String]>,
anchor: Option<&crate::Anchor>,
sort_key: SortKey,
search_term: Option<&'a str>,
) {
builder.push(" WHERE 1 = 1");
if archived_only {
@ -1680,6 +1685,11 @@ fn push_thread_filters<'a>(
}
separated.push_unseparated(")");
}
if let Some(search_term) = search_term {
builder.push(" AND instr(title, ");
builder.push_bind(search_term);
builder.push(") > 0");
}
if let Some(anchor) = anchor {
let anchor_ts = datetime_to_epoch_seconds(anchor.ts);
let column = match sort_key {

View file

@ -129,6 +129,7 @@ LEFT JOIN jobs
None,
None,
SortKey::UpdatedAt,
None,
);
builder
.push(" AND id != ")

View file

@ -678,6 +678,7 @@ async fn run_ratatui_app(
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
&config.model_provider_id,
None,
)
.await
{

View file

@ -159,6 +159,7 @@ async fn run_session_picker(
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
request.default_provider.as_str(),
None,
)
.await;
let _ = tx.send(BackgroundEvent::PageLoaded {