diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index fdd4c155b0..200bf63fd1 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6c2ad69e1b..52757ee340 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 150b221adf..172563a687 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadLoadedListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadLoadedListParams.json index d10ee7ed96..db59495f6e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadLoadedListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadLoadedListParams.json @@ -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" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadLoadedListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadLoadedListResponse.json index cfd90fb813..c13d9a6c89 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadLoadedListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadLoadedListResponse.json @@ -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" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedListParams.ts index a7889e4f09..0fdcbe2f3d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedListParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedListResponse.ts index 21a48c37e9..2fc0c22d67 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedListResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedListResponse.ts @@ -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, +/** + * Loaded-thread summaries for the returned page when `includeSummaries` is true. + * Empty when summaries were not requested. + */ +summaries: Array, /** * Opaque cursor to pass to the next call to continue after the last item. * if None, there are no more items to return. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedSummary.ts new file mode 100644 index 0000000000..aee24a3a5a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadLoadedSummary.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d369ba3423..307d3bcd5f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index aea80029a8..3a0cdeadc0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4393,6 +4393,9 @@ pub struct ThreadLoadedListParams { /// Optional page size; defaults to no limit. #[ts(optional = nullable)] pub limit: Option, + /// 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, + /// Loaded-thread summaries for the returned page when `includeSummaries` is true. + /// Empty when summaries were not requested. + #[serde(default)] + pub summaries: Vec, /// 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, } +#[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, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + pub agent_role: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index df41d859cd..44a9f84195 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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" } + ] } } ``` diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index f59ea14402..8ff2949f20 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -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; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 40be4f34ef..4dc816b631 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1896,7 +1896,11 @@ impl ThreadRequestProcessor { &self, params: ThreadLoadedListParams, ) -> Result { - let ThreadLoadedListParams { cursor, limit } = params; + let ThreadLoadedListParams { + cursor, + limit, + include_summaries, + } = params; let mut data: Vec = 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, }) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs b/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs index 245cfafb49..d2ed66b215 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs @@ -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::(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::(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::(resp)?; assert_eq!(second_page, vec![expected[1].clone()]); + assert_eq!(second_summaries, Vec::new()); assert_eq!(next_cursor, None); Ok(()) diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index c0188add8c..47e30e3c7f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -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::(list_resp)?; + let ThreadLoadedListResponse { + data, + summaries, + next_cursor, + } = to_response::(list_resp)?; assert_eq!(data, vec![thread_id]); + assert_eq!(summaries, Vec::new()); assert_eq!(next_cursor, None); Ok(()) diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index c98a54180c..19cbd02697 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -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, + summaries: Vec, primary_thread_id: ThreadId, ) -> Vec { - 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 = 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 { - 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, + 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, ); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 05aba144c8..fa78d0d271 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -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 diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 449da8e212..258b1a2ae7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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,