feat: add nick name to sub-agents (#12320)

Adding random nick name to sub-agents. Used for UX

At the same time, also storing and wiring the role of the sub-agent
This commit is contained in:
jif-oai 2026-02-20 14:39:49 +00:00 committed by GitHub
parent 03ff04cd65
commit 0f9eed3a6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1125 additions and 109 deletions

View file

@ -6377,6 +6377,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -6524,6 +6538,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -9431,6 +9431,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -15092,6 +15106,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -15218,6 +15246,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -123,6 +123,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"

View file

@ -123,6 +123,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"

View file

@ -716,6 +716,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -777,6 +791,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -550,6 +564,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -550,6 +564,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -716,6 +716,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -777,6 +791,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -550,6 +564,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -716,6 +716,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -777,6 +791,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -550,6 +564,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@ -550,6 +564,20 @@
},
"Thread": {
"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"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"

View file

@ -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 { ThreadId } from "./ThreadId";
export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, } } | "memory_consolidation" | { "other": string };
export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, agent_nickname: string | null, agent_role: string | null, } } | "memory_consolidation" | { "other": string };

View file

@ -43,6 +43,14 @@ cliVersion: string,
* Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
*/
source: SessionSource,
/**
* 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,
/**
* Optional Git metadata captured when the thread was created.
*/

View file

