mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
feat(app-server): include persisted subagent metadata
This commit is contained in:
+18
@@ -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"
|
||||
}
|
||||
|
||||
+18
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user