feat(app-server): list descendant threads by ancestor (#29591)

## Why

`thread/list` can filter direct children with `parentThreadId`, but
clients cannot request an entire spawned subtree. Discovering every
descendant requires repeated client-side requests and gives up the
database's existing filtering and pagination path.

## What changed

Experimental clients can use `ancestorThreadId` to return strict
descendants at any depth while `parentThreadId` retains its direct-child
meaning. The filters are mutually exclusive, the ancestor is excluded,
and every result preserves its immediate `parentThreadId` so callers can
reconstruct the tree.

## How it works

- **Explicit relationship:** Internal list parameters distinguish direct
children from transitive descendants without changing the meaning of
`parentThreadId`.
- **Existing graph:** Persisted parent-child spawn edges remain the
source of truth, so descendant lookup needs no schema migration or
ancestry cache.
- **Indexed traversal:** A recursive SQLite query starts from the
parent-edge index, walks each generation, and applies thread filters,
sorting, and cursor pagination in the same database request.
- **Reconstructable results:** The response stays flat and normally
ordered while carrying each descendant's immediate parent.

## Verification

Ran 550 tests across the protocol, state, rollout, and thread-store
crates, then reran the four focused state, store, and app-server
descendant-listing tests after the final diff reduction. Scoped Clippy
and formatting checks passed. Stable and experimental schema generation
was checked; the stable fixtures remain unchanged while the experimental
schema includes the new field.
This commit is contained in:
Brent Traut authored and GitHub committed 2026-06-24 13:08:14 -07:00
1 parent 3ccef20ef4
commit 8057603d0c
30 files changed
+494 -106

No files matched your search

+61 -5
View File
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
@@ -29,6 +30,7 @@ use crate::StoredThread;
use crate::StoredThreadHistory;
use crate::ThreadMetadataPatch;
use crate::ThreadPage;
use crate::ThreadRelationFilter;
use crate::ThreadStore;
use crate::ThreadStoreError;
use crate::ThreadStoreFuture;
@@ -97,17 +99,20 @@ mod tests {
}
#[tokio::test]
async fn list_threads_filters_by_parent_thread_id() {
async fn list_threads_filters_by_spawn_relationship() {
let store = InMemoryThreadStore::default();
let parent_thread_id = ThreadId::default();
let child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread id");
let unrelated_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread id");
let grandchild_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread id");
for (thread_id, parent_thread_id) in [
(child_thread_id, Some(parent_thread_id)),
(unrelated_thread_id, None),
(grandchild_thread_id, Some(child_thread_id)),
] {
store
.create_thread(CreateThreadParams {
@@ -145,7 +150,7 @@ mod tests {
cwd_filters: None,
archived: false,
search_term: None,
parent_thread_id: Some(parent_thread_id),
relation_filter: Some(ThreadRelationFilter::DirectChildrenOf(parent_thread_id)),
use_state_db_only: false,
},
)
@@ -159,6 +164,33 @@ mod tests {
.collect::<Vec<_>>(),
vec![child_thread_id]
);
let page = ThreadStore::list_threads(
&store,
ListThreadsParams {
page_size: 10,
cursor: None,
sort_key: ThreadSortKey::CreatedAt,
sort_direction: SortDirection::Desc,
allowed_sources: Vec::new(),
model_providers: None,
cwd_filters: None,
archived: false,
search_term: None,
relation_filter: Some(ThreadRelationFilter::DescendantsOf(parent_thread_id)),
use_state_db_only: false,
},
)
.await
.expect("list descendant threads");
assert_eq!(
page.items
.into_iter()
.map(|item| item.thread_id)
.collect::<HashSet<_>>(),
HashSet::from([child_thread_id, grandchild_thread_id])
);
}
}
@@ -463,9 +495,33 @@ impl ThreadStore for InMemoryThreadStore {
fn list_threads(&self, params: ListThreadsParams) -> ThreadStoreFuture<'_, ThreadPage> {
Box::pin(async move {
let mut page = InMemoryThreadStore::list_threads(self).await?;
if let Some(parent_thread_id) = params.parent_thread_id {
page.items
.retain(|thread| thread.parent_thread_id == Some(parent_thread_id));
match params.relation_filter {
Some(ThreadRelationFilter::DirectChildrenOf(parent_thread_id)) => {
page.items
.retain(|thread| thread.parent_thread_id == Some(parent_thread_id));
}
Some(ThreadRelationFilter::DescendantsOf(ancestor_thread_id)) => {
let mut subtree = HashSet::from([ancestor_thread_id]);
loop {
let mut discovered = false;
for thread in &page.items {
if thread
.parent_thread_id
.is_some_and(|parent_thread_id| subtree.contains(&parent_thread_id))
{
discovered |= subtree.insert(thread.thread_id);
}
}
if !discovered {
break;
}
}
page.items.retain(|thread| {
thread.thread_id != ancestor_thread_id
&& subtree.contains(&thread.thread_id)
});
}
None => {}
}
Ok(page)
})