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
@@ -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