Add exact search mode for session lookup

This commit is contained in:
canvrno-oai
2026-05-04 14:18:48 -07:00
parent 87d2235b54
commit e1f4aa728c
21 changed files with 216 additions and 29 deletions
@@ -3628,6 +3628,17 @@
"null"
]
},
"searchMode": {
"anyOf": [
{
"$ref": "#/definitions/ThreadListSearchMode"
},
{
"type": "null"
}
],
"description": "Optional search matching mode. Defaults to substring matching."
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
@@ -3674,6 +3685,13 @@
},
"type": "object"
},
"ThreadListSearchMode": {
"enum": [
"contains",
"exact"
],
"type": "string"
},
"ThreadLoadedListParams": {
"properties": {
"cursor": {
@@ -16123,6 +16123,17 @@
"null"
]
},
"searchMode": {
"anyOf": [
{
"$ref": "#/definitions/v2/ThreadListSearchMode"
},
{
"type": "null"
}
],
"description": "Optional search matching mode. Defaults to substring matching."
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
@@ -16200,6 +16211,13 @@
"title": "ThreadListResponse",
"type": "object"
},
"ThreadListSearchMode": {
"enum": [
"contains",
"exact"
],
"type": "string"
},
"ThreadLoadedListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -14009,6 +14009,17 @@
"null"
]
},
"searchMode": {
"anyOf": [
{
"$ref": "#/definitions/ThreadListSearchMode"
},
{
"type": "null"
}
],
"description": "Optional search matching mode. Defaults to substring matching."
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
@@ -14086,6 +14097,13 @@
"title": "ThreadListResponse",
"type": "object"
},
"ThreadListSearchMode": {
"enum": [
"contains",
"exact"
],
"type": "string"
},
"ThreadLoadedListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -21,6 +21,13 @@
}
]
},
"ThreadListSearchMode": {
"enum": [
"contains",
"exact"
],
"type": "string"
},
"ThreadSortKey": {
"enum": [
"created_at",
@@ -89,6 +96,17 @@
"null"
]
},
"searchMode": {
"anyOf": [
{
"$ref": "#/definitions/ThreadListSearchMode"
},
{
"type": "null"
}
],
"description": "Optional search matching mode. Defaults to substring matching."
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SortDirection } from "./SortDirection";
import type { ThreadListSearchMode } from "./ThreadListSearchMode";
import type { ThreadSortKey } from "./ThreadSortKey";
import type { ThreadSourceKind } from "./ThreadSourceKind";
@@ -51,4 +52,8 @@ useStateDbOnly?: boolean,
/**
* Optional substring filter for the extracted thread title.
*/
searchTerm?: string | null, };
searchTerm?: string | null,
/**
* Optional search matching mode. Defaults to substring matching.
*/
searchMode?: ThreadListSearchMode | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadListSearchMode = "contains" | "exact";
@@ -363,6 +363,7 @@ export type { ThreadInjectItemsResponse } from "./ThreadInjectItemsResponse";
export type { ThreadItem } from "./ThreadItem";
export type { ThreadListParams } from "./ThreadListParams";
export type { ThreadListResponse } from "./ThreadListResponse";
export type { ThreadListSearchMode } from "./ThreadListSearchMode";
export type { ThreadLoadedListParams } from "./ThreadLoadedListParams";
export type { ThreadLoadedListResponse } from "./ThreadLoadedListResponse";
export type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams";
@@ -4325,6 +4325,9 @@ pub struct ThreadListParams {
/// Optional substring filter for the extracted thread title.
#[ts(optional = nullable)]
pub search_term: Option<String>,
/// Optional search matching mode. Defaults to substring matching.
#[ts(optional = nullable)]
pub search_mode: Option<ThreadListSearchMode>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
@@ -4334,6 +4337,14 @@ pub enum ThreadListCwdFilter {
Many(Vec<String>),
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum ThreadListSearchMode {
Contains,
Exact,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
@@ -8234,10 +8245,12 @@ mod tests {
fn thread_list_params_accepts_state_db_only_flag() {
let params = serde_json::from_value::<ThreadListParams>(json!({
"useStateDbOnly": true,
"searchMode": "exact",
}))
.expect("state db only flag should deserialize");
assert!(params.use_state_db_only);
assert_eq!(params.search_mode, Some(ThreadListSearchMode::Exact));
}
#[test]
@@ -1127,6 +1127,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})?;
println!("< thread/list response: {response:?}");
+1
View File
@@ -321,6 +321,7 @@ Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thr
- `cwd` — restrict results to threads whose session cwd exactly matches this path, or one of these paths when an array is provided. Relative paths are resolved against the app-server process cwd before matching.
- `useStateDbOnly` — when `true`, return from the state DB without scanning JSONL rollouts to repair metadata. Omit or pass `false` to preserve the default scan-and-repair behavior.
- `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive).
- `searchMode` — optional search matching mode for `searchTerm`: `contains` (default) or `exact`.
- Responses include `nextCursor` to continue in the same direction and `backwardsCursor` to pass as `cursor` when reversing `sortDirection`.
- Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
@@ -178,6 +178,7 @@ use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadListSearchMode;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
@@ -9,6 +9,7 @@ struct ThreadListFilters {
archived: bool,
cwd_filters: Option<Vec<PathBuf>>,
search_term: Option<String>,
search_mode: Option<ThreadListSearchMode>,
use_state_db_only: bool,
}
@@ -1826,6 +1827,7 @@ impl ThreadRequestProcessor {
cwd,
use_state_db_only,
search_term,
search_mode,
} = params;
let cwd_filters = normalize_thread_list_cwd_filters(cwd)?;
@@ -1850,6 +1852,7 @@ impl ThreadRequestProcessor {
archived: archived.unwrap_or(false),
cwd_filters,
search_term,
search_mode,
use_state_db_only,
},
)
@@ -3228,6 +3231,7 @@ impl ThreadRequestProcessor {
archived,
cwd_filters,
search_term,
search_mode,
use_state_db_only,
} = filters;
let mut cursor_obj = cursor;
@@ -3287,6 +3291,12 @@ impl ThreadRequestProcessor {
path_utils::paths_match_after_normalization(&it.cwd, expected_cwd)
})
})
&& match (search_mode, search_term.as_deref()) {
(Some(ThreadListSearchMode::Exact), Some(search_term)) => {
it.name.as_deref() == Some(search_term)
}
_ => true,
}
{
filtered.push(it);
if filtered.len() >= remaining {
@@ -331,6 +331,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -500,6 +501,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -586,6 +588,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions()
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -724,6 +727,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -808,6 +812,7 @@ async fn external_agent_config_import_rejects_undetected_session_paths() -> Resu
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -931,6 +936,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -153,6 +153,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
},
})
.await?
@@ -690,6 +690,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let list_resp: JSONRPCResponse = timeout(
@@ -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::ThreadListSearchMode;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
@@ -93,6 +94,7 @@ async fn list_threads_with_sort(
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -533,6 +535,7 @@ async fn thread_list_respects_cwd_filters() -> Result<()> {
])),
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -628,6 +631,13 @@ sqlite = true
)
.await?;
assert_eq!(repaired_page.items.len(), 3);
let exact_thread_id = ThreadId::from_string(&newer_match)?;
let mut exact_metadata = state_db
.get_thread(exact_thread_id)
.await?
.expect("exact match metadata should be present");
exact_metadata.title = "needle exact".to_string();
state_db.upsert_thread(&exact_metadata).await?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
@@ -642,6 +652,7 @@ sqlite = true
cwd: None,
use_state_db_only: false,
search_term: Some("needle".to_string()),
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -657,6 +668,36 @@ sqlite = true
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![newer_match, older_match]);
let exact_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: Some("needle exact".to_string()),
search_mode: Some(ThreadListSearchMode::Exact),
})
.await?;
let exact_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(exact_request_id)),
)
.await??;
let ThreadListResponse {
data: exact_data,
next_cursor: exact_next_cursor,
..
} = to_response::<ThreadListResponse>(exact_resp)?;
assert_eq!(exact_next_cursor, None);
let exact_ids: Vec<_> = exact_data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(exact_ids, vec![newer_match]);
Ok(())
}
@@ -703,6 +744,7 @@ sqlite = true
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -741,6 +783,7 @@ sqlite = true
)),
use_state_db_only: true,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -770,6 +813,7 @@ sqlite = true
)),
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -1464,6 +1508,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -1506,6 +1551,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -1744,6 +1790,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let error: JSONRPCError = timeout(
@@ -483,6 +483,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
},
})
.await?
@@ -804,6 +805,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let list_resp: JSONRPCResponse = timeout(
+1
View File
@@ -185,6 +185,7 @@ impl AppServerClient {
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
},
};
self.send(&request)?;
+2
View File
@@ -1349,6 +1349,7 @@ async fn resolve_resume_thread_id(
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
},
},
"thread/list",
@@ -1414,6 +1415,7 @@ async fn resolve_resume_thread_id(
cwd: None,
use_state_db_only: false,
search_term: Some(session_id.to_string()),
search_mode: None,
},
},
"thread/list",
+45 -28
View File
@@ -32,6 +32,7 @@ use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::Thread as AppServerThread;
use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListSearchMode;
use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_cloud_requirements::cloud_requirements_loader_for_storage;
@@ -546,34 +547,26 @@ async fn lookup_session_target_by_name_with_app_server(
app_server: &mut AppServerSession,
name: &str,
) -> color_eyre::Result<Option<resume_picker::SessionTarget>> {
let mut cursor = None;
loop {
let response = app_server
.thread_list(ThreadListParams {
cursor: cursor.clone(),
limit: Some(100),
sort_key: Some(AppServerThreadSortKey::UpdatedAt),
sort_direction: None,
model_providers: None,
source_kinds: Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]),
archived: Some(false),
cwd: None,
use_state_db_only: false,
search_term: Some(name.to_string()),
})
.await?;
if let Some(thread) = response
.data
.into_iter()
.find(|thread| thread.name.as_deref() == Some(name))
{
return Ok(session_target_from_app_server_thread(thread));
}
if response.next_cursor.is_none() {
return Ok(None);
}
cursor = response.next_cursor;
}
let response = app_server
.thread_list(ThreadListParams {
cursor: None,
limit: Some(100),
sort_key: Some(AppServerThreadSortKey::UpdatedAt),
sort_direction: None,
model_providers: None,
source_kinds: Some(vec![ThreadSourceKind::Cli, ThreadSourceKind::VsCode]),
archived: Some(false),
cwd: None,
use_state_db_only: false,
search_term: Some(name.to_string()),
search_mode: Some(ThreadListSearchMode::Exact),
})
.await?;
Ok(response
.data
.into_iter()
.next()
.and_then(session_target_from_app_server_thread))
}
async fn lookup_session_target_with_app_server(
@@ -653,6 +646,7 @@ fn latest_session_lookup_params(
cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())),
use_state_db_only: false,
search_term: None,
search_mode: None,
}
}
@@ -2047,6 +2041,29 @@ mod tests {
.upsert_thread(&metadata)
.await
.map_err(std::io::Error::other)?;
let newer_thread_id = ThreadId::new();
let newer_rollout_path = temp_dir.path().join("sessions/2025/02/01").join(format!(
"rollout-2025-02-01T11-00-00-{newer_thread_id}.jsonl"
));
std::fs::write(&newer_rollout_path, "")?;
let newer_created_at = chrono::DateTime::parse_from_rfc3339("2025-02-01T11:00:00Z")
.expect("timestamp should parse")
.with_timezone(&chrono::Utc);
let mut newer_builder = codex_state::ThreadMetadataBuilder::new(
newer_thread_id,
newer_rollout_path,
newer_created_at,
serde_json::from_value(serde_json::json!("cli"))
.expect("cli session source should deserialize"),
);
newer_builder.cwd = temp_dir.path().join("other-project");
let mut newer_metadata = newer_builder.build(config.model_provider_id.as_str());
newer_metadata.title = "saved-session-copy".to_string();
newer_metadata.first_user_message = Some("other preview text".to_string());
state_runtime
.upsert_thread(&newer_metadata)
.await
.map_err(std::io::Error::other)?;
let mut app_server =
AppServerSession::new(codex_app_server_client::AppServerClient::InProcess(
+1
View File
@@ -1017,6 +1017,7 @@ fn thread_list_params(
cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned())),
use_state_db_only: false,
search_term: None,
search_mode: None,
}
}