mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
# Summary Adds a field Thread.extras that can be used to hold arbitrary metadata specific to a given thread.
235 lines
8.7 KiB
Rust
235 lines
8.7 KiB
Rust
//! Discovers subagent threads that belong to a primary thread by walking spawn-tree edges.
|
|
//!
|
|
//! When the TUI resumes or switches to an existing thread, it needs to populate
|
|
//! `AgentNavigationState` and `ChatWidget` metadata for every subagent that was spawned during
|
|
//! that thread's lifetime. The app server exposes a flat list of currently loaded threads via
|
|
//! `thread/loaded/list`, but the TUI must figure out which of those are descendants of the
|
|
//! primary thread.
|
|
//!
|
|
//! This module provides the pure, synchronous tree-walk that turns that flat list into the filtered
|
|
//! set of descendants. It intentionally has no async, no I/O, and no side effects so it can be
|
|
//! unit-tested in isolation.
|
|
//!
|
|
//! The walk starts from `primary_thread_id` and repeatedly follows
|
|
//! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are
|
|
//! found. The primary thread itself is never included in the output.
|
|
|
|
use codex_app_server_protocol::SessionSource;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
|
|
/// A subagent thread discovered by the spawn-tree walk, carrying just enough metadata for the
|
|
/// TUI to register it in the navigation cache and rendering metadata map.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct LoadedSubagentThread {
|
|
pub(crate) thread_id: ThreadId,
|
|
pub(crate) agent_nickname: Option<String>,
|
|
pub(crate) agent_role: Option<String>,
|
|
pub(crate) agent_path: Option<String>,
|
|
}
|
|
|
|
/// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent.
|
|
///
|
|
/// The walk is breadth-first over `SessionSource::SubAgent(ThreadSpawn { parent_thread_id })` edges.
|
|
/// Threads whose `source` is not a `ThreadSpawn`, or whose `parent_thread_id` does not chain back
|
|
/// to `primary_thread_id`, are excluded. The primary thread itself is never included.
|
|
///
|
|
/// Results are sorted by stringified thread id for deterministic output in tests and in the
|
|
/// navigation cache. Callers should not rely on this ordering for anything semantic; it exists
|
|
/// purely to make snapshot assertions stable.
|
|
///
|
|
/// If two threads claim the same parent, both are included. Cycles in the parent chain are not
|
|
/// possible because `ThreadId`s are server-assigned UUIDs and the server enforces acyclicity, but
|
|
/// the `included` set guards against re-visiting regardless.
|
|
pub(crate) fn find_loaded_subagent_threads_for_primary(
|
|
threads: Vec<Thread>,
|
|
primary_thread_id: ThreadId,
|
|
) -> Vec<LoadedSubagentThread> {
|
|
let mut threads_by_id = HashMap::new();
|
|
for thread in threads {
|
|
let Ok(thread_id) = ThreadId::from_string(&thread.id) else {
|
|
continue;
|
|
};
|
|
threads_by_id.insert(thread_id, thread);
|
|
}
|
|
|
|
let mut included = HashSet::new();
|
|
let mut pending = vec![primary_thread_id];
|
|
while let Some(parent_thread_id) = pending.pop() {
|
|
for (thread_id, thread) in &threads_by_id {
|
|
if included.contains(thread_id) {
|
|
continue;
|
|
}
|
|
|
|
let Some(source_parent_thread_id) = thread_spawn_parent_thread_id(&thread.source)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if source_parent_thread_id != parent_thread_id {
|
|
continue;
|
|
}
|
|
|
|
included.insert(*thread_id);
|
|
pending.push(*thread_id);
|
|
}
|
|
}
|
|
|
|
let mut loaded_threads: Vec<LoadedSubagentThread> = included
|
|
.into_iter()
|
|
.filter_map(|thread_id| {
|
|
threads_by_id
|
|
.remove(&thread_id)
|
|
.map(|thread| LoadedSubagentThread {
|
|
thread_id,
|
|
agent_nickname: thread.agent_nickname,
|
|
agent_role: thread.agent_role,
|
|
agent_path: thread_spawn_agent_path(&thread.source),
|
|
})
|
|
})
|
|
.collect();
|
|
loaded_threads.sort_by_key(|thread| thread.thread_id.to_string());
|
|
loaded_threads
|
|
}
|
|
|
|
fn thread_spawn_agent_path(source: &SessionSource) -> Option<String> {
|
|
match source {
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => {
|
|
agent_path.clone().map(String::from)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn thread_spawn_parent_thread_id(source: &SessionSource) -> Option<ThreadId> {
|
|
match source {
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
|
parent_thread_id, ..
|
|
}) => Some(*parent_thread_id),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::LoadedSubagentThread;
|
|
use super::find_loaded_subagent_threads_for_primary;
|
|
use codex_app_server_protocol::SessionSource;
|
|
use codex_app_server_protocol::Thread;
|
|
use codex_app_server_protocol::ThreadStatus;
|
|
use codex_protocol::ThreadId;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread {
|
|
Thread {
|
|
id: thread_id.to_string(),
|
|
extra: None,
|
|
session_id: thread_id.to_string(),
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
preview: String::new(),
|
|
ephemeral: false,
|
|
model_provider: "openai".to_string(),
|
|
created_at: 0,
|
|
updated_at: 0,
|
|
recency_at: Some(0),
|
|
status: ThreadStatus::Idle,
|
|
path: None,
|
|
cwd: test_path_buf("/tmp").abs(),
|
|
cli_version: "0.0.0".to_string(),
|
|
source,
|
|
thread_source: None,
|
|
agent_nickname: None,
|
|
agent_role: None,
|
|
git_info: None,
|
|
name: None,
|
|
turns: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn thread_spawn_source(
|
|
parent_thread_id: ThreadId,
|
|
depth: i32,
|
|
agent_nickname: &str,
|
|
agent_role: &str,
|
|
) -> SessionSource {
|
|
serde_json::from_value(serde_json::json!({
|
|
"subAgent": {
|
|
"thread_spawn": {
|
|
"parent_thread_id": parent_thread_id.to_string(),
|
|
"depth": depth,
|
|
"agent_nickname": agent_nickname,
|
|
"agent_role": agent_role,
|
|
}
|
|
}
|
|
}))
|
|
.expect("valid subagent source")
|
|
}
|
|
|
|
#[test]
|
|
fn finds_loaded_subagent_tree_for_primary_thread() {
|
|
let primary_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread");
|
|
let child_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread");
|
|
let grandchild_thread_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread");
|
|
let unrelated_parent_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000004").expect("valid thread");
|
|
let unrelated_child_id =
|
|
ThreadId::from_string("00000000-0000-0000-0000-000000000005").expect("valid thread");
|
|
|
|
let mut child = test_thread(
|
|
child_thread_id,
|
|
thread_spawn_source(primary_thread_id, /*depth*/ 1, "Scout", "explorer"),
|
|
);
|
|
child.agent_nickname = Some("Scout".to_string());
|
|
child.agent_role = Some("explorer".to_string());
|
|
|
|
let mut grandchild = test_thread(
|
|
grandchild_thread_id,
|
|
thread_spawn_source(child_thread_id, /*depth*/ 2, "Atlas", "worker"),
|
|
);
|
|
grandchild.agent_nickname = Some("Atlas".to_string());
|
|
grandchild.agent_role = Some("worker".to_string());
|
|
|
|
let unrelated_child = test_thread(
|
|
unrelated_child_id,
|
|
thread_spawn_source(unrelated_parent_id, /*depth*/ 1, "Other", "researcher"),
|
|
);
|
|
|
|
let loaded = find_loaded_subagent_threads_for_primary(
|
|
vec![
|
|
test_thread(primary_thread_id, SessionSource::Cli),
|
|
child,
|
|
grandchild,
|
|
unrelated_child,
|
|
],
|
|
primary_thread_id,
|
|
);
|
|
|
|
assert_eq!(
|
|
loaded,
|
|
vec![
|
|
LoadedSubagentThread {
|
|
thread_id: child_thread_id,
|
|
agent_nickname: Some("Scout".to_string()),
|
|
agent_role: Some("explorer".to_string()),
|
|
agent_path: None,
|
|
},
|
|
LoadedSubagentThread {
|
|
thread_id: grandchild_thread_id,
|
|
agent_nickname: Some("Atlas".to_string()),
|
|
agent_role: Some("worker".to_string()),
|
|
agent_path: None,
|
|
},
|
|
]
|
|
);
|
|
}
|
|
}
|