mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Add loaded thread summaries for subagent backfill
This commit is contained in:
+5
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+45
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+45
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+43
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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, };
|
||||
|
||||
+6
@@ -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")]
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user