feat(app-server): filter threads by parent (#26662)

## Why

Clients that display or coordinate spawned subagents need an
authoritative snapshot of a thread's immediate spawned children when
they connect to app-server or recover after missing live events.
`thread/list` cannot query by parent, so clients must otherwise scan
unrelated threads or reconstruct relationships from rollout history and
transient events.

The direct spawn relationship already exists in persisted
`thread_spawn_edges` state. Review and Guardian threads do not
participate in that lifecycle and are intentionally outside this
filter's scope.

## What changed

This adds an experimental `parentThreadId` filter to `thread/list`.
Parent-filtered requests return direct spawned children from persisted
state while preserving the existing response shape, explicit filters,
sorting, and timestamp-only cursor behavior. The lookup does not read
rollout transcripts or recursively return descendants.

Supersedes #25112 with the narrower `thread/list` filter approach.

## How it works

1. An experimental client passes a valid thread ID as `parentThreadId`.
2. App-server routes the list through the existing thread-store and
state-database boundaries.
3. SQLite selects threads whose IDs have a direct persisted spawn edge
from that parent.
4. Omitted provider and source filters include all values; explicit
filters keep ordinary `thread/list` semantics.
5. Grandchildren, Review threads, and Guardian threads are excluded.

## Verification

State (144 tests), rollout (69 tests), and focused app-server
thread-list (31 tests) suites passed. Scoped Clippy checks and
repository formatting also passed. Coverage includes direct spawned
children, omitted grandchildren, pagination, malformed IDs, mixed source
kinds, explicit filters, and operation without rollout files.
This commit is contained in:
Brent Traut
2026-06-14 00:14:26 -07:00
committed by GitHub
Unverified
parent efbd00f21f
commit dfd03ea01b
27 changed files with 540 additions and 53 deletions
+19 -1
View File
@@ -134,7 +134,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 `runtimeWorkspaceRoots` for the thread-scoped runtime roots 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. Subagent threads also include `parentThreadId` when the immediate control/spawn parent is known.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `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`, `itemsView`, `nextCursor`, and `backwardsCursor`.
@@ -387,6 +387,24 @@ Example:
When `nextCursor` is `null`, youve reached the final page.
### Example: List direct child threads
Enable `capabilities.experimentalApi` during initialization, then use `thread/list` with `parentThreadId` to page through a thread's direct spawned children from persisted spawn-edge state. Results do not recursively include grandchildren. Review and Guardian threads are not included because they do not participate in the spawn-edge lifecycle. When `modelProviders` or `sourceKinds` is omitted, parent-filtered requests include every provider or source kind, respectively. Explicit filters retain the ordinary `thread/list` behavior, including the interactive-only default for an empty `sourceKinds` list.
```json
{ "method": "thread/list", "id": 21, "params": {
"parentThreadId": "00000000-0000-0000-0000-000000000100",
"limit": 25
} }
{ "id": 21, "result": {
"data": [
{ "id": "00000000-0000-0000-0000-000000000101", "parentThreadId": "00000000-0000-0000-0000-000000000100", "status": { "type": "notLoaded" } }
],
"nextCursor": null,
"backwardsCursor": null
} }
```
### Example: List loaded threads
`thread/loaded/list` returns thread ids currently loaded in memory. This is useful when you want to check which sessions are active without scanning rollouts on disk.
@@ -16,6 +16,7 @@ struct ThreadListFilters {
cwd_filters: Option<Vec<PathBuf>>,
search_term: Option<String>,
use_state_db_only: bool,
parent_thread_id: Option<ThreadId>,
}
fn collect_resume_override_mismatches(
@@ -1875,8 +1876,14 @@ impl ThreadRequestProcessor {
cwd,
use_state_db_only,
search_term,
parent_thread_id,
} = params;
let cwd_filters = normalize_thread_list_cwd_filters(cwd)?;
let parent_thread_id = parent_thread_id
.as_deref()
.map(ThreadId::from_string)
.transpose()
.map_err(|err| invalid_request(format!("invalid parent thread id: {err}")))?;
let requested_page_size = limit
.map(|value| value as usize)
@@ -1900,6 +1907,7 @@ impl ThreadRequestProcessor {
cwd_filters,
search_term,
use_state_db_only,
parent_thread_id,
},
)
.await?;
@@ -3562,6 +3570,7 @@ impl ThreadRequestProcessor {
cwd_filters,
search_term,
use_state_db_only,
parent_thread_id,
} = filters;
let mut cursor_obj = cursor;
let mut last_cursor = cursor_obj.clone();
@@ -3577,9 +3586,15 @@ impl ThreadRequestProcessor {
Some(providers)
}
}
None if parent_thread_id.is_some() => None,
None => Some(vec![self.config.model_provider_id.clone()]),
};
let (allowed_sources_vec, source_kind_filter) = compute_source_filters(source_kinds);
let (allowed_sources_vec, source_kind_filter) =
if parent_thread_id.is_some() && 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,
@@ -3601,6 +3616,7 @@ impl ThreadRequestProcessor {
archived,
search_term: search_term.clone(),
use_state_db_only,
parent_thread_id,
})
.await
.map_err(thread_store_list_error)?;
@@ -338,6 +338,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -514,6 +515,7 @@ required = true
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -601,6 +603,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -688,6 +691,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions()
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -827,6 +831,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -912,6 +917,7 @@ async fn external_agent_config_import_rejects_undetected_session_paths() -> Resu
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -1036,6 +1042,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let response: JSONRPCResponse = timeout(
@@ -132,6 +132,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
},
})
.await?
@@ -71,6 +71,7 @@ async fn list_threads(mcp: &mut TestAppServer) -> Result<ThreadListResponse> {
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let list_resp: JSONRPCResponse = timeout(
@@ -35,6 +35,7 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use std::cmp::Reverse;
@@ -95,6 +96,7 @@ async fn list_threads_with_sort(
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -105,6 +107,37 @@ async fn list_threads_with_sort(
to_response::<ThreadListResponse>(resp)
}
async fn list_threads_for_parent(
mcp: &mut TestAppServer,
parent_thread_id: ThreadId,
cursor: Option<String>,
limit: u32,
model_providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
) -> Result<ThreadListResponse> {
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
cursor,
limit: Some(limit),
sort_key: None,
sort_direction: None,
model_providers,
source_kinds,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: Some(parent_thread_id.to_string()),
})
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response::<ThreadListResponse>(response)
}
fn create_fake_rollouts<F, G>(
codex_home: &Path,
count: usize,
@@ -537,6 +570,7 @@ async fn thread_list_respects_cwd_filters() -> Result<()> {
])),
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -646,6 +680,7 @@ sqlite = true
cwd: None,
use_state_db_only: false,
search_term: Some("needle".to_string()),
parent_thread_id: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -862,6 +897,7 @@ sqlite = true
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -900,6 +936,7 @@ sqlite = true
)),
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -929,6 +966,7 @@ sqlite = true
)),
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -942,6 +980,162 @@ sqlite = true
Ok(())
}
#[tokio::test]
async fn thread_list_parent_filter_reads_direct_children_from_state_db() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let parent_id = ThreadId::new();
let older_child_id = ThreadId::new();
let newer_child_id = ThreadId::new();
let grandchild_id = ThreadId::new();
let state_db = codex_state::StateRuntime::init(
codex_home.path().to_path_buf(),
"mock_provider".to_string(),
)
.await?;
for (thread_id, created_at, source, model_provider) in [
(
older_child_id,
"2025-02-01T10:00:00Z",
CoreSessionSource::SubAgent(SubAgentSource::Other("agent_job:job-1".to_string())),
"other_provider",
),
(
newer_child_id,
"2025-02-01T11:00:00Z",
CoreSessionSource::Cli,
"mock_provider",
),
(
grandchild_id,
"2025-02-01T12:00:00Z",
CoreSessionSource::SubAgent(SubAgentSource::Other("agent_job:job-2".to_string())),
"mock_provider",
),
] {
let created_at = DateTime::parse_from_rfc3339(created_at)?.with_timezone(&Utc);
let mut builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
codex_home.path().join(format!("{thread_id}.jsonl")),
created_at,
source,
);
builder.model_provider = Some(model_provider.to_string());
builder.cwd = codex_home.path().to_path_buf();
builder.cli_version = Some("0.0.0".to_string());
let mut metadata = builder.build(model_provider);
metadata.preview = Some("child thread".to_string());
metadata.first_user_message = metadata.preview.clone();
state_db.upsert_thread(&metadata).await?;
}
for (parent_thread_id, child_thread_id) in [
(parent_id, older_child_id),
(parent_id, newer_child_id),
(newer_child_id, grandchild_id),
] {
state_db
.upsert_thread_spawn_edge(
parent_thread_id,
child_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await?;
}
state_db
.mark_backfill_complete(/*last_watermark*/ None)
.await?;
let mut mcp = init_mcp(codex_home.path()).await?;
let first_page = list_threads_for_parent(
&mut mcp, parent_id, /*cursor*/ None, /*limit*/ 1, /*model_providers*/ None,
/*source_kinds*/ None,
)
.await?;
let second_page = list_threads_for_parent(
&mut mcp,
parent_id,
first_page.next_cursor.clone(),
/*limit*/ 1,
/*model_providers*/ None,
/*source_kinds*/ None,
)
.await?;
assert_eq!(
first_page
.data
.iter()
.map(|thread| thread.id.clone())
.collect::<Vec<_>>(),
vec![newer_child_id.to_string()]
);
assert_eq!(
second_page
.data
.iter()
.map(|thread| thread.id.clone())
.collect::<Vec<_>>(),
vec![older_child_id.to_string()]
);
assert_eq!(second_page.next_cursor, None);
let expected_parent_id = parent_id.to_string();
assert!(
first_page
.data
.iter()
.chain(&second_page.data)
.all(|thread| thread.parent_thread_id.as_deref() == Some(expected_parent_id.as_str()))
);
let interactive_only = list_threads_for_parent(
&mut mcp,
parent_id,
/*cursor*/ None,
/*limit*/ 10,
/*model_providers*/ None,
/*source_kinds*/ Some(Vec::new()),
)
.await?;
assert_eq!(
interactive_only
.data
.iter()
.map(|thread| thread.id.clone())
.collect::<Vec<_>>(),
vec![newer_child_id.to_string()]
);
Ok(())
}
#[tokio::test]
async fn thread_list_parent_filter_rejects_malformed_thread_id() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let mut mcp = init_mcp(codex_home.path()).await?;
let 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: None,
source_kinds: None,
archived: None,
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: Some("not-a-thread-id".to_string()),
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.error.code, -32600);
Ok(())
}
#[tokio::test]
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1629,6 +1823,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -1671,6 +1866,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -1909,6 +2105,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let error: JSONRPCError = timeout(
@@ -566,6 +566,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
},
})
.await?
@@ -960,6 +961,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
cwd: None,
use_state_db_only: false,
search_term: None,
parent_thread_id: None,
})
.await?;
let list_resp: JSONRPCResponse = timeout(