@ -2206,6 +2206,10 @@ pub struct Thread {
pub cli_version: String,
/// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
pub source: SessionSource,
/// 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>,
/// Optional Git metadata captured when the thread was created.
pub git_info: Option<GitInfo>,
/// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read`

View file

@ -224,6 +224,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default).
- `cwd` — restrict results to threads whose session cwd exactly matches this path.
- Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
Example:
@ -235,7 +236,7 @@ Example:
} }
{ "id": 20, "result": {
"data": [
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" } },
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" },
{ "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } }
],
"nextCursor": "opaque-token-or-null"
@ -271,7 +272,7 @@ When `nextCursor` is `null`, youve reached the final page.
### Example: Read a thread
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`.
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
```json
{ "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } }

View file

@ -6658,6 +6658,8 @@ async fn read_summary_from_state_db_context_by_thread_id(
metadata.cwd,
metadata.cli_version,
metadata.source,
metadata.agent_nickname,
metadata.agent_role,
metadata.git_sha,
metadata.git_branch,
metadata.git_origin_url,
@ -6678,9 +6680,12 @@ async fn summary_from_thread_list_item(
.unwrap_or_else(|| fallback_provider.to_string());
let cwd = it.cwd?;
let cli_version = it.cli_version.unwrap_or_default();
let source = it
.source
.unwrap_or(codex_protocol::protocol::SessionSource::Unknown);
let source = with_thread_spawn_agent_metadata(
it.source
.unwrap_or(codex_protocol::protocol::SessionSource::Unknown),
it.agent_nickname.clone(),
it.agent_role.clone(),
);
return Some(ConversationSummary {
conversation_id: thread_id,
path: it.path,
@ -6735,13 +6740,17 @@ fn summary_from_state_db_metadata(
cwd: PathBuf,
cli_version: String,
source: String,
agent_nickname: Option<String>,
agent_role: Option<String>,
git_sha: Option<String>,
git_branch: Option<String>,
git_origin_url: Option<String>,
) -> ConversationSummary {
let preview = first_user_message.unwrap_or_default();
let source = serde_json::from_value(serde_json::Value::String(source))
let source = serde_json::from_str(&source)
.or_else(|_| serde_json::from_value(serde_json::Value::String(source.clone())))
.unwrap_or(codex_protocol::protocol::SessionSource::Unknown);
let source = with_thread_spawn_agent_metadata(source, agent_nickname, agent_role);
let git_info = if git_sha.is_none() && git_branch.is_none() && git_origin_url.is_none() {
None
} else {
@ -6901,6 +6910,35 @@ fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo {
}
}
fn with_thread_spawn_agent_metadata(
source: codex_protocol::protocol::SessionSource,
agent_nickname: Option<String>,
agent_role: Option<String>,
) -> codex_protocol::protocol::SessionSource {
if agent_nickname.is_none() && agent_role.is_none() {
return source;
}
match source {
codex_protocol::protocol::SessionSource::SubAgent(
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_nickname: existing_agent_nickname,
agent_role: existing_agent_role,
},
) => codex_protocol::protocol::SessionSource::SubAgent(
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_nickname: agent_nickname.or(existing_agent_nickname),
agent_role: agent_role.or(existing_agent_role),
},
),
_ => source,
}
}
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
timestamp.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
@ -6937,6 +6975,8 @@ fn build_thread_from_snapshot(
path,
cwd: config_snapshot.cwd.clone(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
agent_nickname: config_snapshot.session_source.get_nickname(),
agent_role: config_snapshot.session_source.get_agent_role(),
source: config_snapshot.session_source.clone().into(),
git_info: None,
turns: Vec::new(),
@ -6975,6 +7015,8 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
path: Some(path),
cwd,
cli_version,
agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(),
source: source.into(),
git_info,
turns: Vec::new(),
@ -6986,6 +7028,7 @@ mod tests {
use super::*;
use anyhow::Result;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
@ -7131,6 +7174,41 @@ mod tests {
Ok(())
}
#[test]
fn summary_from_state_db_metadata_preserves_agent_nickname() -> Result<()> {
let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?;
let source =
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?,
depth: 1,
agent_nickname: None,
agent_role: None,
}))?;
let summary = summary_from_state_db_metadata(
conversation_id,
PathBuf::from("/tmp/rollout.jsonl"),
Some("hi".to_string()),
"2025-09-05T16:53:11Z".to_string(),
"2025-09-05T16:53:12Z".to_string(),
"test-provider".to_string(),
PathBuf::from("/"),
"0.0.0".to_string(),
source,
Some("atlas".to_string()),
Some("explorer".to_string()),
None,
None,
None,
);
let thread = summary_to_thread(summary);
assert_eq!(thread.agent_nickname, Some("atlas".to_string()));
assert_eq!(thread.agent_role, Some("explorer".to_string()));
Ok(())
}
#[tokio::test]
async fn removing_one_listener_does_not_cancel_other_subscriptions_for_same_thread()
-> Result<()> {

View file

@ -133,6 +133,8 @@ mod tests {
let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: None,
});
assert!(source_kind_matches(

View file

@ -622,6 +622,8 @@ mod tests {
path: None,
cwd: PathBuf::from("/tmp"),
cli_version: "test".to_string(),
agent_nickname: None,
agent_role: None,
source,
git_info: None,
turns: Vec::new(),

View file

@ -79,6 +79,8 @@ pub fn create_fake_rollout_with_source(
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source,
agent_nickname: None,
agent_role: None,
model_provider: model_provider.map(str::to_string),
base_instructions: None,
dynamic_tools: None,
@ -158,6 +160,8 @@ pub fn create_fake_rollout_with_text_elements(
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
model_provider: model_provider.map(str::to_string),
base_instructions: None,
dynamic_tools: None,

View file

@ -582,6 +582,8 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: None,
}),
)?;
@ -643,6 +645,8 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> {
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: None,
}),
)?;
let other_id = create_fake_rollout_with_source(

View file

@ -0,0 +1,100 @@
Atlas
Nova
Orion
Iris
Milo
Juno
Mars
Vesta
Luna
Sol
Eros
Nyx
Zeus
Hera
Ares
Ajax
Echo
Leto
Rhea
Gaia
Hades
Apollo
Pallas
Circe
Nereus
Triton
Selene
Helios
Castor
Pollux
Astra
Aura
Thalia
Clio
Erato
Euterpe
Urania
Calliope
Minos
Linus
Cato
Brutus
Seneca
Ovid
Virgil
Horace
Remus
Romulus
Titus
Lucian
Felix
Maximus
Octavia
Claudia
Livia
Aelia
Aurel
Cassia
Sabina
Flavia
Numa
Ceres
Diana
Venus
Pluto
Pan
Ramius
Aether
Chaos
Logos
Ethos
Tethys
Chiron
Talos
Icarus
Daedalus
Hyacinth
Adonis
Perseus
Theseus
Argos
Lemnos
Delos
Sparta
Attica
Corinth
Thebes
Ephyra
Achaea
Cyrene
Sidon
Tyre
Ilium
Etrus
Vercos
Aurex
Novian
Helion
Casson
Aurelix

View file

@ -3,7 +3,9 @@ use crate::agent::guards::Guards;
use crate::agent::status::is_final;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::find_thread_path_by_id_str;
use crate::session_prefix::format_subagent_notification_message;
use crate::state_db;
use crate::thread_manager::ThreadManagerState;
use codex_protocol::ThreadId;
use codex_protocol::protocol::Op;
@ -11,11 +13,20 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::user_input::UserInput;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Weak;
use tokio::sync::watch;
const AGENT_NAMES: &str = include_str!("agent_names.txt");
fn agent_nickname_list() -> Vec<&'static str> {
AGENT_NAMES
.lines()
.map(str::trim)
.filter(|name| !name.is_empty())
.collect()
}
/// Control-plane handle for multi-agent operations.
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
/// spawn new agents and the inter-agent communication layer.
@ -48,7 +59,24 @@ impl AgentControl {
session_source: Option<SessionSource>,
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let session_source = match session_source {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_role,
..
})) => {
let agent_nickname = reservation.reserve_agent_nickname(&agent_nickname_list())?;
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_nickname: Some(agent_nickname),
agent_role,
}))
}
other => other,
};
let notification_source = session_source.clone();
// The same `AgentControl` is sent to spawn the thread.
@ -77,12 +105,51 @@ impl AgentControl {
pub(crate) async fn resume_agent_from_rollout(
&self,
config: crate::config::Config,
rollout_path: PathBuf,
thread_id: ThreadId,
session_source: SessionSource,
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let session_source = match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
..
}) => {
// Collab resume callers rebuild a placeholder ThreadSpawn source. Rehydrate the
// stored nickname/role from sqlite when available; otherwise leave both unset.
let (resumed_agent_nickname, resumed_agent_role) =
if let Some(state_db_ctx) = state_db::get_state_db(&config, None).await {
match state_db_ctx.get_thread(thread_id).await {
Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role),
Ok(None) | Err(_) => (None, None),
}
} else {
(None, None)
};
let reserved_agent_nickname = resumed_agent_nickname
.as_deref()
.map(|agent_nickname| {
reservation.reserve_agent_nickname_with_preference(
&agent_nickname_list(),
Some(agent_nickname),
)
})
.transpose()?;
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_nickname: reserved_agent_nickname,
agent_role: resumed_agent_role,
})
}
other => other,
};
let notification_source = session_source.clone();
let rollout_path =
find_thread_path_by_id_str(config.codex_home.as_path(), &thread_id.to_string())
.await?
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?;
let resumed_thread = state
.resume_thread_from_rollout_with_source(
@ -234,6 +301,8 @@ mod tests {
use crate::agent::agent_status_from_event;
use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::config_loader::LoaderOverrides;
use crate::features::Feature;
use crate::session_prefix::SUBAGENT_NOTIFICATION_OPEN_TAG;
use assert_matches::assert_matches;
use codex_protocol::config_types::ModeKind;
@ -261,6 +330,12 @@ mod tests {
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.cli_overrides(cli_overrides)
.loader_overrides(LoaderOverrides {
#[cfg(target_os = "macos")]
managed_preferences_base64: Some(String::new()),
macos_managed_config_requirements_base64: Some(String::new()),
..LoaderOverrides::default()
})
.build()
.await
.expect("load default test config");
@ -441,11 +516,7 @@ mod tests {
let control = AgentControl::default();
let (_home, config) = test_config().await;
let err = control
.resume_agent_from_rollout(
config,
PathBuf::from("/tmp/missing-rollout.jsonl"),
SessionSource::Exec,
)
.resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec)
.await
.expect_err("resume_agent should fail without a manager");
assert_eq!(
@ -717,12 +788,6 @@ mod tests {
.spawn_agent(config.clone(), text_input("hello"), None)
.await
.expect("spawn_agent should succeed");
let rollout_path = manager
.get_thread(resumable_id)
.await
.expect("thread should exist")
.rollout_path()
.expect("rollout path should exist");
let _ = control
.shutdown_agent(resumable_id)
.await
@ -734,7 +799,7 @@ mod tests {
.expect("spawn_agent should succeed for active slot");
let err = control
.resume_agent_from_rollout(config, rollout_path, SessionSource::Exec)
.resume_agent_from_rollout(config, resumable_id, SessionSource::Exec)
.await
.expect_err("resume should respect max threads");
let CodexErr::AgentLimitReached {
@ -766,9 +831,8 @@ mod tests {
);
let control = manager.agent_control();
let missing_rollout = config.codex_home.join("sessions/missing-rollout.jsonl");
let _ = control
.resume_agent_from_rollout(config.clone(), missing_rollout, SessionSource::Exec)
.resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec)
.await
.expect_err("resume should fail for missing rollout path");
@ -795,6 +859,8 @@ mod tests {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
@ -812,4 +878,176 @@ mod tests {
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
}
#[tokio::test]
async fn spawn_thread_subagent_gets_random_nickname_in_session_source() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let snapshot = child_thread.config_snapshot().await;
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: seen_parent_thread_id,
depth,
agent_nickname,
agent_role,
}) = snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
};
assert_eq!(seen_parent_thread_id, parent_thread_id);
assert_eq!(depth, 1);
assert!(agent_nickname.is_some());
assert_eq!(agent_role, Some("explorer".to_string()));
}
#[tokio::test]
async fn resume_thread_subagent_restores_stored_nickname_and_role() {
let (home, mut config) = test_config().await;
config.features.enable(Feature::Sqlite);
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let control = manager.agent_control();
let harness = AgentControlHarness {
_home: home,
config,
manager,
control,
};
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let mut status_rx = harness
.control
.subscribe_status(child_thread_id)
.await
.expect("status subscription should succeed");
if matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) {
timeout(Duration::from_secs(5), async {
loop {
status_rx
.changed()
.await
.expect("child status should advance past pending init");
if !matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) {
break;
}
}
})
.await
.expect("child should initialize before shutdown");
}
let original_snapshot = child_thread.config_snapshot().await;
let original_nickname = original_snapshot
.session_source
.get_nickname()
.expect("spawned sub-agent should have a nickname");
let state_db = child_thread
.state_db()
.expect("sqlite state db should be available for nickname resume test");
timeout(Duration::from_secs(5), async {
loop {
if let Ok(Some(metadata)) = state_db.get_thread(child_thread_id).await
&& metadata.agent_nickname.is_some()
&& metadata.agent_role.as_deref() == Some("explorer")
{
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("child thread metadata should be persisted to sqlite before shutdown");
let _ = harness
.control
.shutdown_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let resumed_thread_id = harness
.control
.resume_agent_from_rollout(
harness.config.clone(),
child_thread_id,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: None,
}),
)
.await
.expect("resume should succeed");
assert_eq!(resumed_thread_id, child_thread_id);
let resumed_snapshot = harness
.manager
.get_thread(resumed_thread_id)
.await
.expect("resumed child thread should exist")
.config_snapshot()
.await;
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: resumed_parent_thread_id,
depth: resumed_depth,
agent_nickname: resumed_nickname,
agent_role: resumed_role,
}) = resumed_snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
};
assert_eq!(resumed_parent_thread_id, parent_thread_id);
assert_eq!(resumed_depth, 1);
assert_eq!(resumed_nickname, Some(original_nickname));
assert_eq!(resumed_role, Some("explorer".to_string()));
let _ = harness
.control
.shutdown_agent(resumed_thread_id)
.await
.expect("resumed child shutdown should submit");
}
}

