feat(app-server): include persisted subagent metadata

This commit is contained in:
Brent Traut
2026-06-04 14:23:32 -07:00
parent 448d1372c7
commit 5fb7a44ecf
9 changed files with 140 additions and 12 deletions
@@ -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"
}
@@ -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"
}
@@ -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, };
@@ -1143,6 +1143,9 @@ pub struct ThreadSubagent {
pub thread_id: String,
pub parent_thread_id: String,
pub lifecycle_status: ThreadSubagentLifecycleStatus,
pub nickname: Option<String>,
pub role: Option<String>,
pub model: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
+2 -2
View File
@@ -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
} }
@@ -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,
}
}
@@ -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(),
+5 -2
View File
@@ -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<String>,
pub agent_role: Option<String>,
pub model: Option<String>,
}
/// Keyset-paginated direct children from the persisted thread spawn graph.
+26 -5
View File
@@ -134,16 +134,16 @@ ON CONFLICT(child_thread_id) DO UPDATE SET
}
let mut builder = QueryBuilder::<Sqlite>::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::<String, _>("status")?
.parse::<crate::DirectionalThreadSpawnEdgeStatus>()?,
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,
}