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
@@ -3347,6 +3347,7 @@ mod tests {
extra: None,
session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(),
forked_from_id: None,
attached_to_thread_id: None,
parent_thread_id: None,
preview: "first prompt".to_string(),
ephemeral: true,
@@ -3406,6 +3407,7 @@ mod tests {
"extra": null,
"sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7",
"forkedFromId": null,
"attachedToThreadId": null,
"parentThreadId": null,
"preview": "first prompt",
"ephemeral": true,
@@ -304,6 +304,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
extra: None,
session_id: "thr_123".to_string(),
forked_from_id: None,
attached_to_thread_id: None,
parent_thread_id: None,
preview: String::new(),
ephemeral: false,
@@ -1435,6 +1435,10 @@ pub struct ThreadSectionDeleteResponse {}
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadListParams {
/// Include persistent threads attached to a parent when searching by title.
#[experimental("thread/list.includeAttachedThreads")]
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub include_attached_threads: bool,
/// Show local history visible to the current ChatGPT account. Defaults to false.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub current_account_only: bool,
@@ -1526,6 +1530,9 @@ pub struct ThreadListParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSearchParams {
/// Include persistent threads attached to a parent in search results.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub include_attached_threads: bool,
/// Show local history visible to the current ChatGPT account. Defaults to false.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub current_account_only: bool,
@@ -218,6 +218,9 @@ pub struct Thread {
pub session_id: String,
/// Source thread id when this thread was created by forking another thread.
pub forked_from_id: Option<String>,
/// Parent that owns this persisted attached thread, independent of fork or spawn lineage.
#[experimental("thread.attachedToThreadId")]
pub attached_to_thread_id: Option<String>,
/// The ID of the parent thread. This will only be set if this thread is a subagent.
pub parent_thread_id: Option<String>,
/// Usually the first user message in the thread, if available.
@@ -305,6 +308,7 @@ struct ThreadCompatibility {
extra: Option<ThreadExtra>,
session_id: String,
forked_from_id: Option<String>,
attached_to_thread_id: Option<String>,
parent_thread_id: Option<String>,
preview: String,
ephemeral: bool,
@@ -350,6 +354,7 @@ impl<'de> Deserialize<'de> for Thread {
extra: thread.extra,
session_id: thread.session_id,
forked_from_id: thread.forked_from_id,
attached_to_thread_id: thread.attached_to_thread_id,
parent_thread_id: thread.parent_thread_id,
preview: thread.preview,
ephemeral: thread.ephemeral,