mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
3ccef20ef4
commit
8057603d0c
30 files changed
+494
-106
No files matched your search
@@ -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)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user