diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6b57b020f..36f4fec3b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 80a607aca..1b7f99d39 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1920,6 +1920,13 @@ "null" ] }, + "searchTerm": { + "description": "Optional substring filter for the extracted thread title.", + "type": [ + "string", + "null" + ] + }, "sortKey": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 488117b94..73df29a3d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12896,6 +12896,13 @@ "null" ] }, + "searchTerm": { + "description": "Optional substring filter for the extracted thread title.", + "type": [ + "string", + "null" + ] + }, "sortKey": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json index fa88ea3f7..c5cf1364c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListParams.json @@ -65,6 +65,13 @@ "null" ] }, + "searchTerm": { + "description": "Optional substring filter for the extracted thread title.", + "type": [ + "string", + "null" + ] + }, "sortKey": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts index 7fdadbad6..ea14ec3d4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadListParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 52207edaf..ab0bd0559 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1963,6 +1963,9 @@ pub struct ThreadListParams { /// matches this path are returned. #[ts(optional = nullable)] pub cwd: Option, + /// Optional substring filter for the extracted thread title. + #[ts(optional = nullable)] + pub search_term: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index ae147d5b4..a9225b042 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -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:?}"); diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 2f273a679..eebaaba10 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -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 } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9d8abf262..762c3d91d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 thread’s 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: diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 834fbf3ff..75d7aaf4d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -290,6 +290,7 @@ struct ThreadListFilters { source_kinds: Option>, archived: bool, cwd: Option, + search_term: Option, } // 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 = 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 { diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 1f6e0627e..96aa2ec76 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -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::(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( diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 384aad6c8..5d2211d3c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -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( diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 7e639e382..d612bd14e 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -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 { 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 { 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 { 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); diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/core/src/state_db.rs index 7f5b62a12..f14b932fa 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/core/src/state_db.rs @@ -226,6 +226,7 @@ pub async fn list_threads_db( allowed_sources: &[SessionSource], model_providers: Option<&[String]>, archived: bool, + search_term: Option<&str>, ) -> Option { 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 { diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 7cf80e3d8..762c11cb6 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -181,6 +181,7 @@ impl AppServerClient { source_kinds: None, archived: None, cwd: None, + search_term: None, }, }; self.send(&request)?; diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 503891a49..3a3abe9a9 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -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). diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 29d85cdd5..513345fbb 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -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 { 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 { diff --git a/codex-rs/state/src/runtime/memories.rs b/codex-rs/state/src/runtime/memories.rs index bad5679ea..1899aec8b 100644 --- a/codex-rs/state/src/runtime/memories.rs +++ b/codex-rs/state/src/runtime/memories.rs @@ -129,6 +129,7 @@ LEFT JOIN jobs None, None, SortKey::UpdatedAt, + None, ); builder .push(" AND id != ") diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e5971ba38..d51f42b2f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -678,6 +678,7 @@ async fn run_ratatui_app( INTERACTIVE_SESSION_SOURCES, Some(provider_filter.as_slice()), &config.model_provider_id, + None, ) .await { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 6cb1387a4..a827ccab5 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -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 {