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:
parent
a046849438
commit
f46b767b7e
20 changed files with 156 additions and 3 deletions
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1920,6 +1920,13 @@
|
|||
"null"
|
||||
]
|
||||
},
|
||||
"searchTerm": {
|
||||
"description": "Optional substring filter for the extracted thread title.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortKey": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12896,6 +12896,13 @@
|
|||
"null"
|
||||
]
|
||||
},
|
||||
"searchTerm": {
|
||||
"description": "Optional substring filter for the extracted thread title.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortKey": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@
|
|||
"null"
|
||||
]
|
||||
},
|
||||
"searchTerm": {
|
||||
"description": "Optional substring filter for the extracted thread title.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sortKey": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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, };
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ impl AppServerClient {
|
|||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
search_term: None,
|
||||
},
|
||||
};
|
||||
self.send(&request)?;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ LEFT JOIN jobs
|
|||
None,
|
||||
None,
|
||||
SortKey::UpdatedAt,
|
||||
None,
|
||||
);
|
||||
builder
|
||||
.push(" AND id != ")
|
||||
|
|
|
|||
|
|
@ -678,6 +678,7 @@ async fn run_ratatui_app(
|
|||
INTERACTIVE_SESSION_SOURCES,
|
||||
Some(provider_filter.as_slice()),
|
||||
&config.model_provider_id,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue