Allow searching persisted attached threads (#60574)

## Why

Title and content searches exclude attached threads, preventing clients from finding their saved conversations alongside ordinary threads.

## What changed

- Add `includeAttachedThreads` to `thread/list` and `thread/search`. For `thread/list`, require a non-empty `searchTerm` and preserve the usual provider and source filters.
- Expose `attachedToThreadId` on thread responses to identify the owning parent independently of fork and subagent lineage.
- Support inclusive searches through SQLite and filesystem fallback, repairing missing database entries with saved thread names during title searches.

## Testing

Add regression coverage for opt-in title and content searches, attached-parent metadata after restart, default search filters, filesystem searches without SQLite, and discovery and repair of unindexed title matches.

GitOrigin-RevId: ac12ec3c730ad7f1722bdf8e21600d17f12017f0
This commit is contained in:
Brent Traut
2026-11-10 11:17:06 +00:00
committed by copyberry
parent c82708f858
commit 77fde36ae1
56 changed files with 587 additions and 277 deletions
@@ -2754,6 +2754,7 @@ impl ThreadRequestProcessor {
params: ThreadListParams,
) -> Result<ThreadListResponse, JSONRPCErrorError> {
let ThreadListParams {
include_attached_threads,
current_account_only,
cursor,
limit,
@@ -2815,6 +2816,15 @@ impl ThreadRequestProcessor {
}
}
let cwd_filters = normalize_thread_list_cwd_filters(cwd)?;
if include_attached_threads
&& search_term
.as_ref()
.is_none_or(|term| term.trim().is_empty())
{
return Err(invalid_request(
"includeAttachedThreads requires a non-empty searchTerm",
));
}
let relation_filter = match (parent_thread_id, ancestor_thread_id, attached_to_thread_id) {
(Some(_), Some(_), None) => {
return Err(invalid_request(
@@ -2850,6 +2860,9 @@ impl ThreadRequestProcessor {
"attachedToThreadId cannot be combined with parentThreadId or ancestorThreadId",
));
}
(None, None, None) if include_attached_threads => {
Some(StoreThreadRelationFilter::IncludeAttached)
}
(None, None, None) => None,
};
@@ -2926,6 +2939,7 @@ impl ThreadRequestProcessor {
params: ThreadSearchParams,
) -> Result<ThreadSearchResponse, JSONRPCErrorError> {
let ThreadSearchParams {
include_attached_threads,
current_account_only,
cursor,
limit,
@@ -2961,6 +2975,7 @@ impl ThreadRequestProcessor {
let page = self
.thread_store
.search_threads(StoreSearchThreadsParams {
include_attached_threads,
account: account.clone(),
page_size: remaining.min(THREAD_LIST_MAX_LIMIT),
cursor: cursor_obj.clone(),
@@ -6078,6 +6093,8 @@ impl ThreadRequestProcessor {
let mut items = Vec::with_capacity(requested_page_size);
let mut next_cursor: Option<String> = None;
let parent_scoped =
relation_filter.is_some_and(StoreThreadRelationFilter::is_parent_scoped);
let model_provider_filter = match model_providers {
Some(providers) => {
if providers.is_empty() {
@@ -6086,15 +6103,14 @@ impl ThreadRequestProcessor {
Some(providers)
}
}
None if relation_filter.is_some() => None,
None if parent_scoped => None,
None => Some(vec![self.config.model_provider_id.clone()]),
};
let (allowed_sources_vec, source_kind_filter) =
if relation_filter.is_some() && source_kinds.is_none() {
(Vec::new(), None)
} else {
compute_source_filters(source_kinds)
};
let (allowed_sources_vec, source_kind_filter) = if parent_scoped && source_kinds.is_none() {
(Vec::new(), None)
} else {
compute_source_filters(source_kinds)
};
let allowed_sources = allowed_sources_vec.as_slice();
let store_sort_direction = match sort_direction {
SortDirection::Asc => StoreSortDirection::Asc,
@@ -6752,6 +6768,7 @@ fn thread_from_stored_thread(
extra: None,
session_id: thread_id,
forked_from_id: thread.forked_from_id.map(|id| id.to_string()),
attached_to_thread_id: thread.attached_to_thread_id.map(|id| id.to_string()),
parent_thread_id: thread.parent_thread_id.map(|id| id.to_string()),
preview: thread.preview,
ephemeral: false,
@@ -6952,6 +6969,7 @@ fn build_thread_from_snapshot(
extra: None,
session_id,
forked_from_id: None,
attached_to_thread_id: None,
parent_thread_id: config_snapshot.parent_thread_id.map(|id| id.to_string()),
preview: String::new(),
ephemeral: config_snapshot.ephemeral,