Compare commits

...

5 Commits

Author SHA1 Message Date
canvrno-oai
555cc029e9 Document thread list search mode overview 2026-05-04 16:06:58 -07:00
canvrno-oai
5ba10e9eed Clarify exact-name thread search mode 2026-05-04 15:28:29 -07:00
canvrno-oai
c5c404c7f4 Limit exact session lookup to one result 2026-05-04 14:46:43 -07:00
canvrno-oai
ec11708d43 Fix exact name thread list test 2026-05-04 14:42:01 -07:00
canvrno-oai
e1f4aa728c Add exact search mode for session lookup 2026-05-04 14:18:48 -07:00
21 changed files with 231 additions and 31 deletions

View File

@@ -3628,6 +3628,17 @@
"null"
]
},
"searchMode": {
"anyOf": [
{
"$ref": "#/definitions/ThreadListSearchMode"
},
{
"type": "null"
}
],
"description": "Optional search matching mode. Defaults to substring title matching."
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
@@ -3674,6 +3685,13 @@
},
"type": "object"
},
"ThreadListSearchMode": {
"enum": [
"contains",
"exactName"
],
"type": "string"
},
"ThreadLoadedListParams": {
"properties": {
"cursor": {

View File

@@ -16123,6 +16123,17 @@
"null"
]
},
"searchMode": {
"anyOf": [
{
"$ref": "#/definitions/v2/ThreadListSearchMode"
},
{
"type": "null"
}
],
"description": "Optional search matching mode. Defaults to substring title matching."
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
@@ -16200,6 +16211,13 @@
"title": "ThreadListResponse",
"type": "object"
},
"ThreadListSearchMode": {
"enum": [
"contains",
"exactName"
],
"type": "string"
},
"ThreadLoadedListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {

View File

@@ -14009,6 +14009,17 @@
"null"
]
},
"searchMode": {
"anyOf": [
{
"$ref": "#/definitions/ThreadListSearchMode"
},
{
"type": "null"
}
],
"description": "Optional search matching mode. Defaults to substring title matching."
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
@@ -14086,6 +14097,13 @@
"title": "ThreadListResponse",
"type": "object"
},
"ThreadListSearchMode": {
"enum": [
"contains",
"exactName"
],
"type": "string"
},
"ThreadLoadedListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {

View File

@@ -21,6 +21,13 @@
}
]
},
"ThreadListSearchMode": {
"enum": [
"contains",
"exactName"
],
"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 title matching."
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [

View File

@@ -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 title matching.
*/
searchMode?: ThreadListSearchMode | null, };

View File

@@ -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" | "exactName";

View File

@@ -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";

View File

@@ -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 title 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,
ExactName,
}
#[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": "exactName",
}))
.expect("state db only flag should deserialize");
assert!(params.use_state_db_only);
assert_eq!(params.search_mode, Some(ThreadListSearchMode::ExactName));
}
#[test]

View File

@@ -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:?}");

View File

@@ -146,7 +146,7 @@ Example with notification opt-out:
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
- `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/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, `searchTerm`, and `searchMode` 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/turns/list` — experimental; page through a stored threads turn history without resuming it; supports cursor-based pagination with `sortDirection`, `nextCursor`, and `backwardsCursor`.
@@ -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 `exactName` to narrow to exact user-facing thread names.
- 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.

View File

@@ -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;

View File

@@ -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::ExactName), Some(search_term)) => {
it.name.as_deref() == Some(search_term)
}
_ => true,
}
{
filtered.push(it);
if filtered.len() >= remaining {

View File

@@ -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(

View File

@@ -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?

View File

@@ -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(

View File

@@ -17,6 +17,9 @@ 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::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
@@ -93,6 +96,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 +537,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(
@@ -630,6 +635,24 @@ sqlite = true
assert_eq!(repaired_page.items.len(), 3);
let mut mcp = init_mcp(codex_home.path()).await?;
let set_name_id = mcp
.send_thread_set_name_request(ThreadSetNameParams {
thread_id: newer_match.clone(),
name: "needle exact".to_string(),
})
.await?;
let set_name_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(set_name_id)),
)
.await??;
let _: ThreadSetNameResponse = to_response::<ThreadSetNameResponse>(set_name_resp)?;
let _ = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/name/updated"),
)
.await??;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
cursor: None,
@@ -642,6 +665,7 @@ sqlite = true
cwd: None,
use_state_db_only: false,
search_term: Some("needle".to_string()),
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -655,7 +679,37 @@ sqlite = true
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]);
assert_eq!(ids, vec![newer_match.as_str(), older_match.as_str()]);
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::ExactName),
})
.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.as_str()]);
Ok(())
}
@@ -703,6 +757,7 @@ sqlite = true
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -741,6 +796,7 @@ sqlite = true
)),
use_state_db_only: true,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -770,6 +826,7 @@ sqlite = true
)),
use_state_db_only: false,
search_term: None,
search_mode: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -1464,6 +1521,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 +1564,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 +1803,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(

View File

@@ -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(

View File

@@ -185,6 +185,7 @@ impl AppServerClient {
cwd: None,
use_state_db_only: false,
search_term: None,
search_mode: None,
},
};
self.send(&request)?;

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",

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(1),
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::ExactName),
})
.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(

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,
}
}