mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
efbd00f21f
commit
dfd03ea01b
@@ -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 thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
|
||||
@@ -387,6 +387,24 @@ Example:
|
||||
|
||||
When `nextCursor` is `null`, you’ve 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(
|
||||
|
||||
Reference in New Issue
Block a user