Add loaded thread summaries for subagent backfill

This commit is contained in:
canvrno-oai
2026-05-04 14:08:10 -07:00
parent 87d2235b54
commit a3f6451db9
18 changed files with 358 additions and 146 deletions
+5 -1
View File
@@ -3691,6 +3691,10 @@
"integer",
"null"
]
},
"includeSummaries": {
"description": "When true, include lightweight metadata for each loaded thread in the page.",
"type": "boolean"
}
},
"type": "object"
@@ -6327,4 +6331,4 @@
}
],
"title": "ClientRequest"
}
}
@@ -16218,6 +16218,10 @@
"integer",
"null"
]
},
"includeSummaries": {
"description": "When true, include lightweight metadata for each loaded thread in the page.",
"type": "boolean"
}
},
"title": "ThreadLoadedListParams",
@@ -16233,6 +16237,13 @@
},
"type": "array"
},
"summaries": {
"description": "Loaded-thread summaries for the returned page when `includeSummaries` is true. Empty when summaries were not requested.",
"items": {
"$ref": "#/definitions/v2/ThreadLoadedSummary"
},
"type": "array"
},
"nextCursor": {
"description": "Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.",
"type": [
@@ -16247,6 +16258,39 @@
"title": "ThreadLoadedListResponse",
"type": "object"
},
"ThreadLoadedSummary": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"id": {
"description": "Thread id for a currently loaded session.",
"type": "string"
},
"parentThreadId": {
"description": "Parent thread id when this loaded thread is an AgentControl-spawned subagent.",
"type": [
"string",
"null"
]
}
},
"required": [
"id"
],
"type": "object"
},
"ThreadMemoryMode": {
"enum": [
"enabled",
@@ -18261,4 +18305,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -14104,6 +14104,10 @@
"integer",
"null"
]
},
"includeSummaries": {
"description": "When true, include lightweight metadata for each loaded thread in the page.",
"type": "boolean"
}
},
"title": "ThreadLoadedListParams",
@@ -14119,6 +14123,13 @@
},
"type": "array"
},
"summaries": {
"description": "Loaded-thread summaries for the returned page when `includeSummaries` is true. Empty when summaries were not requested.",
"items": {
"$ref": "#/definitions/ThreadLoadedSummary"
},
"type": "array"
},
"nextCursor": {
"description": "Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.",
"type": [
@@ -14133,6 +14144,39 @@
"title": "ThreadLoadedListResponse",
"type": "object"
},
"ThreadLoadedSummary": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"id": {
"description": "Thread id for a currently loaded session.",
"type": "string"
},
"parentThreadId": {
"description": "Parent thread id when this loaded thread is an AgentControl-spawned subagent.",
"type": [
"string",
"null"
]
}
},
"required": [
"id"
],
"type": "object"
},
"ThreadMemoryMode": {
"enum": [
"enabled",
@@ -16146,4 +16190,4 @@
},
"title": "CodexAppServerProtocolV2",
"type": "object"
}
}
@@ -16,8 +16,12 @@
"integer",
"null"
]
},
"includeSummaries": {
"description": "When true, include lightweight metadata for each loaded thread in the page.",
"type": "boolean"
}
},
"title": "ThreadLoadedListParams",
"type": "object"
}
}
@@ -1,5 +1,40 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ThreadLoadedSummary": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"id": {
"description": "Thread id for a currently loaded session.",
"type": "string"
},
"parentThreadId": {
"description": "Parent thread id when this loaded thread is an AgentControl-spawned subagent.",
"type": [
"string",
"null"
]
}
},
"required": [
"id"
],
"type": "object"
}
},
"properties": {
"data": {
"description": "Thread ids for sessions currently loaded in memory.",
@@ -8,6 +43,13 @@
},
"type": "array"
},
"summaries": {
"description": "Loaded-thread summaries for the returned page when `includeSummaries` is true. Empty when summaries were not requested.",
"items": {
"$ref": "#/definitions/ThreadLoadedSummary"
},
"type": "array"
},
"nextCursor": {
"description": "Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.",
"type": [
@@ -21,4 +63,4 @@
],
"title": "ThreadLoadedListResponse",
"type": "object"
}
}
@@ -10,4 +10,8 @@ cursor?: string | null,
/**
* Optional page size; defaults to no limit.
*/
limit?: number | null, };
limit?: number | null,
/**
* When true, include lightweight metadata for each loaded thread in the page.
*/
includeSummaries?: boolean, };
@@ -1,12 +1,18 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadLoadedSummary } from "./ThreadLoadedSummary";
export type ThreadLoadedListResponse = {
/**
* Thread ids for sessions currently loaded in memory.
*/
data: Array<string>,
/**
* Loaded-thread summaries for the returned page when `includeSummaries` is true.
* Empty when summaries were not requested.
*/
summaries: Array<ThreadLoadedSummary>,
/**
* Opaque cursor to pass to the next call to continue after the last item.
* if None, there are no more items to return.
@@ -0,0 +1,17 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadLoadedSummary = { id: string,
/**
* Parent thread id when this loaded thread is an AgentControl-spawned subagent.
*/
parentThreadId: string | null,
/**
* Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
*/
agentNickname: string | null,
/**
* Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
*/
agentRole: string | null, };
@@ -365,6 +365,7 @@ export type { ThreadListParams } from "./ThreadListParams";
export type { ThreadListResponse } from "./ThreadListResponse";
export type { ThreadLoadedListParams } from "./ThreadLoadedListParams";
export type { ThreadLoadedListResponse } from "./ThreadLoadedListResponse";
export type { ThreadLoadedSummary } from "./ThreadLoadedSummary";
export type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams";
export type { ThreadMetadataUpdateParams } from "./ThreadMetadataUpdateParams";
export type { ThreadMetadataUpdateResponse } from "./ThreadMetadataUpdateResponse";
@@ -4393,6 +4393,9 @@ pub struct ThreadLoadedListParams {
/// Optional page size; defaults to no limit.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// When true, include lightweight metadata for each loaded thread in the page.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub include_summaries: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -4401,11 +4404,29 @@ pub struct ThreadLoadedListParams {
pub struct ThreadLoadedListResponse {
/// Thread ids for sessions currently loaded in memory.
pub data: Vec<String>,
/// Loaded-thread summaries for the returned page when `includeSummaries` is true.
/// Empty when summaries were not requested.
#[serde(default)]
pub summaries: Vec<ThreadLoadedSummary>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// if None, there are no more items to return.
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadLoadedSummary {
/// Thread id for a currently loaded session.
pub id: String,
/// Parent thread id when this loaded thread is an AgentControl-spawned subagent.
pub parent_thread_id: Option<String>,
/// Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
pub agent_nickname: Option<String>,
/// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
pub agent_role: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
+8 -4
View File
@@ -147,7 +147,7 @@ Example with notification opt-out:
- `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 response `permissionProfile` for the exact active runtime permissions 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.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/loaded/list` — list the thread ids currently loaded in memory. Pass `includeSummaries: true` to include lightweight live metadata for each returned thread.
- `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`, `nextCursor`, and `backwardsCursor`.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
@@ -347,12 +347,16 @@ When `nextCursor` is `null`, you’ve reached the final page.
### 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.
`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. Pass `includeSummaries: true` when the caller also needs lightweight live metadata, such as subagent parentage.
```json
{ "method": "thread/loaded/list", "id": 21 }
{ "method": "thread/loaded/list", "id": 21, "params": { "includeSummaries": true } }
{ "id": 21, "result": {
"data": ["thr_123", "thr_456"]
"data": ["thr_123", "thr_456"],
"summaries": [
{ "id": "thr_123", "parentThreadId": null, "agentNickname": null, "agentRole": null },
{ "id": "thr_456", "parentThreadId": "thr_123", "agentNickname": "Scout", "agentRole": "explorer" }
]
} }
```
@@ -180,6 +180,7 @@ use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadLoadedSummary;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
@@ -1896,7 +1896,11 @@ impl ThreadRequestProcessor {
&self,
params: ThreadLoadedListParams,
) -> Result<ThreadLoadedListResponse, JSONRPCErrorError> {
let ThreadLoadedListParams { cursor, limit } = params;
let ThreadLoadedListParams {
cursor,
limit,
include_summaries,
} = params;
let mut data: Vec<String> = self
.thread_manager
.list_thread_ids()
@@ -1904,15 +1908,16 @@ impl ThreadRequestProcessor {
.into_iter()
.map(|thread_id| thread_id.to_string())
.collect();
data.sort();
if data.is_empty() {
return Ok(ThreadLoadedListResponse {
data,
summaries: Vec::new(),
next_cursor: None,
});
}
data.sort();
let total = data.len();
let start = match cursor {
Some(cursor) => {
@@ -1931,10 +1936,52 @@ impl ThreadRequestProcessor {
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let end = start.saturating_add(effective_limit).min(total);
let page = data[start..end].to_vec();
let summaries = if include_summaries {
let mut summaries = Vec::with_capacity(page.len());
for thread_id in &page {
let Ok(thread_id) = ThreadId::from_string(thread_id) else {
tracing::warn!("ignoring loaded thread with invalid id during summary build");
continue;
};
let thread = match self.thread_manager.get_thread(thread_id).await {
Ok(thread) => thread,
Err(err) => {
tracing::warn!(
thread_id = %thread_id,
%err,
"failed to load thread summary"
);
continue;
}
};
let config_snapshot = thread.config_snapshot().await;
let parent_thread_id = match &config_snapshot.session_source {
codex_protocol::protocol::SessionSource::SubAgent(
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
parent_thread_id,
..
},
) => Some(parent_thread_id.to_string()),
_ => None,
};
summaries.push(ThreadLoadedSummary {
id: thread_id.to_string(),
parent_thread_id,
agent_nickname: config_snapshot.session_source.get_nickname(),
agent_role: config_snapshot.session_source.get_agent_role(),
});
}
summaries
} else {
Vec::new()
};
let next_cursor = page.last().filter(|_| end < total).cloned();
Ok(ThreadLoadedListResponse {
data: page,
summaries,
next_cursor,
})
}
@@ -6,6 +6,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadLoadedSummary;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use pretty_assertions::assert_eq;
@@ -16,7 +17,7 @@ use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_loaded_list_returns_loaded_thread_ids() -> Result<()> {
async fn thread_loaded_list_returns_loaded_thread_ids_and_summaries() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
@@ -27,7 +28,10 @@ async fn thread_loaded_list_returns_loaded_thread_ids() -> Result<()> {
let thread_id = start_thread(&mut mcp).await?;
let list_id = mcp
.send_thread_loaded_list_request(ThreadLoadedListParams::default())
.send_thread_loaded_list_request(ThreadLoadedListParams {
include_summaries: true,
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
@@ -36,10 +40,20 @@ async fn thread_loaded_list_returns_loaded_thread_ids() -> Result<()> {
.await??;
let ThreadLoadedListResponse {
mut data,
summaries,
next_cursor,
} = to_response::<ThreadLoadedListResponse>(resp)?;
data.sort();
assert_eq!(data, vec![thread_id]);
assert_eq!(data, vec![thread_id.clone()]);
assert_eq!(
summaries,
vec![ThreadLoadedSummary {
id: thread_id,
parent_thread_id: None,
agent_nickname: None,
agent_role: None,
}]
);
assert_eq!(next_cursor, None);
Ok(())
@@ -64,6 +78,7 @@ async fn thread_loaded_list_paginates() -> Result<()> {
.send_thread_loaded_list_request(ThreadLoadedListParams {
cursor: None,
limit: Some(1),
include_summaries: false,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -73,15 +88,18 @@ async fn thread_loaded_list_paginates() -> Result<()> {
.await??;
let ThreadLoadedListResponse {
data: first_page,
summaries: first_summaries,
next_cursor,
} = to_response::<ThreadLoadedListResponse>(resp)?;
assert_eq!(first_page, vec![expected[0].clone()]);
assert_eq!(first_summaries, Vec::new());
assert_eq!(next_cursor, Some(expected[0].clone()));
let list_id = mcp
.send_thread_loaded_list_request(ThreadLoadedListParams {
cursor: next_cursor,
limit: Some(1),
include_summaries: false,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -91,9 +109,11 @@ async fn thread_loaded_list_paginates() -> Result<()> {
.await??;
let ThreadLoadedListResponse {
data: second_page,
summaries: second_summaries,
next_cursor,
} = to_response::<ThreadLoadedListResponse>(resp)?;
assert_eq!(second_page, vec![expected[1].clone()]);
assert_eq!(second_summaries, Vec::new());
assert_eq!(next_cursor, None);
Ok(())
@@ -76,9 +76,13 @@ async fn thread_unsubscribe_keeps_thread_loaded_until_idle_timeout() -> Result<(
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
)
.await??;
let ThreadLoadedListResponse { data, next_cursor } =
to_response::<ThreadLoadedListResponse>(list_resp)?;
let ThreadLoadedListResponse {
data,
summaries,
next_cursor,
} = to_response::<ThreadLoadedListResponse>(list_resp)?;
assert_eq!(data, vec![thread_id]);
assert_eq!(summaries, Vec::new());
assert_eq!(next_cursor, None);
Ok(())
+59 -98
View File
@@ -1,20 +1,20 @@
//! Discovers subagent threads that belong to a primary thread by walking spawn-tree edges.
//! Discovers subagent threads that belong to a primary thread by walking loaded-thread summaries.
//!
//! 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
//! that thread's lifetime. The app server exposes a flat summary 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.
//! The walk starts from `primary_thread_id` and repeatedly follows loaded summary
//! `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::Thread;
use codex_app_server_protocol::ThreadLoadedSummary;
use codex_protocol::ThreadId;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -30,9 +30,9 @@ pub(crate) struct LoadedSubagentThread {
/// 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.
/// The walk is breadth-first over loaded summary `parent_thread_id` edges. Threads 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
@@ -42,26 +42,29 @@ pub(crate) struct LoadedSubagentThread {
/// 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>,
summaries: Vec<ThreadLoadedSummary>,
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 {
let mut summaries_by_id = HashMap::new();
for summary in summaries {
let Ok(thread_id) = ThreadId::from_string(&summary.id) else {
continue;
};
threads_by_id.insert(thread_id, thread);
summaries_by_id.insert(thread_id, summary);
}
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 {
for (thread_id, summary) in &summaries_by_id {
if included.contains(thread_id) {
continue;
}
let Some(source_parent_thread_id) = thread_spawn_parent_thread_id(&thread.source)
let Some(source_parent_thread_id) = summary
.parent_thread_id
.as_deref()
.and_then(|id| ThreadId::from_string(id).ok())
else {
continue;
};
@@ -78,12 +81,12 @@ pub(crate) fn find_loaded_subagent_threads_for_primary(
let mut loaded_threads: Vec<LoadedSubagentThread> = included
.into_iter()
.filter_map(|thread_id| {
threads_by_id
summaries_by_id
.remove(&thread_id)
.map(|thread| LoadedSubagentThread {
.map(|summary| LoadedSubagentThread {
thread_id,
agent_nickname: thread.agent_nickname,
agent_role: thread.agent_role,
agent_nickname: summary.agent_nickname,
agent_role: summary.agent_role,
})
})
.collect();
@@ -91,71 +94,28 @@ pub(crate) fn find_loaded_subagent_threads_for_primary(
loaded_threads
}
fn thread_spawn_parent_thread_id(
source: &codex_app_server_protocol::SessionSource,
) -> Option<ThreadId> {
let value = serde_json::to_value(source).ok()?;
let parent_thread_id = value
.get("subAgent")?
.get("thread_spawn")?
.get("parent_thread_id")?
.as_str()?;
ThreadId::from_string(parent_thread_id).ok()
}
#[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_app_server_protocol::ThreadLoadedSummary;
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 {
fn summary(
thread_id: ThreadId,
parent_thread_id: Option<ThreadId>,
agent_nickname: Option<&str>,
agent_role: Option<&str>,
) -> ThreadLoadedSummary {
ThreadLoadedSummary {
id: thread_id.to_string(),
forked_from_id: None,
preview: String::new(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 0,
updated_at: 0,
status: ThreadStatus::Idle,
path: None,
cwd: test_path_buf("/tmp").abs(),
cli_version: "0.0.0".to_string(),
source,
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: Vec::new(),
parent_thread_id: parent_thread_id.map(|id| id.to_string()),
agent_nickname: agent_nickname.map(str::to_string),
agent_role: agent_role.map(str::to_string),
}
}
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 =
@@ -169,31 +129,32 @@ mod tests {
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,
summary(
primary_thread_id,
/*parent_thread_id*/ None,
/*agent_nickname*/ None,
/*agent_role*/ None,
),
summary(
child_thread_id,
Some(primary_thread_id),
Some("Scout"),
Some("explorer"),
),
summary(
grandchild_thread_id,
Some(child_thread_id),
Some("Atlas"),
Some("worker"),
),
summary(
unrelated_child_id,
Some(unrelated_parent_id),
Some("Other"),
Some("researcher"),
),
],
primary_thread_id,
);
+17 -29
View File
@@ -520,9 +520,9 @@ impl App {
/// thread creation so that the `/agent` picker and keyboard navigation are pre-populated even
/// if the TUI did not witness the original spawn events.
///
/// The loaded-thread list is fetched in full (no pagination) and the spawn tree is walked
/// by `find_loaded_subagent_threads_for_primary`. Each discovered subagent is registered via
/// `upsert_agent_picker_thread`, which writes to both `AgentNavigationState` and the
/// The loaded-thread summary list is fetched in full (no pagination) and the spawn tree is
/// walked by `find_loaded_subagent_threads_for_primary`. Each discovered subagent is registered
/// via `upsert_agent_picker_thread`, which writes to both `AgentNavigationState` and the
/// `ChatWidget` metadata map.
pub(super) async fn backfill_loaded_subagent_threads(
&mut self,
@@ -532,45 +532,33 @@ impl App {
return false;
};
let loaded_thread_ids = match app_server
let loaded_threads = match app_server
.thread_loaded_list(ThreadLoadedListParams {
cursor: None,
limit: None,
include_summaries: true,
})
.await
{
Ok(response) => response.data,
Ok(response) => response,
Err(err) => {
tracing::warn!(%err, "failed to list loaded threads for subagent backfill");
return false;
}
};
let mut threads = Vec::new();
let mut had_read_error = false;
for thread_id in loaded_thread_ids {
let Ok(thread_id) = ThreadId::from_string(&thread_id) else {
tracing::warn!("ignoring loaded thread with invalid id during subagent backfill");
continue;
};
if thread_id == primary_thread_id {
continue;
}
match app_server
.thread_read(thread_id, /*include_turns*/ false)
.await
{
Ok(thread) => threads.push(thread),
Err(err) => {
had_read_error = true;
tracing::warn!(thread_id = %thread_id, %err, "failed to read loaded thread");
}
}
let had_summary_gap = loaded_threads.summaries.len() != loaded_threads.data.len();
if had_summary_gap {
tracing::warn!(
loaded_thread_count = loaded_threads.data.len(),
loaded_summary_count = loaded_threads.summaries.len(),
"loaded thread summary response omitted entries"
);
}
for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) {
for thread in
find_loaded_subagent_threads_for_primary(loaded_threads.summaries, primary_thread_id)
{
self.upsert_agent_picker_thread(
thread.thread_id,
thread.agent_nickname,
@@ -579,7 +567,7 @@ impl App {
);
}
!had_read_error
!had_summary_gap
}
/// Returns the adjacent thread id for keyboard navigation, backfilling from the server if the
+2 -2
View File
@@ -461,8 +461,8 @@ impl AppServerSession {
/// Lists thread ids that the app server currently holds in memory.
///
/// Used by `App::backfill_loaded_subagent_threads` to discover subagent threads that were
/// spawned before the TUI connected. The caller then fetches full metadata per thread via
/// `thread_read` and walks the spawn tree.
/// spawned before the TUI connected. That caller requests summaries and walks the spawn tree
/// without issuing a `thread/read` for each loaded thread.
pub(crate) async fn thread_loaded_list(
&mut self,
params: ThreadLoadedListParams,