View file

@ -3,6 +3,8 @@ use crate::error::Result;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use rand::prelude::IndexedRandom;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Mutex;
@ -17,10 +19,18 @@ use std::sync::atomic::Ordering;
/// is).
#[derive(Default)]
pub(crate) struct Guards {
threads_set: Mutex<HashSet<ThreadId>>,
active_agents: Mutex<ActiveAgents>,
total_count: AtomicUsize,
}
#[derive(Default)]
struct ActiveAgents {
threads_set: HashSet<ThreadId>,
thread_agent_nicknames: HashMap<ThreadId, String>,
used_agent_nicknames: HashSet<String>,
nickname_reset_count: usize,
}
fn session_depth(session_source: &SessionSource) -> i32 {
match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth,
@ -52,28 +62,69 @@ impl Guards {
Ok(SpawnReservation {
state: Arc::clone(self),
active: true,
reserved_agent_nickname: None,
})
}
pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) {
let removed = {
let mut threads = self
.threads_set
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
threads.remove(&thread_id)
let removed = active_agents.threads_set.remove(&thread_id);
active_agents.thread_agent_nicknames.remove(&thread_id);
removed
};
if removed {
self.total_count.fetch_sub(1, Ordering::AcqRel);
}
}
fn register_spawned_thread(&self, thread_id: ThreadId) {
let mut threads = self
.threads_set
fn register_spawned_thread(&self, thread_id: ThreadId, agent_nickname: Option<String>) {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
threads.insert(thread_id);
active_agents.threads_set.insert(thread_id);
if let Some(agent_nickname) = agent_nickname {
active_agents
.used_agent_nicknames
.insert(agent_nickname.clone());
active_agents
.thread_agent_nicknames
.insert(thread_id, agent_nickname);
}
}
fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option<String> {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let agent_nickname = if let Some(preferred) = preferred {
preferred.to_string()
} else {
if names.is_empty() {
return None;
}
let available_names: Vec<&str> = names
.iter()
.copied()
.filter(|name| !active_agents.used_agent_nicknames.contains(*name))
.collect();
if let Some(name) = available_names.choose(&mut rand::rng()) {
(*name).to_string()
} else {
active_agents.used_agent_nicknames.clear();
active_agents.nickname_reset_count += 1;
names.choose(&mut rand::rng())?.to_string()
}
};
active_agents
.used_agent_nicknames
.insert(agent_nickname.clone());
Some(agent_nickname)
}
fn try_increment_spawned(&self, max_threads: usize) -> bool {
@ -98,11 +149,41 @@ impl Guards {
pub(crate) struct SpawnReservation {
state: Arc<Guards>,
active: bool,
reserved_agent_nickname: Option<String>,
}
impl SpawnReservation {
pub(crate) fn commit(mut self, thread_id: ThreadId) {
self.state.register_spawned_thread(thread_id);
pub(crate) fn reserve_agent_nickname(&mut self, names: &[&str]) -> Result<String> {
self.reserve_agent_nickname_with_preference(names, None)
}
pub(crate) fn reserve_agent_nickname_with_preference(
&mut self,
names: &[&str],
preferred: Option<&str>,
) -> Result<String> {
let agent_nickname = self
.state
.reserve_agent_nickname(names, preferred)
.ok_or_else(|| {
CodexErr::UnsupportedOperation("no available agent nicknames".to_string())
})?;
self.reserved_agent_nickname = Some(agent_nickname.clone());
Ok(agent_nickname)
}
pub(crate) fn commit(self, thread_id: ThreadId) {
self.commit_with_agent_nickname(thread_id, None);
}
pub(crate) fn commit_with_agent_nickname(
mut self,
thread_id: ThreadId,
agent_nickname: Option<String>,
) {
let agent_nickname = self.reserved_agent_nickname.take().or(agent_nickname);
self.state
.register_spawned_thread(thread_id, agent_nickname);
self.active = false;
}
}
@ -130,6 +211,8 @@ mod tests {
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_nickname: None,
agent_role: None,
});
let child_depth = next_thread_spawn_depth(&session_source);
assert_eq!(child_depth, 2);
@ -232,4 +315,83 @@ mod tests {
.expect("slot released after second thread removal");
drop(reservation);
}
#[test]
fn failed_spawn_keeps_nickname_marked_used() {
let guards = Arc::new(Guards::default());
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha"])
.expect("reserve agent name");
assert_eq!(agent_nickname, "alpha");
drop(reservation);
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha", "beta"])
.expect("unused name should still be preferred");
assert_eq!(agent_nickname, "beta");
}
#[test]
fn agent_nickname_resets_used_pool_when_exhausted() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha"])
.expect("name should be reused after pool reset");
assert_eq!(second_name, "alpha");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn released_nickname_stays_used_until_pool_reset() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
guards.release_spawned_thread(first_id);
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha", "beta"])
.expect("released name should still be marked used");
assert_eq!(second_name, "beta");
let second_id = ThreadId::new();
second.commit(second_id);
guards.release_spawned_thread(second_id);
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
let third_name = third
.reserve_agent_nickname(&["alpha", "beta"])
.expect("pool reset should permit a duplicate");
assert!(third_name == "alpha" || third_name == "beta");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
}

View file

@ -172,6 +172,8 @@ mod tests {
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: None,

View file

@ -39,7 +39,7 @@ pub struct ThreadsPage {
}
/// Summary information for a thread rollout file.
#[derive(Debug, PartialEq)]
#[derive(Debug, PartialEq, Default)]
pub struct ThreadItem {
/// Absolute path to the rollout file.
pub path: PathBuf,
@ -57,6 +57,10 @@ pub struct ThreadItem {
pub git_origin_url: Option<String>,
/// Session source from session metadata.
pub source: Option<SessionSource>,
/// Random unique nickname from session metadata for AgentControl-spawned sub-agents.
pub agent_nickname: Option<String>,
/// Role (agent_role) from session metadata for AgentControl-spawned sub-agents.
pub agent_role: Option<String>,
/// Model provider from session metadata.
pub model_provider: Option<String>,
/// CLI version from session metadata.
@ -87,6 +91,8 @@ struct HeadTailSummary {
git_sha: Option<String>,
git_origin_url: Option<String>,
source: Option<SessionSource>,
agent_nickname: Option<String>,
agent_role: Option<String>,
model_provider: Option<String>,
cli_version: Option<String>,
created_at: Option<String>,
@ -715,6 +721,8 @@ async fn build_thread_item(
git_sha,
git_origin_url,
source,
agent_nickname,
agent_role,
model_provider,
cli_version,
created_at,
@ -733,6 +741,8 @@ async fn build_thread_item(
git_sha,
git_origin_url,
source,
agent_nickname,
agent_role,
model_provider,
cli_version,
created_at,
@ -1017,6 +1027,8 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
RolloutItem::SessionMeta(session_meta_line) => {
if !summary.saw_session_meta {
summary.source = Some(session_meta_line.meta.source.clone());
summary.agent_nickname = session_meta_line.meta.agent_nickname.clone();
summary.agent_role = session_meta_line.meta.agent_role.clone();
summary.model_provider = session_meta_line.meta.model_provider.clone();
summary.thread_id = Some(session_meta_line.meta.id);
summary.cwd = Some(session_meta_line.meta.cwd.clone());

View file

@ -47,6 +47,8 @@ pub(crate) fn builder_from_session_meta(
session_meta.meta.source.clone(),
);
builder.model_provider = session_meta.meta.model_provider.clone();
builder.agent_nickname = session_meta.meta.agent_nickname.clone();
builder.agent_role = session_meta.meta.agent_role.clone();
builder.cwd = session_meta.meta.cwd.clone();
builder.cli_version = Some(session_meta.meta.cli_version.clone());
builder.sandbox_policy = SandboxPolicy::new_read_only_policy();
@ -512,6 +514,8 @@ mod tests {
originator: "cli".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::default(),
agent_nickname: None,
agent_role: None,
model_provider: Some("openai".to_string()),
base_instructions: None,
dynamic_tools: None,
@ -660,6 +664,8 @@ mod tests {
originator: "cli".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::default(),
agent_nickname: None,
agent_role: None,
model_provider: Some("test-provider".to_string()),
base_instructions: None,
dynamic_tools: None,

View file

@ -391,6 +391,8 @@ impl RolloutRecorder {
cwd: config.cwd.clone(),
originator: originator().value,
cli_version: env!("CARGO_PKG_VERSION").to_string(),
agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(),
source,
model_provider: Some(config.model_provider_id.clone()),
base_instructions: Some(base_instructions),
@ -929,9 +931,12 @@ impl From<codex_state::ThreadsPage> for ThreadsPage {
git_sha: item.git_sha,
git_origin_url: item.git_origin_url,
source: Some(
serde_json::from_value(Value::String(item.source))
serde_json::from_str(item.source.as_str())
.or_else(|_| serde_json::from_value(Value::String(item.source)))
.unwrap_or(SessionSource::Unknown),
),
agent_nickname: item.agent_nickname,
agent_role: item.agent_role,
model_provider: Some(item.model_provider),
cli_version: Some(item.cli_version),
created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)),

View file

@ -560,6 +560,8 @@ async fn test_list_conversations_latest_first() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-01-03T12-00-00".into()),
@ -574,6 +576,8 @@ async fn test_list_conversations_latest_first() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-01-02T12-00-00".into()),
@ -588,6 +592,8 @@ async fn test_list_conversations_latest_first() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-01-01T12-00-00".into()),
@ -695,6 +701,8 @@ async fn test_pagination_cursor() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-05T09-00-00".into()),
@ -709,6 +717,8 @@ async fn test_pagination_cursor() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-04T09-00-00".into()),
@ -759,6 +769,8 @@ async fn test_pagination_cursor() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-03T09-00-00".into()),
@ -773,6 +785,8 @@ async fn test_pagination_cursor() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-02T09-00-00".into()),
@ -814,6 +828,8 @@ async fn test_pagination_cursor() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-01T09-00-00".into()),
@ -894,6 +910,8 @@ async fn test_get_thread_contents() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some(ts.into()),
@ -1086,6 +1104,8 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> {
originator: "test_originator".into(),
cli_version: "test_version".into(),
source: SessionSource::VSCode,
agent_nickname: None,
agent_role: None,
model_provider: Some("test-provider".into()),
base_instructions: None,
dynamic_tools: None,
@ -1203,6 +1223,8 @@ async fn test_stable_ordering_same_second_pagination() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some(ts.to_string()),
@ -1217,6 +1239,8 @@ async fn test_stable_ordering_same_second_pagination() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some(ts.to_string()),
@ -1258,6 +1282,8 @@ async fn test_stable_ordering_same_second_pagination() {
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
agent_nickname: None,
agent_role: None,
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some(ts.to_string()),

View file

@ -156,7 +156,11 @@ mod spawn {
.spawn_agent(
config,
input_items,
Some(thread_spawn_source(session.conversation_id, child_depth)),
Some(thread_spawn_source(
session.conversation_id,
child_depth,
role_name,
)),
)
.await
.map_err(collab_spawn_error);
@ -284,7 +288,6 @@ mod send_input {
mod resume_agent {
use super::*;
use crate::agent::next_thread_spawn_depth;
use crate::rollout::find_thread_path_by_id_str;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
@ -331,15 +334,7 @@ mod resume_agent {
.await;
let error = if matches!(status, AgentStatus::NotFound) {
// If the thread is no longer active, attempt to restore it from rollout.
match try_resume_closed_agent(
&session,
&turn,
receiver_thread_id,
&args.id,
child_depth,
)
.await
{
match try_resume_closed_agent(&session, &turn, receiver_thread_id, child_depth).await {
Ok(resumed_status) => {
status = resumed_status;
None
@ -388,33 +383,16 @@ mod resume_agent {
session: &Arc<Session>,
turn: &Arc<TurnContext>,
receiver_thread_id: ThreadId,
receiver_id: &str,
child_depth: i32,
) -> Result<AgentStatus, FunctionCallError> {
let rollout_path = find_thread_path_by_id_str(
turn.config.codex_home.as_path(),
receiver_id,
)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"tool failed: failed to locate rollout for agent {receiver_thread_id}: {err}"
))
})?
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"agent with id {receiver_thread_id} not found"
))
})?;
let config = build_agent_resume_config(turn.as_ref(), child_depth)?;
let resumed_thread_id = session
.services
.agent_control
.resume_agent_from_rollout(
config,
rollout_path,
thread_spawn_source(session.conversation_id, child_depth),
receiver_thread_id,
thread_spawn_source(session.conversation_id, child_depth, None),
)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
@ -733,10 +711,16 @@ fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError {
}
}
fn thread_spawn_source(parent_thread_id: ThreadId, depth: i32) -> SessionSource {
fn thread_spawn_source(
parent_thread_id: ThreadId,
depth: i32,
agent_role: Option<&str>,
) -> SessionSource {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_nickname: None,
agent_role: agent_role.map(str::to_string),
})
}
@ -1068,6 +1052,8 @@ mod tests {
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: DEFAULT_AGENT_MAX_DEPTH,
agent_nickname: None,
agent_role: None,
});
let invocation = invocation(
@ -1104,6 +1090,8 @@ mod tests {
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: DEFAULT_AGENT_MAX_DEPTH,
agent_nickname: None,
agent_role: None,
});
let invocation = invocation(
@ -1487,6 +1475,8 @@ mod tests {
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: DEFAULT_AGENT_MAX_DEPTH,
agent_nickname: None,
agent_role: None,
});
let invocation = invocation(

View file

@ -66,6 +66,8 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: None,
@ -107,6 +109,8 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: None,

View file

@ -123,6 +123,8 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
originator: "test".to_string(),
cli_version: "test".to_string(),
source: SessionSource::default(),
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: Some(dynamic_tools_for_hook),

View file

@ -1861,6 +1861,10 @@ pub enum SubAgentSource {
ThreadSpawn {
parent_thread_id: ThreadId,
depth: i32,
#[serde(default)]
agent_nickname: Option<String>,
#[serde(default, alias = "agent_type")]
agent_role: Option<String>,
},
MemoryConsolidation,
Other(String),
@ -1879,6 +1883,26 @@ impl fmt::Display for SessionSource {
}
}
impl SessionSource {
pub fn get_nickname(&self) -> Option<String> {
match self {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) => {
agent_nickname.clone()
}
_ => None,
}
}
pub fn get_agent_role(&self) -> Option<String> {
match self {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => {
agent_role.clone()
}
_ => None,
}
}
}
impl fmt::Display for SubAgentSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@ -1888,6 +1912,7 @@ impl fmt::Display for SubAgentSource {
SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
..
} => {
write!(f, "thread_spawn_{parent_thread_id}_d{depth}")
}
@ -1912,6 +1937,12 @@ pub struct SessionMeta {
pub cli_version: String,
#[serde(default)]
pub source: SessionSource,
/// Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_nickname: Option<String>,
/// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
#[serde(default, alias = "agent_type", skip_serializing_if = "Option::is_none")]
pub agent_role: Option<String>,
pub model_provider: Option<String>,
/// base_instructions for the session. This *should* always be present when creating a new session,
/// but may be missing for older sessions. If not present, fall back to rendering the base_instructions
@ -1931,6 +1962,8 @@ impl Default for SessionMeta {
originator: String::new(),
cli_version: String::new(),
source: SessionSource::default(),
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: None,

View file

@ -0,0 +1,2 @@
ALTER TABLE threads ADD COLUMN agent_nickname TEXT;
ALTER TABLE threads ADD COLUMN agent_role TEXT;

View file

@ -37,6 +37,8 @@ fn apply_session_meta_from_item(metadata: &mut ThreadMetadata, meta_line: &Sessi
}
metadata.id = meta_line.meta.id;
metadata.source = enum_to_string(&meta_line.meta.source);
metadata.agent_nickname = meta_line.meta.agent_nickname.clone();
metadata.agent_role = meta_line.meta.agent_role.clone();
if let Some(provider) = meta_line.meta.model_provider.as_deref() {
metadata.model_provider = provider.to_string();
}
@ -216,6 +218,8 @@ mod tests {
created_at,
updated_at: created_at,
source: "cli".to_string(),
agent_nickname: None,
agent_role: None,
model_provider: "openai".to_string(),
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),

View file

@ -62,6 +62,10 @@ pub struct ThreadMetadata {
pub updated_at: DateTime<Utc>,
/// The session source (stringified enum).
pub source: 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>,
/// The model provider identifier.
pub model_provider: String,
/// The working directory for the thread.
@ -101,6 +105,10 @@ pub struct ThreadMetadataBuilder {
pub updated_at: Option<DateTime<Utc>>,
/// The session source.
pub source: SessionSource,
/// Optional random unique nickname assigned to the session.
pub agent_nickname: Option<String>,
/// Optional role (agent_role) assigned to the session.
pub agent_role: Option<String>,
/// The model provider identifier, if known.
pub model_provider: Option<String>,
/// The working directory for the thread.
@ -135,6 +143,8 @@ impl ThreadMetadataBuilder {
created_at,
updated_at: None,
source,
agent_nickname: None,
agent_role: None,
model_provider: None,
cwd: PathBuf::new(),
cli_version: None,
@ -163,6 +173,8 @@ impl ThreadMetadataBuilder {
created_at,
updated_at,
source,
agent_nickname: self.agent_nickname.clone(),
agent_role: self.agent_role.clone(),
model_provider: self
.model_provider
.clone()
@ -201,6 +213,12 @@ impl ThreadMetadata {
if self.source != other.source {
diffs.push("source");
}
if self.agent_nickname != other.agent_nickname {
diffs.push("agent_nickname");
}
if self.agent_role != other.agent_role {
diffs.push("agent_role");
}
if self.model_provider != other.model_provider {
diffs.push("model_provider");
}
@ -252,6 +270,8 @@ pub(crate) struct ThreadRow {
created_at: i64,
updated_at: i64,
source: String,
agent_nickname: Option<String>,
agent_role: Option<String>,
model_provider: String,
cwd: String,
cli_version: String,
@ -274,6 +294,8 @@ impl ThreadRow {
created_at: row.try_get("created_at")?,
updated_at: row.try_get("updated_at")?,
source: row.try_get("source")?,
agent_nickname: row.try_get("agent_nickname")?,
agent_role: row.try_get("agent_role")?,
model_provider: row.try_get("model_provider")?,
cwd: row.try_get("cwd")?,
cli_version: row.try_get("cli_version")?,
@ -300,6 +322,8 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
created_at,
updated_at,
source,
agent_nickname,
agent_role,
model_provider,
cwd,
cli_version,
@ -319,6 +343,8 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
created_at: epoch_seconds_to_datetime(created_at)?,
updated_at: epoch_seconds_to_datetime(updated_at)?,
source,
agent_nickname,
agent_role,
model_provider,
cwd: PathBuf::from(cwd),
cli_version,

View file

@ -211,6 +211,8 @@ SELECT
created_at,
updated_at,
source,
agent_nickname,
agent_role,
model_provider,
cwd,
cli_version,
@ -310,6 +312,8 @@ SELECT
created_at,
updated_at,
source,
agent_nickname,
agent_role,
model_provider,
cwd,
cli_version,
@ -691,6 +695,8 @@ INSERT INTO threads (
created_at,
updated_at,
source,
agent_nickname,
agent_role,
model_provider,
cwd,
cli_version,
@ -704,12 +710,14 @@ INSERT INTO threads (
git_sha,
git_branch,
git_origin_url
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
rollout_path = excluded.rollout_path,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
source = excluded.source,
agent_nickname = excluded.agent_nickname,
agent_role = excluded.agent_role,
model_provider = excluded.model_provider,
cwd = excluded.cwd,
cli_version = excluded.cli_version,
@ -730,6 +738,8 @@ ON CONFLICT(id) DO UPDATE SET
.bind(datetime_to_epoch_seconds(metadata.created_at))
.bind(datetime_to_epoch_seconds(metadata.updated_at))
.bind(metadata.source.as_str())
.bind(metadata.agent_nickname.as_deref())
.bind(metadata.agent_role.as_deref())
.bind(metadata.model_provider.as_str())
.bind(metadata.cwd.display().to_string())
.bind(metadata.cli_version.as_str())
@ -3092,6 +3102,8 @@ VALUES (?, ?, ?, ?, ?)
created_at: now,
updated_at: now,
source: "cli".to_string(),
agent_nickname: None,
agent_role: None,
model_provider: "test-provider".to_string(),
cwd,
cli_version: "0.0.0".to_string(),

View file

@ -95,6 +95,8 @@ SELECT
created_at,
updated_at,
source,
agent_nickname,
agent_role,
model_provider,
cwd,
cli_version,

View file

@ -1327,17 +1327,10 @@ mod tests {
fn make_item(path: &str, ts: &str, preview: &str) -> ThreadItem {
ThreadItem {
path: PathBuf::from(path),
thread_id: None,
first_user_message: Some(preview.to_string()),
cwd: None,
git_branch: None,
git_sha: None,
git_origin_url: None,
source: None,
model_provider: None,
cli_version: None,
created_at: Some(ts.to_string()),
updated_at: Some(ts.to_string()),
..Default::default()
}
}
@ -1462,17 +1455,10 @@ mod tests {
fn head_to_row_uses_first_user_message() {
let item = ThreadItem {
path: PathBuf::from("/tmp/a.jsonl"),
thread_id: None,
first_user_message: Some("real question".to_string()),
cwd: None,
git_branch: None,
git_sha: None,
git_origin_url: None,
source: None,
model_provider: None,
cli_version: None,
created_at: Some("2025-01-01T00:00:00Z".into()),
updated_at: Some("2025-01-01T00:00:00Z".into()),
..Default::default()
};
let row = head_to_row(&item);
assert_eq!(row.preview, "real question");
@ -1483,31 +1469,17 @@ mod tests {
// Construct two items with different timestamps and real user text.
let a = ThreadItem {
path: PathBuf::from("/tmp/a.jsonl"),
thread_id: None,
first_user_message: Some("A".to_string()),
cwd: None,
git_branch: None,
git_sha: None,
git_origin_url: None,
source: None,
model_provider: None,
cli_version: None,
created_at: Some("2025-01-01T00:00:00Z".into()),
updated_at: Some("2025-01-01T00:00:00Z".into()),
..Default::default()
};
let b = ThreadItem {
path: PathBuf::from("/tmp/b.jsonl"),
thread_id: None,
first_user_message: Some("B".to_string()),
cwd: None,
git_branch: None,
git_sha: None,
git_origin_url: None,
source: None,
model_provider: None,
cli_version: None,
created_at: Some("2025-01-02T00:00:00Z".into()),
updated_at: Some("2025-01-02T00:00:00Z".into()),
..Default::default()
};
let rows = rows_from_items(vec![a, b]);
assert_eq!(rows.len(), 2);
@ -1520,17 +1492,10 @@ mod tests {
fn row_uses_tail_timestamp_for_updated_at() {
let item = ThreadItem {
path: PathBuf::from("/tmp/a.jsonl"),
thread_id: None,
first_user_message: Some("Hello".to_string()),
cwd: None,
git_branch: None,
git_sha: None,
git_origin_url: None,
source: None,
model_provider: None,
cli_version: None,
created_at: Some("2025-01-01T00:00:00Z".into()),
updated_at: Some("2025-01-01T01:00:00Z".into()),
..Default::default()
};
let row = head_to_row(&item);