From 5fb7a44ecf2cdc4a9c5157fe22effbb3d75c4a48 Mon Sep 17 00:00:00 2001 From: Brent Traut Date: Thu, 4 Jun 2026 14:23:32 -0700 Subject: [PATCH] feat(app-server): include persisted subagent metadata --- .../codex_app_server_protocol.schemas.json | 18 +++++ .../codex_app_server_protocol.v2.schemas.json | 18 +++++ .../schema/typescript/v2/ThreadSubagent.ts | 2 +- .../src/protocol/v2/thread.rs | 3 + codex-rs/app-server/README.md | 4 +- .../request_processors/thread_processor.rs | 3 + .../tests/suite/v2/thread_subagents.rs | 66 ++++++++++++++++++- codex-rs/state/src/model/graph.rs | 7 +- codex-rs/state/src/runtime/threads.rs | 31 +++++++-- 9 files changed, 140 insertions(+), 12 deletions(-) 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 dfe886c777..2ed99db05f 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 @@ -18093,9 +18093,27 @@ "lifecycleStatus": { "$ref": "#/definitions/v2/ThreadSubagentLifecycleStatus" }, + "model": { + "type": [ + "string", + "null" + ] + }, + "nickname": { + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "type": "string" }, + "role": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" } 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 2cb5ff750e..76209aca9c 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 @@ -15910,9 +15910,27 @@ "lifecycleStatus": { "$ref": "#/definitions/ThreadSubagentLifecycleStatus" }, + "model": { + "type": [ + "string", + "null" + ] + }, + "nickname": { + "type": [ + "string", + "null" + ] + }, "parentThreadId": { "type": "string" }, + "role": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSubagent.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSubagent.ts index dd581cc22f..cba32253d6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSubagent.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSubagent.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadSubagentLifecycleStatus } from "./ThreadSubagentLifecycleStatus"; -export type ThreadSubagent = { threadId: string, parentThreadId: string, lifecycleStatus: ThreadSubagentLifecycleStatus, }; +export type ThreadSubagent = { threadId: string, parentThreadId: string, lifecycleStatus: ThreadSubagentLifecycleStatus, nickname: string | null, role: string | null, model: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 601557952f..33e37b6181 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1143,6 +1143,9 @@ pub struct ThreadSubagent { pub thread_id: String, pub parent_thread_id: String, pub lifecycle_status: ThreadSubagentLifecycleStatus, + pub nickname: Option, + pub role: Option, + pub model: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2d36c34e56..7847c8ac44 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -374,7 +374,7 @@ When `nextCursor` is `null`, you’ve reached the final page. ### Example: List thread subagents (experimental) -Use `thread/subagents/list` with `capabilities.experimentalApi = true` to page through direct persisted subagent children. Results are ordered by `threadId`. Pass the opaque `nextCursor` from one response into the next request. Omit `lifecycleStatuses` to include every durable spawn-edge lifecycle status, or filter with `"open"` and `"closed"`. +Use `thread/subagents/list` with `capabilities.experimentalApi = true` to page through direct persisted subagent children. Results are ordered by `threadId`. Pass the opaque `nextCursor` from one response into the next request. Omit `lifecycleStatuses` to include every durable spawn-edge lifecycle status, or filter with `"open"` and `"closed"`. Persisted `nickname`, `role`, and `model` metadata are `null` when unavailable. `ThreadSubagent.lifecycleStatus` is distinct from `ThreadStatus`: subagent lifecycle is durable spawn-edge state, while thread status describes runtime activity such as `active`, `idle`, `systemError`, or `notLoaded`. Descendants are not returned recursively. @@ -386,7 +386,7 @@ Use `thread/subagents/list` with `capabilities.experimentalApi = true` to page t } } { "id": 21, "result": { "data": [ - { "threadId": "00000000-0000-0000-0000-000000000101", "parentThreadId": "00000000-0000-0000-0000-000000000100", "lifecycleStatus": "open" } + { "threadId": "00000000-0000-0000-0000-000000000101", "parentThreadId": "00000000-0000-0000-0000-000000000100", "lifecycleStatus": "open", "nickname": "atlas", "role": "explorer", "model": "gpt-5.4" } ], "nextCursor": null } } 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 7a31631cbe..39776a8688 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -3898,6 +3898,9 @@ fn thread_subagent_from_edge(edge: codex_state::ThreadSpawnEdge) -> ThreadSubage ThreadSubagentLifecycleStatus::Closed } }, + nickname: edge.agent_nickname, + role: edge.agent_role, + model: edge.model, } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_subagents.rs b/codex-rs/app-server/tests/suite/v2/thread_subagents.rs index e085afbe3a..6317838155 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_subagents.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_subagents.rs @@ -1,6 +1,7 @@ use anyhow::Result; use app_test_support::TestAppServer; use app_test_support::to_response; +use chrono::Utc; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -9,9 +10,12 @@ use codex_app_server_protocol::ThreadSubagentLifecycleStatus; use codex_app_server_protocol::ThreadSubagentsListParams; use codex_app_server_protocol::ThreadSubagentsListResponse; use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; use codex_state::DirectionalThreadSpawnEdgeStatus; use codex_state::StateRuntime; +use codex_state::ThreadMetadataBuilder; use pretty_assertions::assert_eq; +use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -29,6 +33,15 @@ async fn thread_subagents_list_uses_persisted_edges_without_rollouts() -> Result state_db .mark_backfill_complete(/*last_watermark*/ None) .await?; + upsert_subagent_metadata( + &state_db, + codex_home.path(), + open_child_thread_id, + "atlas", + "explorer", + "gpt-5.4", + ) + .await?; state_db .upsert_thread_spawn_edge( parent_thread_id, @@ -67,10 +80,13 @@ async fn thread_subagents_list_uses_persisted_edges_without_rollouts() -> Result assert_ne!(next_cursor, open_child_thread_id.to_string()); assert_eq!( first_page.data, - vec![subagent( + vec![subagent_with_metadata( parent_thread_id, open_child_thread_id, ThreadSubagentLifecycleStatus::Open, + Some("atlas"), + Some("explorer"), + Some("gpt-5.4"), )] ); @@ -105,10 +121,13 @@ async fn thread_subagents_list_uses_persisted_edges_without_rollouts() -> Result ) .await?, ThreadSubagentsListResponse { - data: vec![subagent( + data: vec![subagent_with_metadata( parent_thread_id, open_child_thread_id, ThreadSubagentLifecycleStatus::Open, + Some("atlas"), + Some("explorer"), + Some("gpt-5.4"), )], next_cursor: None, } @@ -201,14 +220,57 @@ fn subagent( parent_thread_id: ThreadId, child_thread_id: ThreadId, lifecycle_status: ThreadSubagentLifecycleStatus, +) -> ThreadSubagent { + subagent_with_metadata( + parent_thread_id, + child_thread_id, + lifecycle_status, + /*nickname*/ None, + /*role*/ None, + /*model*/ None, + ) +} + +fn subagent_with_metadata( + parent_thread_id: ThreadId, + child_thread_id: ThreadId, + lifecycle_status: ThreadSubagentLifecycleStatus, + nickname: Option<&str>, + role: Option<&str>, + model: Option<&str>, ) -> ThreadSubagent { ThreadSubagent { thread_id: child_thread_id.to_string(), parent_thread_id: parent_thread_id.to_string(), lifecycle_status, + nickname: nickname.map(str::to_string), + role: role.map(str::to_string), + model: model.map(str::to_string), } } +async fn upsert_subagent_metadata( + state_db: &StateRuntime, + codex_home: &Path, + thread_id: ThreadId, + nickname: &str, + role: &str, + model: &str, +) -> Result<()> { + let mut builder = ThreadMetadataBuilder::new( + thread_id, + codex_home.join(format!("{thread_id}.jsonl")), + Utc::now(), + SessionSource::Cli, + ); + builder.agent_nickname = Some(nickname.to_string()); + builder.agent_role = Some(role.to_string()); + let mut metadata = builder.build("mock_provider"); + metadata.model = Some(model.to_string()); + state_db.upsert_thread(&metadata).await?; + Ok(()) +} + fn list_params(parent_thread_id: ThreadId) -> ThreadSubagentsListParams { ThreadSubagentsListParams { parent_thread_id: parent_thread_id.to_string(), diff --git a/codex-rs/state/src/model/graph.rs b/codex-rs/state/src/model/graph.rs index ee72831b48..95067b0edb 100644 --- a/codex-rs/state/src/model/graph.rs +++ b/codex-rs/state/src/model/graph.rs @@ -11,12 +11,15 @@ pub enum DirectionalThreadSpawnEdgeStatus { Closed, } -/// Persisted directional parent-child edge for a spawned thread. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Persisted directional parent-child edge with optional joined child metadata. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ThreadSpawnEdge { pub parent_thread_id: ThreadId, pub child_thread_id: ThreadId, pub status: DirectionalThreadSpawnEdgeStatus, + pub agent_nickname: Option, + pub agent_role: Option, + pub model: Option, } /// Keyset-paginated direct children from the persisted thread spawn graph. diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 2e3d110107..87c5412177 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -134,16 +134,16 @@ ON CONFLICT(child_thread_id) DO UPDATE SET } let mut builder = QueryBuilder::::new( - "SELECT parent_thread_id, child_thread_id, status FROM thread_spawn_edges WHERE parent_thread_id = ", + "SELECT edge.parent_thread_id, edge.child_thread_id, edge.status, threads.agent_nickname, threads.agent_role, threads.model FROM thread_spawn_edges AS edge LEFT JOIN threads ON threads.id = edge.child_thread_id WHERE edge.parent_thread_id = ", ); builder.push_bind(parent_thread_id.to_string()); if let Some(cursor) = cursor { builder - .push(" AND child_thread_id > ") + .push(" AND edge.child_thread_id > ") .push_bind(cursor.to_string()); } if let Some(statuses) = statuses { - builder.push(" AND status IN ("); + builder.push(" AND edge.status IN ("); let mut separated = builder.separated(", "); for status in statuses { separated.push_bind(status.as_ref()); @@ -151,7 +151,7 @@ ON CONFLICT(child_thread_id) DO UPDATE SET separated.push_unseparated(")"); } builder - .push(" ORDER BY child_thread_id LIMIT ") + .push(" ORDER BY edge.child_thread_id LIMIT ") .push_bind(page_size.saturating_add(1) as i64); let rows = builder.build().fetch_all(self.pool.as_ref()).await?; @@ -1148,6 +1148,9 @@ fn thread_spawn_edge_from_row( status: row .try_get::("status")? .parse::()?, + agent_nickname: row.try_get("agent_nickname")?, + agent_role: row.try_get("agent_role")?, + model: row.try_get("model")?, }) } @@ -2104,7 +2107,7 @@ INSERT INTO thread_spawn_edges ( #[tokio::test] async fn thread_spawn_children_page_uses_child_id_keyset() { let codex_home = unique_temp_dir(); - let runtime = StateRuntime::init(codex_home, "test-provider".to_string()) + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) .await .expect("state db should initialize"); let parent_thread_id = @@ -2115,6 +2118,15 @@ INSERT INTO thread_spawn_edges ( ThreadId::from_string("00000000-0000-0000-0000-000000000922").expect("valid thread id"); let child_c_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000923").expect("valid thread id"); + let mut child_a_metadata = + test_thread_metadata(&codex_home, child_a_thread_id, codex_home.clone()); + child_a_metadata.agent_nickname = Some("atlas".to_string()); + child_a_metadata.agent_role = Some("explorer".to_string()); + child_a_metadata.model = Some("gpt-5.4".to_string()); + runtime + .upsert_thread(&child_a_metadata) + .await + .expect("child metadata insert should succeed"); for (child_thread_id, status) in [ (child_a_thread_id, DirectionalThreadSpawnEdgeStatus::Open), (child_b_thread_id, DirectionalThreadSpawnEdgeStatus::Closed), @@ -2142,11 +2154,17 @@ INSERT INTO thread_spawn_edges ( parent_thread_id, child_thread_id: child_a_thread_id, status: DirectionalThreadSpawnEdgeStatus::Open, + agent_nickname: Some("atlas".to_string()), + agent_role: Some("explorer".to_string()), + model: Some("gpt-5.4".to_string()), }, crate::ThreadSpawnEdge { parent_thread_id, child_thread_id: child_b_thread_id, status: DirectionalThreadSpawnEdgeStatus::Closed, + agent_nickname: None, + agent_role: None, + model: None, }, ], next_cursor: Some(child_b_thread_id), @@ -2169,6 +2187,9 @@ INSERT INTO thread_spawn_edges ( parent_thread_id, child_thread_id: child_c_thread_id, status: DirectionalThreadSpawnEdgeStatus::Open, + agent_nickname: None, + agent_role: None, + model: None, }], next_cursor: None, }