Files
codex/codex-rs/core/src/agent/control_tests.rs
T
sayan-oai c775dd3c33 Defer environment selection changes until the next turn (#46310)
## Why

Updating environment selections while a turn is running must not redirect its tools or prevent its pending environment setup from completing.

## What changed

- Store future environment selections separately from active environments, and activate them when preparing new work with no task running.
- Keep turn contexts and MCP workspace roots tied to the active environment snapshot. Report saved selections through thread settings and `environment_selections()`.
- Route environment configuration and failure callbacks to matching active or future selections so each can finish setup independently.
- Stop the running task before manual compaction adopts the saved environments.

## Testing

Add regression tests that preserve a running task's working directory and workspace roots, then adopt the saved selection for the next task. Add remote environment coverage showing that future setup can complete while the active turn waits, with each turn receiving its own capability roots.

GitOrigin-RevId: 09a6b90131d2ab2441ce2fe9b0368a3668889f31
2026-09-17 21:47:23 +00:00

4992 lines
176 KiB
Rust

use super::*;
use crate::CodexThread;
use crate::StateDbHandle;
use crate::ThreadManager;
use crate::agent::agent_status_from_event;
use crate::agent::next_thread_spawn_depth;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::config::AgentRoleConfig;
use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::context::ContextualUserFragment;
use crate::context::ManagedDeveloperInstructions;
use crate::context::MultiAgentRoleInstructions;
use crate::context::SubagentNotification;
use crate::init_state_db;
use crate::session::SessionSettingsUpdate;
use crate::thread_manager::StartThreadOptions;
use crate::tools::handlers::multi_agents_common::thread_spawn_source;
use assert_matches::assert_matches;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::Instructions;
use codex_extension_api::LoadInstructionsFuture;
use codex_extension_api::LoadedUserInstructions;
use codex_extension_api::ThreadInstructionsProvider;
use codex_extension_api::empty_extension_registry;
use codex_features::Feature;
use codex_history::CompactedItem;
use codex_history::InitialHistory;
use codex_history::ResumedHistory;
use codex_history::RolloutItem;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::AgentPath;
use codex_protocol::ResponseItemId;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ContentItemKind;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::InternalChatMessageMetadataPassthrough;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
use codex_protocol::protocol::ThreadSettingsSnapshot;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageRecord;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::protocol::TurnStartedEvent;
use codex_thread_store::ArchiveThreadParams;
use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::LocalThreadStoreConfig;
use codex_thread_store::PersistContext;
use codex_thread_store::ThreadStore;
use codex_utils_path_uri::PathUri;
use core_test_support::responses::strip_response_item_ids;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::sleep;
use tokio::time::timeout;
use toml::Value as TomlValue;
async fn test_config_with_cli_overrides(
mut cli_overrides: Vec<(String, TomlValue)>,
) -> (TempDir, Config) {
let home = TempDir::new().expect("create temp dir");
cli_overrides.push((
"model".to_string(),
TomlValue::String("gpt-5.5".to_string()),
));
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(home.path().to_path_buf())
.cli_overrides(cli_overrides)
.build()
.await
.expect("load default test config");
(home, config)
}
async fn test_config() -> (TempDir, Config) {
test_config_with_cli_overrides(Vec::new()).await
}
fn text_input(text: &str) -> Vec<UserInput> {
vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}]
}
fn captured_op_matches(actual: &(ThreadId, Op), expected: &(ThreadId, Op)) -> bool {
if actual.0 != expected.0 {
return false;
}
match (&actual.1, &expected.1) {
(
Op::InterAgentCommunication {
communication: actual,
..
},
Op::InterAgentCommunication {
communication: expected,
..
},
) => actual == expected,
_ => false,
}
}
fn rollout_response_item(item: ResponseItem) -> RolloutItem {
RolloutItem::ResponseItem(item.into())
}
fn user_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
}
fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
phase,
internal_chat_message_metadata_passthrough: None,
}
}
#[test]
fn register_session_root_skips_threads_with_explicit_parent() {
let control = AgentControl::default();
control.register_session_root(ThreadId::new(), Some(ThreadId::new()));
assert_eq!(control.state.agent_id_for_path(&AgentPath::root()), None);
}
fn spawn_agent_call(call_id: &str) -> ResponseItem {
ResponseItem::FunctionCall {
id: None,
name: "spawn_agent".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: call_id.to_string(),
encrypted_function_args: None,
internal_chat_message_metadata_passthrough: None,
}
}
struct AgentControlHarness {
_home: TempDir,
config: Config,
state_db: Option<StateDbHandle>,
manager: ThreadManager,
control: AgentControl,
}
impl AgentControlHarness {
async fn new() -> Self {
let (home, config) = test_config().await;
Self::new_with_config(home, config).await
}
async fn new_with_config(home: TempDir, config: Config) -> Self {
let state_db = init_state_db(&config).await;
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
state_db.clone(),
);
let control = manager.agent_control();
Self {
_home: home,
config,
state_db,
manager,
control,
}
}
async fn start_thread(&self) -> (ThreadId, Arc<CodexThread>) {
let new_thread = self
.manager
.start_thread(StartThreadOptions::new(self.config.clone()))
.await
.expect("start thread");
(new_thread.thread_id, new_thread.thread)
}
async fn start_paginated_thread(&self) -> (ThreadId, Arc<CodexThread>) {
let new_thread = self
.manager
.start_thread(StartThreadOptions {
history_mode: Some(ThreadHistoryMode::Paginated),
environments: Some(Vec::new()),
..StartThreadOptions::new(self.config.clone())
})
.await
.expect("start paginated thread");
(new_thread.thread_id, new_thread.thread)
}
async fn spawn_anonymous_child(
&self,
parent_thread_id: ThreadId,
options: SpawnAgentOptions,
) -> ThreadId {
self.control
.spawn_agent_with_metadata(
self.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
options,
)
.await
.expect("child spawn should succeed")
.thread_id
}
}
async fn persisted_originator(thread: &CodexThread) -> String {
thread.ensure_rollout_materialized().await;
thread
.flush_rollout()
.await
.expect("thread rollout should flush");
let stored_thread = thread
.read_thread(
/*include_archived*/ true, /*include_history*/ true,
)
.await
.expect("thread should be readable");
let history = stored_thread.history.expect("history should be loaded");
history
.items
.iter()
.find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.originator.clone()),
RolloutItem::ResponseItem(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::EventMsg(_)
| RolloutItem::Compacted(_)
| RolloutItem::WorldState(_)
| RolloutItem::RealtimeItem(_)
| RolloutItem::RetainedContext(_)
| RolloutItem::SecurityRiskScore(_)
| RolloutItem::TokenUsageRecord(_)
| RolloutItem::TurnContext(_) => None,
})
.expect("session metadata should be persisted")
}
fn has_subagent_notification<'a>(
history_items: impl IntoIterator<Item = &'a ResponseItem>,
) -> bool {
history_items.into_iter().any(|item| {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
if role != "user" {
return false;
}
content.iter().any(|content_item| match content_item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
SubagentNotification::matches_text(text)
}
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => false,
})
})
}
/// Returns true when any message item contains `needle` in a text span.
fn history_contains_text<'a>(
history_items: impl IntoIterator<Item = &'a ResponseItem>,
needle: &str,
) -> bool {
history_items.into_iter().any(|item| {
let ResponseItem::Message { content, .. } = item else {
return false;
};
content.iter().any(|content_item| match content_item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
text.contains(needle)
}
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => false,
})
})
}
async fn wait_for_recorded_user_message(thread: &CodexThread, needle: &str) {
timeout(Duration::from_secs(5), async {
loop {
let event = thread
.next_event()
.await
.expect("event stream should stay open");
if let EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::UserMessage(item),
..
}) = event.msg
&& item.content.iter().any(
|input| matches!(input, UserInput::Text { text, .. } if text.contains(needle)),
)
{
return;
}
}
})
.await
.expect("timed out waiting for user message recording");
}
fn history_contains_assistant_inter_agent_communication<'a>(
history_items: impl IntoIterator<Item = &'a ResponseItem>,
expected: &InterAgentCommunication,
) -> bool {
history_items.into_iter().any(|item| {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
if role != "assistant" {
return false;
}
content.iter().any(|content_item| match content_item {
ContentItem::OutputText { text } => {
serde_json::from_str::<InterAgentCommunication>(text)
.ok()
.as_ref()
== Some(expected)
}
ContentItem::InputText { .. }
| ContentItem::InputImage { .. }
| ContentItem::InputAudio { .. } => false,
})
})
}
async fn wait_for_subagent_notification(parent_thread: &Arc<CodexThread>) -> bool {
let wait = async {
loop {
let history = parent_thread.session.clone_history().await;
if has_subagent_notification(history.raw_items()) {
return true;
}
sleep(Duration::from_millis(25)).await;
}
};
// CI can take several seconds to schedule the detached completion watcher,
// especially on slower Windows runners.
timeout(Duration::from_secs(10), wait).await.is_ok()
}
async fn persist_thread_for_tree_resume(thread: &Arc<CodexThread>, message: &str) {
// These tests only need a durable resume fixture. Stop the child prompt
// first so this marker records directly instead of waiting behind an
// unrelated active turn.
thread
.session
.abort_all_tasks(TurnAbortReason::Interrupted)
.await;
thread
.inject_response_items(vec![user_message(message)])
.await
.expect("inject thread resume context");
thread
.session
.ensure_rollout_materialized(PersistContext::Standard)
.await;
thread
.session
.flush_rollout()
.await
.expect("test thread rollout should flush");
}
async fn wait_for_live_thread_spawn_children(
control: &AgentControl,
parent_thread_id: ThreadId,
expected_children: &[ThreadId],
) {
let mut expected_children = expected_children.to_vec();
expected_children.sort_by_key(std::string::ToString::to_string);
timeout(Duration::from_secs(5), async {
loop {
let mut child_ids = control
.open_thread_spawn_children(parent_thread_id)
.await
.expect("live child list should load")
.into_iter()
.map(|(thread_id, _)| thread_id)
.collect::<Vec<_>>();
child_ids.sort_by_key(std::string::ToString::to_string);
if child_ids == expected_children {
break;
}
sleep(Duration::from_millis(25)).await;
}
})
.await
.expect("expected persisted child tree");
}
async fn assert_thread_not_loaded(manager: &ThreadManager, thread_id: ThreadId) {
match manager.get_thread(thread_id).await {
Err(err) => match err.details() {
CodexErrorDetails::ThreadNotFound(id) => assert_eq!(*id, thread_id),
_ => panic!("expected ThreadNotFound, got {err:?}"),
},
Ok(_) => panic!("expected thread not to be loaded"),
}
}
#[tokio::test]
async fn send_input_errors_when_manager_dropped() {
let control = AgentControl::default();
let err = control
.send_input(
ThreadId::new(),
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
Default::default(),
)
.await
.expect_err("send_input should fail without a manager");
assert_eq!(
err.to_string(),
"unsupported operation: thread manager dropped"
);
}
#[tokio::test]
async fn get_status_returns_not_found_without_manager() {
let control = AgentControl::default();
let got = control.get_status(ThreadId::new()).await;
assert_eq!(got, AgentStatus::NotFound);
}
#[tokio::test]
async fn on_event_updates_status_from_task_started() {
let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
root_turn_id: None,
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}));
assert_eq!(status, Some(AgentStatus::Running));
}
#[tokio::test]
async fn on_event_updates_status_from_task_complete() {
for (error, expected) in [
(None, AgentStatus::Completed(Some("done".to_string()))),
(
Some(ErrorEvent {
misalignment: None,
message: "denied".to_string(),
codex_error_info: None,
}),
AgentStatus::Errored("denied".to_string()),
),
] {
let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
started_at: None,
last_agent_message: Some("done".to_string()),
error,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}));
assert_eq!(status, Some(expected));
}
}
#[tokio::test]
async fn on_event_updates_status_from_error() {
let status = agent_status_from_event(&EventMsg::Error(ErrorEvent {
misalignment: None,
message: "boom".to_string(),
codex_error_info: None,
}));
let expected = AgentStatus::Errored("boom".to_string());
assert_eq!(status, Some(expected));
}
#[tokio::test]
async fn on_event_updates_status_from_turn_aborted() {
let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
started_at: None,
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}));
let expected = AgentStatus::Interrupted;
assert_eq!(status, Some(expected));
}
#[tokio::test]
async fn on_event_updates_status_from_shutdown_complete() {
let status = agent_status_from_event(&EventMsg::ShutdownComplete);
assert_eq!(status, Some(AgentStatus::Shutdown));
}
#[tokio::test]
async fn spawn_agent_errors_when_manager_dropped() {
let control = AgentControl::default();
let (_home, config) = test_config().await;
let err = control
.spawn_agent(config, text_input("hello"), /*session_source*/ None)
.await
.expect_err("spawn_agent should fail without a manager");
assert_eq!(
err.to_string(),
"unsupported operation: thread manager dropped"
);
}
#[tokio::test]
async fn resume_agent_errors_when_manager_dropped() {
let control = AgentControl::default();
let (_home, config) = test_config().await;
let err = control
.resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec)
.await
.expect_err("resume_agent should fail without a manager");
assert_eq!(
err.to_string(),
"unsupported operation: thread manager dropped"
);
}
#[tokio::test]
async fn send_input_errors_when_thread_missing() {
let harness = AgentControlHarness::new().await;
let thread_id = ThreadId::new();
let err = harness
.control
.send_input(
thread_id,
vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
Default::default(),
)
.await
.expect_err("send_input should fail for missing thread");
assert_matches!(
err.details(),
CodexErrorDetails::ThreadNotFound(id) if *id == thread_id
);
}
#[tokio::test]
async fn get_status_returns_not_found_for_missing_thread() {
let harness = AgentControlHarness::new().await;
let status = harness.control.get_status(ThreadId::new()).await;
assert_eq!(status, AgentStatus::NotFound);
}
#[tokio::test]
async fn get_status_returns_pending_init_for_new_thread() {
let harness = AgentControlHarness::new().await;
let (thread_id, _) = harness.start_thread().await;
let status = harness.control.get_status(thread_id).await;
assert_eq!(status, AgentStatus::PendingInit);
}
#[tokio::test]
async fn subscribe_status_errors_for_missing_thread() {
let harness = AgentControlHarness::new().await;
let thread_id = ThreadId::new();
let err = harness
.control
.subscribe_status(thread_id)
.await
.expect_err("subscribe_status should fail for missing thread");
assert_matches!(
err.details(),
CodexErrorDetails::ThreadNotFound(id) if *id == thread_id
);
}
#[tokio::test]
async fn subscribe_status_updates_on_shutdown() {
let harness = AgentControlHarness::new().await;
let (thread_id, thread) = harness.start_thread().await;
let mut status_rx = harness
.control
.subscribe_status(thread_id)
.await
.expect("subscribe_status should succeed");
assert_eq!(status_rx.borrow().clone(), AgentStatus::PendingInit);
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
let _ = status_rx.changed().await;
assert_eq!(status_rx.borrow().clone(), AgentStatus::Shutdown);
}
#[tokio::test]
async fn send_input_submits_user_message() {
let harness = AgentControlHarness::new().await;
let (thread_id, thread) = harness.start_thread().await;
let submission_id = harness
.control
.send_input(
thread_id,
vec![UserInput::Text {
text: "hello from tests".to_string(),
text_elements: Vec::new(),
}],
Default::default(),
)
.await
.expect("send_input should succeed");
assert!(!submission_id.is_empty());
wait_for_recorded_user_message(thread.as_ref(), "hello from tests").await;
}
#[tokio::test]
async fn send_inter_agent_communication_without_turn_queues_message_without_triggering_turn() {
let harness = AgentControlHarness::new().await;
let (thread_id, thread) = harness.start_thread().await;
let communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"hello from tests".to_string(),
/*trigger_turn*/ false,
);
let submission_id = harness
.control
.send_inter_agent_communication(
thread_id,
communication.clone(),
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
Default::default(),
)
.await
.expect("send_inter_agent_communication should succeed");
assert!(!submission_id.is_empty());
let expected = (
thread_id,
Op::InterAgentCommunication {
communication: communication.clone(),
start_options: Default::default(),
},
);
let captured = harness
.manager
.captured_ops()
.into_iter()
.find(|entry| captured_op_matches(entry, &expected));
assert!(captured.is_some());
timeout(Duration::from_secs(5), async {
loop {
if thread
.session
.input_queue
.has_pending_input(&thread.session.active_turn)
.await
{
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("inter-agent communication should stay pending");
let history = thread.session.clone_history().await;
assert!(!history_contains_assistant_inter_agent_communication(
history.raw_items(),
&communication
));
}
#[tokio::test]
async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
check_v2_agent_reload(V2ReloadRoute::Sender).await;
}
#[tokio::test]
async fn ensure_v2_child_loaded_preserves_evicted_parent_authority() {
check_v2_agent_reload(V2ReloadRoute::NestedParent).await;
}
#[derive(Clone, Copy)]
enum V2ReloadRoute {
Sender,
NestedParent,
}
async fn spawn_v2_reload_test_child(
control: &AgentControl,
config: Config,
parent: &CodexThread,
task_name: &str,
) -> LiveAgent {
let source = thread_spawn_source(
parent.session.thread_id,
&parent.session_source,
next_thread_spawn_depth(&parent.session_source),
/*agent_role*/ None,
Some(task_name.to_string()),
)
.expect("child source");
control
.spawn_agent_with_metadata(
config,
text_input("hello child"),
Some(source),
SpawnAgentOptions {
parent_thread_id: Some(parent.session.thread_id),
..Default::default()
},
)
.await
.expect("spawn_agent should succeed")
}
async fn check_v2_agent_reload(route: V2ReloadRoute) {
let (home, mut config) = test_config().await;
let _ = config.features.enable(Feature::MultiAgentV2);
let _ = config.features.enable(Feature::Sqlite);
config.model = Some("gpt-5.6-sol".to_string());
config.multi_agent_v2.max_concurrent_threads_per_session = 3;
config.permissions.allow_login_shell = true;
config
.permissions
.set_permission_profile(PermissionProfile::read_only())
.expect("read-only parent profile");
let harness = AgentControlHarness::new_with_config(home, config).await;
let client_mcp_extensions =
ClientMcpExtensions::new([(OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({}))]);
let root = harness
.manager
.start_thread(StartThreadOptions {
history_mode: Some(ThreadHistoryMode::Paginated),
client_mcp_extensions: client_mcp_extensions.clone(),
user_instructions: Some(LoadedUserInstructions {
instructions: Some(Instructions {
text: "global instructions survive parent eviction".to_string(),
source: None,
}),
warnings: Vec::new(),
}),
thread_instructions_provider: Some(Arc::new(StaticThreadInstructionsProvider(
"thread instructions survive parent eviction",
))),
..StartThreadOptions::new(harness.config.clone())
})
.await
.expect("start root thread");
let control = root.thread.session.services.agent_control.clone();
let parent_thread = match route {
V2ReloadRoute::Sender => root.thread,
V2ReloadRoute::NestedParent => {
let parent = spawn_v2_reload_test_child(
&control,
harness.config.clone(),
&root.thread,
"parent",
)
.await;
harness
.manager
.get_thread(parent.thread_id)
.await
.expect("nested parent should exist")
}
};
let parent_thread_id = parent_thread.session.thread_id;
let inherited_instructions = parent_thread.session.inherited_instructions().await;
assert!(inherited_instructions.user.is_some());
assert!(inherited_instructions.thread.is_some());
let mut child_config = harness.config.clone();
child_config.model = Some("gpt-5.6-luna".to_string());
let spawned_agent =
spawn_v2_reload_test_child(&control, child_config, &parent_thread, "worker").await;
let agent_path = spawned_agent
.metadata
.agent_path
.clone()
.expect("agent path");
let child_thread = harness
.manager
.get_thread(spawned_agent.thread_id)
.await
.expect("child thread should exist");
child_thread
.inject_response_items(vec![assistant_message(
"child persisted",
Some(MessagePhase::FinalAnswer),
)])
.await
.expect("child rollout should persist with v2 metadata");
child_thread
.shutdown_and_wait()
.await
.expect("child thread should shut down");
let stored_child = child_thread
.read_thread(
/*include_archived*/ true, /*include_history*/ false,
)
.await
.expect("child metadata should be readable");
assert_eq!(stored_child.history_mode, ThreadHistoryMode::Paginated);
assert!(
harness
.manager
.remove_thread(&spawned_agent.thread_id)
.await
.is_some()
);
match harness.manager.get_thread(spawned_agent.thread_id).await {
Err(err) => match err.details() {
CodexErrorDetails::ThreadNotFound(id) => assert_eq!(*id, spawned_agent.thread_id),
_ => panic!("expected ThreadNotFound, got {err:?}"),
},
Ok(_) => panic!("expected thread to be removed"),
}
let mut sender_config = harness.config.clone();
sender_config.model_provider_id = "ollama".to_string();
sender_config.model_provider = sender_config
.model_providers
.get("ollama")
.cloned()
.expect("ollama provider should be configured");
let mut parent_turn = parent_thread.session.new_default_turn().await;
match route {
V2ReloadRoute::Sender => control
.ensure_v2_agent_loaded(sender_config, spawned_agent.thread_id, /*parent*/ None)
.await
.expect("known v2 agent should reload"),
V2ReloadRoute::NestedParent => {
let environment = parent_turn
.environments
.primary()
.expect("parent environment");
let mut owner_config = environment.config().clone();
owner_config.allow_login_shell = false;
let mut selection = environment.selection();
selection.config = EnvironmentConfigState::Ready(owner_config);
parent_thread
.session
.update_settings(SessionSettingsUpdate {
environments: Some(TurnEnvironmentSelections::new(
parent_turn.config.cwd.clone(),
vec![selection],
)),
..Default::default()
})
.await
.expect("save parent environments");
parent_thread.session.mark_interrupted();
// The fixture has no task runner to finish the turn or consume child results.
*parent_thread.session.active_turn.lock().await = None;
let _ = parent_thread
.session
.input_queue
.drain_mailbox_input_items()
.await;
harness
.manager
.ensure_multi_agent_v2_child_loaded(spawned_agent.thread_id)
.await
.expect("known child should reload through its parent");
parent_turn = parent_thread.session.new_default_turn().await;
assert!(harness.manager.get_thread(parent_thread_id).await.is_err());
}
}
let reloaded_child = harness
.manager
.get_thread(spawned_agent.thread_id)
.await
.expect("reloaded child thread should exist");
let reloaded_instructions = reloaded_child.session.inherited_instructions().await;
assert_eq!(
(reloaded_instructions.user, reloaded_instructions.thread),
(inherited_instructions.user, inherited_instructions.thread),
"reloading a child must retain both parent snapshots, even if residency evicts the parent",
);
if matches!(route, V2ReloadRoute::NestedParent) {
let reloaded_turn = reloaded_child.session.new_default_turn().await;
assert_eq!(
(
reloaded_turn.environments.to_selections(),
reloaded_turn.permission_profile(),
reloaded_child.client_mcp_extensions(),
),
(
parent_turn.environments.to_selections(),
parent_turn.permission_profile(),
client_mcp_extensions,
),
);
assert!(Arc::ptr_eq(
&reloaded_child.session.services.exec_policy,
&parent_thread.session.services.exec_policy,
));
}
assert_eq!(
reloaded_child.config_snapshot().await.model,
"gpt-5.6-luna",
"residency reload must preserve the worker model instead of inheriting its parent model",
);
assert_eq!(
(
reloaded_child.config_snapshot().await.model_provider_id,
reloaded_child
.session
.new_default_turn()
.await
.provider
.info()
.clone(),
),
(
stored_child.model_provider,
harness.config.model_provider.clone()
),
"residency reload must preserve the worker provider instead of inheriting its sender's provider",
);
let communication = InterAgentCommunication::new(
AgentPath::root(),
agent_path,
Vec::new(),
"hello after reload".to_string(),
/*trigger_turn*/ false,
);
control
.send_inter_agent_communication(
spawned_agent.thread_id,
communication.clone(),
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
Default::default(),
)
.await
.expect("send_inter_agent_communication should succeed after reload");
let expected = (
spawned_agent.thread_id,
Op::InterAgentCommunication {
communication,
start_options: Default::default(),
},
);
let captured = harness
.manager
.captured_ops()
.into_iter()
.find(|entry| captured_op_matches(entry, &expected));
assert!(captured.is_some());
}
#[tokio::test]
async fn resume_agent_from_rollout_does_not_reopen_v2_descendants() {
let (home, mut config) = test_config().await;
let _ = config.features.enable(Feature::MultiAgentV2);
let _ = config.features.enable(Feature::Sqlite);
let harness = AgentControlHarness::new_with_config(home, config).await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
let worker_path = AgentPath::root().join("worker").expect("worker path");
let worker_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello worker"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(worker_path.clone()),
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("worker spawn should succeed");
let reviewer_path = worker_path.join("reviewer").expect("reviewer path");
let reviewer_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello reviewer"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(reviewer_path.clone()),
agent_nickname: None,
agent_role: Some("reviewer".to_string()),
})),
)
.await
.expect("reviewer spawn should succeed");
let sibling_thread_id = harness
.spawn_anonymous_child(parent_thread_id, SpawnAgentOptions::default())
.await;
let worker_thread = harness
.manager
.get_thread(worker_thread_id)
.await
.expect("worker thread should exist");
let reviewer_thread = harness
.manager
.get_thread(reviewer_thread_id)
.await
.expect("reviewer thread should exist");
let sibling_thread = harness
.manager
.get_thread(sibling_thread_id)
.await
.expect("sibling thread should exist");
persist_thread_for_tree_resume(&parent_thread, "parent persisted").await;
persist_thread_for_tree_resume(&worker_thread, "worker persisted").await;
persist_thread_for_tree_resume(&reviewer_thread, "reviewer persisted").await;
persist_thread_for_tree_resume(&sibling_thread, "sibling persisted").await;
wait_for_live_thread_spawn_children(
&harness.control,
parent_thread_id,
&[worker_thread_id, sibling_thread_id],
)
.await;
wait_for_live_thread_spawn_children(&harness.control, worker_thread_id, &[reviewer_thread_id])
.await;
let report = harness
.manager
.shutdown_all_threads_bounded(Duration::from_secs(5))
.await;
assert_eq!(report.submit_failed, Vec::<ThreadId>::new());
assert_eq!(report.timed_out, Vec::<ThreadId>::new());
let resumed_manager = ThreadManager::with_models_provider_home_and_state_for_tests(
CodexAuth::from_api_key("dummy"),
harness.config.model_provider.clone(),
harness.config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
harness.state_db.clone(),
);
let resumed_control = resumed_manager.agent_control();
let resumed_parent_thread_id = resumed_control
.resume_agent_from_rollout(
harness.config.clone(),
parent_thread_id,
SessionSource::Exec,
)
.await
.expect("v2 root resume should succeed");
assert_eq!(resumed_parent_thread_id, parent_thread_id);
assert_ne!(
resumed_control.get_status(parent_thread_id).await,
AgentStatus::NotFound
);
assert_thread_not_loaded(&resumed_manager, worker_thread_id).await;
assert_thread_not_loaded(&resumed_manager, reviewer_thread_id).await;
assert_thread_not_loaded(&resumed_manager, sibling_thread_id).await;
resumed_control
.restore_v2_agent_metadata(&harness.config, parent_thread_id)
.await;
for thread_id in [worker_thread_id, sibling_thread_id] {
assert!(resumed_control.ensure_agent_known(thread_id).is_ok());
}
resumed_control
.close_agent(worker_thread_id)
.await
.expect("closing a restored sibling should succeed");
let closed_worker = resumed_control.ensure_agent_known(worker_thread_id);
let surviving_sibling = resumed_control.ensure_agent_known(sibling_thread_id);
assert!(closed_worker.is_err());
assert!(surviving_sibling.is_ok());
assert_thread_not_loaded(&resumed_manager, sibling_thread_id).await;
}
struct StaticThreadInstructionsProvider(&'static str);
impl ThreadInstructionsProvider for StaticThreadInstructionsProvider {
fn load_thread_instructions(&self) -> LoadInstructionsFuture<'_> {
let instructions = Instructions {
text: self.0.to_string(),
source: None,
};
Box::pin(async move {
LoadedUserInstructions {
instructions: Some(instructions),
warnings: Vec::new(),
}
})
}
}
#[tokio::test]
async fn cold_resume_with_thread_instructions_preserves_lazy_v2_child_inheritance() {
let (home, mut config) = test_config().await;
let _ = config.features.enable(Feature::MultiAgentV2);
let _ = config.features.enable(Feature::Sqlite);
let harness = AgentControlHarness::new_with_config(home, config).await;
let parent = harness
.manager
.start_thread(StartThreadOptions {
thread_instructions_provider: Some(Arc::new(StaticThreadInstructionsProvider(
"initial thread instructions",
))),
..StartThreadOptions::new(harness.config.clone())
})
.await
.expect("start parent with thread instructions");
let parent_thread_id = parent.thread_id;
let control = &parent.thread.session.services.agent_control;
let worker_thread_id =
spawn_v2_reload_test_child(control, harness.config.clone(), &parent.thread, "worker")
.await
.thread_id;
let worker = harness
.manager
.get_thread(worker_thread_id)
.await
.expect("worker should be loaded");
persist_thread_for_tree_resume(&parent.thread, "parent persisted").await;
persist_thread_for_tree_resume(&worker, "worker persisted").await;
wait_for_live_thread_spawn_children(control, parent_thread_id, &[worker_thread_id]).await;
let stored_parent = parent
.thread
.read_thread(
/*include_archived*/ true, /*include_history*/ true,
)
.await
.expect("read parent history");
let initial_history = InitialHistory::Resumed(ResumedHistory {
conversation_id: parent_thread_id,
history: Arc::new(stored_parent.history.expect("parent history").items),
rollout_path: stored_parent.rollout_path,
});
let report = harness
.manager
.shutdown_all_threads_bounded(Duration::from_secs(5))
.await;
assert_eq!(report.submit_failed, Vec::<ThreadId>::new());
assert_eq!(report.timed_out, Vec::<ThreadId>::new());
let resumed_manager = ThreadManager::with_models_provider_home_and_state_for_tests(
CodexAuth::from_api_key("dummy"),
harness.config.model_provider.clone(),
harness.config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
harness.state_db.clone(),
);
let resumed_parent = resumed_manager
.start_thread(StartThreadOptions {
initial_history,
session_source: Some(SessionSource::Exec),
thread_instructions_provider: Some(Arc::new(StaticThreadInstructionsProvider(
"resumed thread instructions",
))),
..StartThreadOptions::new(harness.config.clone())
})
.await
.expect("cold resume parent with updated thread instructions");
let thread_instructions = Instructions {
text: "resumed thread instructions".to_string(),
source: None,
};
assert_eq!(
resumed_parent
.thread
.session
.inherited_instructions()
.await
.thread,
Some(thread_instructions.clone()),
);
assert_thread_not_loaded(&resumed_manager, worker_thread_id).await;
resumed_manager
.ensure_multi_agent_v2_child_loaded(worker_thread_id)
.await
.expect("resume v2 worker on demand");
let resumed_worker = resumed_manager
.get_thread(worker_thread_id)
.await
.expect("resumed worker should be loaded");
assert_eq!(
resumed_worker.session.inherited_instructions().await.thread,
Some(thread_instructions),
);
}
#[tokio::test]
async fn spawn_agent_creates_thread_and_sends_prompt() {
let harness = AgentControlHarness::new().await;
let thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("spawned"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let thread = harness
.manager
.get_thread(thread_id)
.await
.expect("thread should be registered");
wait_for_recorded_user_message(thread.as_ref(), "spawned").await;
}
#[tokio::test]
async fn ephemeral_spawn_does_not_persist_agent_graph_edge() {
let (home, mut config) = test_config().await;
config.ephemeral = true;
let harness = AgentControlHarness::new_with_config(home, config).await;
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("spawned"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
)
.await
.expect("ephemeral agent spawn should succeed");
let persisted_children = harness
.state_db
.as_ref()
.expect("manager should retain state db")
.list_thread_spawn_children(parent_thread_id)
.await
.expect("persisted child list should load");
assert_eq!(persisted_children, Vec::<ThreadId>::new());
assert!(
harness.manager.get_thread(child_thread_id).await.is_ok(),
"ephemeral child should remain live"
);
}
#[tokio::test]
async fn spawn_agent_fork_from_paginated_parent_uses_model_context_prefix() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await;
parent_thread
.inject_response_items(vec![user_message("paginated parent context")])
.await
.expect("inject paginated parent context");
let turn_context = parent_thread.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-paginated".to_string();
parent_thread
.session
.record_conversation_items(
turn_context.as_ref(),
turn_context.model_info(),
&[spawn_agent_call(&parent_spawn_call_id)],
)
.await;
parent_thread
.session
.persist_rollout_items(&[
rollout_response_item(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "id-less inherited context".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}),
RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: parent_thread_id,
turn_id: "parent-turn".to_string(),
item: TurnItem::UserMessage(UserMessageItem {
id: "parent-user".to_string(),
client_id: None,
content: Vec::new(),
}),
started_at_ms: Some(0),
completed_at_ms: 1,
})),
RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(
ThreadSettingsAppliedEvent {
thread_id: Some(parent_thread_id),
thread_settings: ThreadSettingsSnapshot {
disabled_plugin_ids: Vec::new(),
model: "parent-only-model".to_string(),
model_provider_id: "parent-only-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::workspace_write(),
active_permission_profile: None,
cwd: harness.config.cwd.clone(),
runtime_workspace_roots: None,
reasoning_effort: None,
reasoning_summary: None,
personality: None,
collaboration_mode: CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: "parent-only-model".to_string(),
reasoning_effort: None,
developer_instructions: None,
},
},
},
},
)),
])
.await;
let child_thread_id = harness
.spawn_anonymous_child(
parent_thread_id,
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
..Default::default()
},
)
.await;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
assert!(
history_contains_text(
child_thread.session.clone_history().await.raw_items(),
"paginated parent context",
),
"bounded parent context should remain model-visible to the child"
);
child_thread.ensure_rollout_materialized().await;
child_thread
.flush_rollout()
.await
.expect("child rollout should flush");
let rollout_path = child_thread
.rollout_path()
.expect("child rollout should exist");
let lines = std::fs::read_to_string(&rollout_path)
.expect("read child rollout")
.lines()
.map(|line| codex_rollout::parse_rollout_line(line).expect("parse rollout line"))
.collect::<Vec<_>>();
let RolloutItem::SessionMeta(meta_line) = &lines[0].item else {
panic!("child rollout should start with session metadata");
};
assert_eq!(meta_line.meta.history_mode, ThreadHistoryMode::Paginated);
assert_eq!(meta_line.meta.parent_thread_id, Some(parent_thread_id));
assert_eq!(meta_line.meta.forked_from_id, Some(parent_thread_id));
let prefix_end = usize::try_from(
meta_line
.meta
.subagent_history_start_ordinal
.expect("paginated child should mark its local history boundary"),
)
.expect("history boundary should fit in usize");
let copied_prefix = &lines[1..prefix_end];
let copied_idless_context = copied_prefix
.iter()
.find_map(|line| match &line.item {
RolloutItem::ResponseItem(response_item)
if serde_json::to_string(&response_item.item)
.expect("serialize response item")
.contains("id-less inherited context") =>
{
Some(response_item)
}
_ => None,
})
.expect("copied prefix should contain inherited response item");
assert!(
copied_idless_context.id().is_some_and(|id| !id.is_empty()),
"copied model context should receive response item ids before persistence"
);
let copied_parent_context_count = lines
.iter()
.filter(|line| {
serde_json::to_string(&line.item)
.expect("serialize rollout item")
.contains("paginated parent context")
})
.count();
assert_eq!(
copied_parent_context_count, 1,
"copied model context should be persisted once"
);
assert!(
!copied_prefix.iter().any(|line| {
matches!(
&line.item,
RolloutItem::EventMsg(
EventMsg::ItemCompleted(_) | EventMsg::ThreadSettingsApplied(_)
)
)
}),
"copied non-structural presentation and metadata records should not enter the child rollout"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_without_fork_from_paginated_parent_stays_fresh_and_paginated() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await;
parent_thread
.inject_response_items(vec![user_message("parent-only context")])
.await
.expect("inject parent-only context");
let child_thread_id = harness
.spawn_anonymous_child(
parent_thread_id,
SpawnAgentOptions {
parent_thread_id: Some(parent_thread_id),
..Default::default()
},
)
.await;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
assert!(
!history_contains_text(
child_thread.session.clone_history().await.raw_items(),
"parent-only context",
),
"fork_turns=none should not copy parent context"
);
child_thread.ensure_rollout_materialized().await;
child_thread
.flush_rollout()
.await
.expect("child rollout should flush");
let meta = codex_rollout::read_session_meta_line(
&child_thread
.rollout_path()
.expect("child rollout should exist"),
)
.await
.expect("read child session metadata");
assert_eq!(meta.meta.history_mode, ThreadHistoryMode::Paginated);
assert_eq!(meta.meta.subagent_history_start_ordinal, None);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[test_case::test_case(true; "thread context enabled")]
#[test_case::test_case(false; "thread context disabled")]
#[tokio::test]
async fn spawn_agent_fork_drops_inherited_token_usage_state(thread_context_enabled: bool) {
let mut harness = AgentControlHarness::new().await;
let _ = harness.config.features.disable(Feature::MultiAgentV2);
harness
.config
.features
.set_enabled(Feature::GuardianThreadContext, thread_context_enabled)
.expect("test context mode");
let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await;
let parent_usage = TokenUsage {
total_tokens: 120,
..TokenUsage::default()
};
let parent_record = TokenUsageRecord {
thread_id: parent_thread_id,
turn_id: "parent-turn".to_string(),
session_id: parent_thread.session.session_id(),
root_turn_id: "parent-turn".to_string(),
response_id: "parent-response".to_string(),
usage: parent_usage.clone(),
turn_token_usage: parent_usage.clone(),
thread_token_usage: parent_usage,
};
let parent_spawn_call_id = "spawn-call-token-usage".to_string();
parent_thread
.session
.persist_rollout_items(&[
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(vec![user_message("compacted parent context").into()]),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: Some(parent_record.clone()),
}),
RolloutItem::TokenUsageRecord(parent_record),
rollout_response_item(spawn_agent_call(&parent_spawn_call_id)),
])
.await;
let child_thread_id = harness
.spawn_anonymous_child(
parent_thread_id,
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
..Default::default()
},
)
.await;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let child_usage = TokenUsage {
total_tokens: 80,
..TokenUsage::default()
};
assert!(
!child_thread
.session
.clone_history()
.await
.retained_context()
.user_messages_complete(),
"V1 forks lack complete retained authorization in both context modes"
);
let turn_context = child_thread.session.new_default_turn().await;
child_thread
.session
.record_observed_response_completed(
turn_context.as_ref(),
"child-response",
Some(&child_usage),
/*usage_metadata*/ None,
)
.await;
child_thread
.flush_rollout()
.await
.expect("child rollout should flush");
let rollout_path = child_thread
.rollout_path()
.expect("child rollout should exist");
let lines = std::fs::read_to_string(&rollout_path)
.expect("read child rollout")
.lines()
.map(|line| codex_rollout::parse_rollout_line(line).expect("parse rollout line"))
.collect::<Vec<_>>();
assert!(
!lines.iter().any(|line| {
matches!(
&line.item,
RolloutItem::TokenUsageRecord(record) if record.thread_id == parent_thread_id
)
}),
"child rollout should not inherit parent token usage records"
);
assert!(
lines.iter().all(|line| {
!matches!(
&line.item,
RolloutItem::Compacted(compacted)
if compacted.latest_token_usage_record.is_some()
)
}),
"child rollout should not inherit parent token usage checkpoints"
);
let child_record = lines.iter().rev().find_map(|line| match &line.item {
RolloutItem::TokenUsageRecord(record) => Some(record),
_ => None,
});
assert_eq!(
child_record,
Some(&TokenUsageRecord {
thread_id: child_thread_id,
turn_id: turn_context.sub_id.clone(),
session_id: child_thread.session.session_id(),
root_turn_id: turn_context.sub_id.clone(),
response_id: "child-response".to_string(),
usage: child_usage.clone(),
turn_token_usage: child_usage.clone(),
thread_token_usage: child_usage,
})
);
}
#[tokio::test]
async fn spawn_agent_numeric_fork_from_compacted_paginated_parent_clamps_to_provable_turns() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await;
let parent_spawn_call_id = "spawn-call-paginated-numeric".to_string();
parent_thread
.session
.persist_rollout_items(&[
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "compacted summary".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
.into(),
]),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
rollout_response_item(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "recent parent turn".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}),
rollout_response_item(spawn_agent_call(&parent_spawn_call_id)),
])
.await;
let clamped_child_thread_id = harness
.spawn_anonymous_child(
parent_thread_id,
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
..Default::default()
},
)
.await;
let clamped_child_thread = harness
.manager
.get_thread(clamped_child_thread_id)
.await
.expect("clamped child thread should be registered");
let clamped_history = clamped_child_thread.session.clone_history().await;
assert!(
history_contains_text(clamped_history.raw_items(), "recent parent turn"),
"clamped numeric fork should keep the provable recent turn"
);
assert!(
!history_contains_text(clamped_history.raw_items(), "compacted summary"),
"clamped numeric fork should not expand into compacted parent context"
);
let _ = harness
.control
.shutdown_live_agent(clamped_child_thread_id)
.await
.expect("clamped child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
let managed_fragment = "<managed_developer_instructions>\nParent developer instructions.\n</managed_developer_instructions>";
let persistent_fragment =
"<persistent_mode>\nParent developer instructions.\n</persistent_mode>";
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
let _ = parent_config.features.enable(Feature::MultiAgentV2);
parent_config.developer_instructions = Some("Parent developer instructions.".to_string());
parent_config.multi_agent_v2.root_agent_usage_hint_text =
Some("Parent root guidance.".to_string());
parent_config.multi_agent_v2.subagent_usage_hint_text =
Some("Parent subagent guidance.".to_string());
let mut child_config = harness.config.clone();
let _ = child_config.features.enable(Feature::MultiAgentV2);
child_config.developer_instructions = Some("Child developer instructions.".to_string());
child_config.multi_agent_v2.subagent_developer_instructions =
Some("Child developer instructions.".to_string());
child_config.multi_agent_v2.root_agent_usage_hint_text =
Some("Child root guidance.".to_string());
child_config.multi_agent_v2.subagent_usage_hint_text =
Some("Child subagent guidance.".to_string());
let new_thread = harness
.manager
.start_thread(StartThreadOptions::new(parent_config.clone()))
.await
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
let parent_thread = new_thread.thread;
parent_thread
.session
.inject_no_new_turn(
vec![user_message("parent seed context")],
/*current_turn_context*/ None,
)
.await;
let expected_parent_seed = parent_thread
.session
.clone_history()
.await
.raw_items()
.next()
.cloned()
.expect("parent seed should be recorded");
let turn_context = parent_thread.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-history".to_string();
let trigger_message = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"parent trigger message".to_string(),
/*trigger_turn*/ true,
);
let standalone_output = ResponseItem::FunctionCallOutput {
id: None,
call_id: None,
name: Some("notifications".to_string()),
namespace: Some("slack".to_string()),
output: FunctionCallOutputPayload::from_text("parent notification".to_string()),
internal_chat_message_metadata_passthrough: None,
};
parent_thread
.session
.record_conversation_items(
turn_context.as_ref(), turn_context.model_info(),
&[
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent root guidance.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent subagent guidance.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![
ContentItem::InputText {
text: "Developer context before.\nParent developer instructions.\nDeveloper context after."
.to_string(),
},
ContentItem::InputText {
text: "<multi_agent_mode>Proactive multi-agent delegation is active.</multi_agent_mode>"
.to_string(),
},
ContentItem::InputText {
text: "Preserved developer context.".to_string(),
},
ContentItem::InputText {
text: managed_fragment.to_string(),
},
ContentItem::InputText {
text: persistent_fragment.to_string(),
},
],
phase: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![
ContentItemKind("generic.developer_instructions".to_string()),
ContentItemKind("multi_agent.mode_instructions".to_string()),
ContentItemKind("generic.developer_policy".to_string()),
ContentItemKind("managed_config.developer_instructions".to_string()),
ContentItemKind("persistent_mode.instructions".to_string()),
]),
..Default::default()
},
),
},
assistant_message("parent commentary", Some(MessagePhase::Commentary)),
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
standalone_output,
assistant_message("parent unknown phase", /*phase*/ None),
ResponseItem::Reasoning {
id: Some(ResponseItemId::with_suffix("rs", "parent-reasoning")),
summary: Vec::new(),
content: None,
encrypted_content: None,
internal_chat_message_metadata_passthrough: None,
},
trigger_message.to_response_input_item().into(),
spawn_agent_call(&parent_spawn_call_id),
],
)
.await;
let expected_standalone_output = parent_thread
.session
.clone_history()
.await
.raw_items()
.find(|item| matches!(item, ResponseItem::FunctionCallOutput { call_id: None, .. }))
.cloned()
.expect("standalone output should be recorded");
let parent_reference_context_item = turn_context.to_turn_context_item();
parent_thread
.session
.persist_rollout_items(&[RolloutItem::TurnContext(
parent_reference_context_item.clone(),
)])
.await;
parent_thread
.session
.ensure_rollout_materialized(PersistContext::Standard)
.await;
parent_thread
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
child_config,
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
..Default::default()
},
)
.await
.expect("forked spawn should succeed")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
assert_ne!(child_thread_id, parent_thread_id);
assert_eq!(
child_thread.config_snapshot().await.history_mode,
ThreadHistoryMode::Legacy
);
let history = child_thread.session.clone_history().await;
let history_items = history.raw_items().cloned().collect::<Vec<_>>();
let expected_final_answer = parent_thread
.session
.clone_history()
.await
.raw_items()
.find(|item| {
matches!(
item,
ResponseItem::Message {
role,
phase: Some(MessagePhase::FinalAnswer),
..
} if role == "assistant"
)
})
.cloned()
.expect("parent final answer should be recorded");
let mut expected_developer_message = ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![
ContentItem::InputText {
text: "Developer context before.\nChild developer instructions.\nDeveloper context after."
.to_string(),
},
ContentItem::InputText {
text: "Preserved developer context.".to_string(),
},
ContentItem::InputText {
text: managed_fragment.to_string(),
},
ContentItem::InputText {
text: persistent_fragment.to_string(),
},
],
phase: None,
internal_chat_message_metadata_passthrough: Some(
InternalChatMessageMetadataPassthrough {
content_item_kinds: Some(vec![
ContentItemKind("generic.developer_instructions".to_string()),
ContentItemKind("generic.developer_policy".to_string()),
ContentItemKind("managed_config.developer_instructions".to_string()),
ContentItemKind("persistent_mode.instructions".to_string()),
]),
..Default::default()
},
),
};
expected_developer_message.set_turn_id_if_missing(&turn_context.sub_id);
expected_developer_message.set_create_time_if_missing(
history_items[1]
.executed_tool_call_metadata()
.and_then(|metadata| metadata.create_time.clone())
.expect("recorded developer message should have a creation timestamp"),
);
let expected_history = [
expected_parent_seed,
expected_developer_message,
expected_final_answer,
expected_standalone_output,
ContextualUserFragment::into(MultiAgentRoleInstructions::Configured(
"Child subagent guidance.".to_string(),
)),
];
assert_eq!(
strip_response_item_ids(&history_items),
strip_response_item_ids(&expected_history),
"full-history forked child history should replace parent usage hints with the child subagent hint while filtering non-final assistant/tool chatter"
);
assert_eq!(
serde_json::to_value(child_thread.session.reference_context_item().await)
.expect("serialize child reference context item"),
serde_json::to_value(Some(parent_reference_context_item))
.expect("serialize expected reference context item"),
"full-history forked child should preserve the parent diff baseline"
);
let mut no_hint_child_config = harness.config.clone();
let _ = no_hint_child_config.features.enable(Feature::MultiAgentV2);
no_hint_child_config.developer_instructions = Some(String::new());
no_hint_child_config
.multi_agent_v2
.subagent_developer_instructions = Some(String::new());
no_hint_child_config.multi_agent_v2.subagent_usage_hint_text = Some(String::new());
let no_hint_child_thread_id = harness
.control
.spawn_agent_with_metadata(
no_hint_child_config,
text_input("child task without hints"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
..Default::default()
},
)
.await
.expect("forked spawn should honor an empty subagent usage hint")
.thread_id;
let no_hint_child_thread = harness
.manager
.get_thread(no_hint_child_thread_id)
.await
.expect("no-hint child thread should be registered");
let no_hint_history = no_hint_child_thread.session.clone_history().await;
assert!(
!history_contains_text(no_hint_history.raw_items(), "Child subagent guidance.")
&& !history_contains_text(
no_hint_history.raw_items(),
"You are an agent in a team of agents"
),
"full-history forked child should not add configured or bundled subagent guidance"
);
assert!(
!history_contains_text(
no_hint_history.raw_items(),
"Developer context before.\nParent developer instructions."
),
"empty child developer instructions should remove parent developer instructions"
);
assert!(
history_contains_text(no_hint_history.raw_items(), managed_fragment)
&& history_contains_text(no_hint_history.raw_items(), persistent_fragment),
"clearing child instructions must preserve overlapping managed and persistent instructions"
);
assert!(
history_contains_text(
no_hint_history.raw_items(),
"Developer context before.\n\nDeveloper context after."
),
"empty child developer instructions should preserve surrounding developer context"
);
assert!(
history_contains_text(no_hint_history.raw_items(), "Preserved developer context."),
"empty child developer instructions should preserve unrelated developer fragments"
);
wait_for_recorded_user_message(child_thread.as_ref(), "child task").await;
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = harness
.control
.shutdown_live_agent(no_hint_child_thread_id)
.await
.expect("no-hint child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[test_case::test_case(true; "thread context enabled")]
#[test_case::test_case(false; "thread context disabled")]
#[tokio::test]
async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(
thread_context_enabled: bool,
) {
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
parent_config
.features
.set_enabled(Feature::GuardianThreadContext, thread_context_enabled)
.expect("test context mode");
let _ = parent_config.features.enable(Feature::MultiAgentV2);
parent_config.developer_instructions = Some("Parent developer instructions.".to_string());
parent_config.multi_agent_v2.root_agent_usage_hint_text =
Some("Parent root guidance.".to_string());
parent_config.multi_agent_v2.subagent_usage_hint_text =
Some("Parent subagent guidance.".to_string());
let mut child_config = harness.config.clone();
child_config
.features
.set_enabled(Feature::GuardianThreadContext, thread_context_enabled)
.expect("test context mode");
let _ = child_config.features.enable(Feature::MultiAgentV2);
child_config.developer_instructions = Some("Child developer instructions.".to_string());
child_config.multi_agent_v2.subagent_developer_instructions =
Some("Child developer instructions.".to_string());
child_config.multi_agent_v2.root_agent_usage_hint_text =
Some("Child root guidance.".to_string());
child_config.multi_agent_v2.subagent_usage_hint_text =
Some("Child subagent guidance.".to_string());
let new_thread = harness
.manager
.start_thread(StartThreadOptions::new(parent_config))
.await
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
let parent_thread = new_thread.thread;
let turn_context = parent_thread.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-compacted-usage-hints".to_string();
let catalog_role = |base: &str| MultiAgentRoleInstructions::Composed {
base: base.to_string(),
marked: true,
omit_update_plan_instructions: false,
max_concurrency: 2,
wait_agent_enabled: false,
expose_model_overrides: false,
};
let parent_task = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::root().join("worker").expect("valid worker path"),
Vec::new(),
"compacted parent delegated task".to_string(),
/*trigger_turn*/ true,
);
let replacement_history = vec![
ContextualUserFragment::into(crate::context::GuardianApprovedAction::new("parent-private-release".to_owned())),
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "compacted parent summary".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ContextualUserFragment::into(catalog_role("Catalog parent root guidance.")),
parent_task.to_model_input_item(),
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent root guidance.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![
ContentItem::InputText {
text: "Compacted context before.\nParent developer instructions.\nCompacted context after."
.to_string(),
},
ContentItem::InputText {
text: "<multi_agent_mode>Proactive multi-agent delegation is active.</multi_agent_mode>"
.to_string(),
},
ContentItem::InputText {
text: "Preserved compacted developer context.".to_string(),
},
],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
];
let answer_event: codex_history::RetainedContextEvent = serde_json::from_value(serde_json::json!({
"type": "verified_answer", "turn_id": "parent-answer-turn", "call_id": "parent-answer-call",
"questions": [{"question": "Parent-local action?", "answer": "Parent only."}]
})).expect("verified answer fixture");
let delivery = codex_history::ResponseItemEnvelope {
item: serde_json::from_value(serde_json::json!({
"type": "function_call_output", "id": "parent-delivery",
"name": "send_message_to_thread", "output": "Parent delivery"
}))
.unwrap(),
metadata: Some(codex_history::CodexHarnessMetadata {
user_input_order: Some(7),
sender_user_messages: Some(Box::new(codex_history::SenderUserMessages {
receiver_turn_id: "parent-turn".to_owned(),
receiver_message_id: "parent-delivery".to_owned(),
text: "Parent-only sender context".to_owned(),
})),
..Default::default()
}),
};
let mut retained_context = codex_history::RetainedContext::default();
retained_context.record(&answer_event);
retained_context.record_sender_user_messages(delivery.metadata.as_ref().unwrap());
parent_thread
.session
.persist_rollout_items(&[
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(
replacement_history
.into_iter()
.map(Into::into)
.chain([delivery.clone()])
.collect(),
),
retained_context: Some(retained_context),
guardian_history: Some(codex_history::GuardianHistoryCheckpoint(vec![
user_message("Parent-local approval must not be inherited."),
])),
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
RolloutItem::RetainedContext(answer_event),
RolloutItem::ResponseItem(delivery),
RolloutItem::TurnContext(turn_context.to_turn_context_item()),
rollout_response_item(spawn_agent_call(&parent_spawn_call_id)),
])
.await;
parent_thread
.session
.ensure_rollout_materialized(PersistContext::Standard)
.await;
parent_thread
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
child_config,
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
multi_agent_v2_usage_hints: Some(ResolvedMultiAgentV2UsageHints {
root: None,
subagent: Some(catalog_role("Catalog child subagent guidance.")),
}),
..Default::default()
},
)
.await
.expect("forked spawn should sanitize compacted usage hints")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.session.clone_history().await;
assert!(
!history_contains_text(
history.conversation_history_snapshot().review_items(),
"Parent-local approval must not be inherited.",
),
"a subagent must not inherit its parent review checkpoint",
);
assert_eq!(
history_contains_text(history.raw_items(), "parent-private-release"),
!thread_context_enabled,
"only retained mode changes parent approval inheritance",
);
let mut inherited_context = codex_history::RetainedContext::default();
if thread_context_enabled {
inherited_context.reserve_order();
} else {
inherited_context.mark_user_messages_incomplete();
}
assert_eq!(history.retained_context(), &inherited_context);
assert!(
history_contains_text(history.raw_items(), "compacted parent summary"),
"forked child history should retain compacted non-hint content"
);
assert!(
!history_contains_text(history.raw_items(), "Catalog parent root guidance."),
"forked child history should strip the resolved parent hint from compacted replacement history"
);
assert!(
history_contains_text(history.raw_items(), "Catalog child subagent guidance."),
"full-history forked child should add the resolved child hint after compacted-history sanitization"
);
assert!(
!history
.raw_items()
.any(|item| matches!(item, ResponseItem::AgentMessage { .. })),
"forked child history should not inherit compacted parent agent messages"
);
assert!(
!history_contains_text(history.raw_items(), "Parent root guidance."),
"forked child history should strip stale parent hints from compacted replacement history"
);
assert!(
!history_contains_text(
history.raw_items(),
"Proactive multi-agent delegation is active."
),
"forked child history should strip stale policy fragments from compound compacted messages"
);
assert!(
!history_contains_text(history.raw_items(), "Parent developer instructions."),
"forked child history should replace parent instructions in compacted replacement history"
);
assert!(
history_contains_text(
history.raw_items(),
"Compacted context before.\nChild developer instructions.\nCompacted context after."
),
"forked child history should replace compacted parent instructions without removing surrounding context"
);
assert!(
history_contains_text(
history.raw_items(),
"Preserved compacted developer context."
),
"forked child history should preserve unrelated compacted developer fragments"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
/// Full-history forks must restore child instructions when compaction discarded
/// the only matching parent instruction fragment from effective history.
#[tokio::test]
async fn spawn_agent_full_fork_restores_instructions_after_compaction_discards_parent_fragment() {
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
let _ = parent_config.features.enable(Feature::MultiAgentV2);
parent_config.developer_instructions = Some("Parent developer instructions.".to_string());
let mut child_config = parent_config.clone();
child_config.developer_instructions = Some("Child developer instructions.".to_string());
child_config.multi_agent_v2.subagent_developer_instructions =
Some("Child developer instructions.".to_string());
let new_thread = harness
.manager
.start_thread(StartThreadOptions::new(parent_config))
.await
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
let parent_thread = new_thread.thread;
let turn_context = parent_thread.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-compacted-stale-instructions".to_string();
let replacement_history = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "compacted parent summary".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Preserved compacted developer context.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
];
// Preserve the parent's live baseline while its durable checkpoint omits the
// developer fragment that appeared in obsolete pre-compaction history.
parent_thread
.session
.replace_history(
replacement_history.clone(),
Some(turn_context.to_turn_context_item()),
)
.await;
parent_thread
.session
.persist_rollout_items(&[
rollout_response_item(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent developer instructions.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}),
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(
replacement_history.into_iter().map(Into::into).collect(),
),
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
RolloutItem::TurnContext(turn_context.to_turn_context_item()),
rollout_response_item(spawn_agent_call(&parent_spawn_call_id)),
])
.await;
parent_thread
.session
.ensure_rollout_materialized(PersistContext::Standard)
.await;
parent_thread
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
child_config,
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
..Default::default()
},
)
.await
.expect("forked spawn should preserve effective compacted instructions")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.session.clone_history().await;
assert!(
history_contains_text(
history.raw_items(),
"Preserved compacted developer context."
),
"full-history fork should preserve unrelated compacted developer fragments"
);
assert!(
!history_contains_text(history.raw_items(), "Parent developer instructions."),
"full-history fork should not restore stale pre-compaction parent instructions"
);
assert!(
history_contains_text(history.raw_items(), "Child developer instructions."),
"full-history fork should append child instructions absent from effective compacted history"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
/// A legacy compaction clears the child's baseline, so its first turn must
/// rebuild configured developer instructions exactly once.
#[tokio::test]
async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_once() {
let managed_policy = "Managed policy for every agent.";
let current_managed_fragment = format!(
"<managed_developer_instructions>\n{managed_policy}\n</managed_developer_instructions>"
);
let stale_managed_fragment =
"<managed_developer_instructions>\nOld managed policy.\n</managed_developer_instructions>";
for (case, parent_developer_instructions) in [
("without parent instructions", None),
(
"with parent instructions",
Some("Parent developer instructions."),
),
] {
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
let _ = parent_config.features.enable(Feature::MultiAgentV2);
parent_config.developer_instructions = parent_developer_instructions.map(str::to_string);
let mut requirements = parent_config.config_layer_stack.requirements().clone();
requirements.additional_developer_instructions = Some(codex_config::Sourced::new(
managed_policy.to_string(),
codex_config::RequirementSource::Unknown,
));
let mut requirements_toml = parent_config.config_layer_stack.requirements_toml().clone();
requirements_toml.additional_developer_instructions = Some(managed_policy.to_string());
parent_config.config_layer_stack = codex_config::ConfigLayerStack::new(
parent_config
.config_layer_stack
.all_layers_low_to_high()
.cloned()
.collect(),
requirements,
requirements_toml,
)
.expect("managed requirements stack");
let mut child_config = parent_config.clone();
child_config.developer_instructions = Some("Child developer instructions.".to_string());
child_config.multi_agent_v2.subagent_developer_instructions =
Some("Child developer instructions.".to_string());
let new_thread = harness
.manager
.start_thread(StartThreadOptions::new(parent_config))
.await
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
let parent_thread = new_thread.thread;
let turn_context = parent_thread.session.new_default_turn().await;
let parent_spawn_call_id = match parent_developer_instructions {
Some(_) => "spawn-call-legacy-compact-with-parent",
None => "spawn-call-legacy-compact-without-parent",
};
let parent_user_message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "parent task before legacy compaction".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
};
// A live parent can reestablish its baseline after resuming a rollout
// whose older compaction record cannot restore that baseline to a child.
parent_thread
.session
.replace_history(
vec![parent_user_message.clone()],
Some(turn_context.to_turn_context_item()),
)
.await;
let mut rollout_items = vec![
rollout_response_item(parent_user_message),
RolloutItem::Compacted(CompactedItem {
message: "legacy compacted summary".to_string(),
replacement_history: None,
retained_context: None,
guardian_history: None,
mcp_resource_origins: None,
window_number: None,
first_window_id: None,
previous_window_id: None,
window_id: None,
compaction_response_id: None,
latest_token_usage_record: None,
}),
];
if let Some(instructions) = parent_developer_instructions {
rollout_items.push(rollout_response_item(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: instructions.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}));
}
rollout_items.push(rollout_response_item(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: stale_managed_fragment.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}));
rollout_items.push(RolloutItem::TurnContext(
turn_context.to_turn_context_item(),
));
rollout_items.push(rollout_response_item(spawn_agent_call(
parent_spawn_call_id,
)));
parent_thread
.session
.persist_rollout_items(&rollout_items)
.await;
parent_thread
.session
.ensure_rollout_materialized(PersistContext::Standard)
.await;
parent_thread
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
child_config,
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.to_string()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
..Default::default()
},
)
.await
.expect("forked spawn should preserve legacy compacted history")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
while child_thread
.session
.reference_context_item()
.await
.is_none()
{
tokio::task::yield_now().await;
}
let history = child_thread.session.clone_history().await;
let mut instruction_count = 0;
let mut managed_instructions = Vec::new();
for item in history.raw_items() {
let ResponseItem::Message { role, content, .. } = item else {
continue;
};
if role != "developer" {
continue;
}
for content_item in content {
if let ContentItem::InputText { text } = content_item {
instruction_count += usize::from(text == "Child developer instructions.");
if ManagedDeveloperInstructions::matches_text(text) {
managed_instructions.push(text.as_str());
}
}
}
}
assert_eq!(
(instruction_count, managed_instructions),
(1, vec![current_managed_fragment.as_str()]),
"{case}: canonical context reconstruction must keep only the current child and managed developer instructions"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
}
#[tokio::test]
async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
let turn_context = parent_thread.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-unflushed".to_string();
parent_thread
.session
.record_conversation_items(
turn_context.as_ref(),
turn_context.model_info(),
&[
assistant_message("unflushed final answer", Some(MessagePhase::FinalAnswer)),
spawn_agent_call(&parent_spawn_call_id),
],
)
.await;
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
..Default::default()
},
)
.await
.expect("forked spawn should flush parent rollout before loading history")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.session.clone_history().await;
assert!(
history_contains_text(history.raw_items(), "unflushed final answer"),
"forked child history should include unflushed assistant final answers after flushing the parent rollout"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
parent_thread
.inject_response_items(vec![user_message("old parent context")])
.await
.expect("inject old parent context");
let queued_communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"queued message".to_string(),
/*trigger_turn*/ false,
);
let queued_turn_context = parent_thread.session.new_default_turn().await;
parent_thread
.session
.record_conversation_items(
queued_turn_context.as_ref(),
queued_turn_context.model_info(),
&[queued_communication.to_response_input_item().into()],
)
.await;
let triggered_communication = InterAgentCommunication::new(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
Vec::new(),
"triggered context".to_string(),
/*trigger_turn*/ true,
);
let triggered_turn_context = parent_thread.session.new_default_turn().await;
parent_thread
.session
.record_conversation_items(
triggered_turn_context.as_ref(),
triggered_turn_context.model_info(),
&[triggered_communication.to_response_input_item().into()],
)
.await;
parent_thread
.inject_response_items(vec![user_message("current parent task")])
.await
.expect("inject current parent task");
let spawn_turn_context = parent_thread.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-last-n".to_string();
parent_thread
.session
.record_conversation_items(
spawn_turn_context.as_ref(),
spawn_turn_context.model_info(),
&[spawn_agent_call(&parent_spawn_call_id)],
)
.await;
parent_thread
.session
.persist_rollout_items(&[RolloutItem::TurnContext(
spawn_turn_context.to_turn_context_item(),
)])
.await;
parent_thread
.session
.ensure_rollout_materialized(PersistContext::Standard)
.await;
parent_thread
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
..Default::default()
},
)
.await
.expect("forked spawn should keep only the last two turns")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.session.clone_history().await;
assert!(
!history_contains_text(history.raw_items(), "old parent context"),
"forked child history should drop parent context outside the requested last-N turn window"
);
assert!(
!history_contains_text(history.raw_items(), "queued message"),
"forked child history should drop queued inter-agent messages outside the requested last-N turn window"
);
assert!(
!history_contains_text(history.raw_items(), "triggered context"),
"forked child history should filter assistant inter-agent messages even when they fall inside the requested last-N turn window"
);
assert!(
history_contains_text(history.raw_items(), "current parent task"),
"forked child history should keep the parent user message from the requested last-N turn window"
);
assert!(
child_thread
.session
.reference_context_item()
.await
.is_none(),
"last-N forked child should rebuild context after truncating the cached prefix"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_limit() {
let harness = AgentControlHarness::new().await;
let selected_capability_roots = vec![SelectedCapabilityRoot {
id: "demo@1".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "build".to_string(),
path: PathUri::parse("file:///plugins/demo").expect("plugin root URI"),
},
}];
let mut thread_extension_init = ExtensionDataInit::new();
thread_extension_init.insert(selected_capability_roots.clone());
let parent = harness
.manager
.start_thread(StartThreadOptions {
environments: Some(Vec::new()),
thread_extension_init,
..StartThreadOptions::new(harness.config.clone())
})
.await
.expect("start parent thread");
let parent_thread_id = parent.thread_id;
let parent_thread = parent.thread;
let startup_turn_context = parent_thread.session.new_default_turn().await;
parent_thread
.session
.record_conversation_items(
startup_turn_context.as_ref(),
startup_turn_context.model_info(),
&[ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "parent startup developer context".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}],
)
.await;
parent_thread
.inject_response_items(vec![user_message("current parent task")])
.await
.expect("inject current parent task");
let spawn_turn_context = parent_thread.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-last-n-under-limit".to_string();
parent_thread
.session
.record_conversation_items(
spawn_turn_context.as_ref(),
spawn_turn_context.model_info(),
&[spawn_agent_call(&parent_spawn_call_id)],
)
.await;
parent_thread
.session
.ensure_rollout_materialized(PersistContext::Standard)
.await;
parent_thread
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
..Default::default()
},
)
.await
.expect("bounded forked spawn should drop startup prefix")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.session.clone_history().await;
assert!(
history_contains_text(history.raw_items(), "current parent task"),
"bounded fork should retain the requested recent parent turn"
);
assert!(
!history_contains_text(history.raw_items(), "parent startup developer context"),
"bounded fork should drop parent startup context even when fewer turns exist than requested"
);
assert_eq!(
&child_thread.session.services.selected_capability_roots,
&selected_capability_roots
);
assert!(
child_thread
.session
.reference_context_item()
.await
.is_none(),
"bounded forked child should still rebuild context after truncating the cached prefix"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() {
let persistent_fragment =
"<persistent_mode>\nParent persistent instructions.\n</persistent_mode>";
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
let _ = parent_config.features.enable(Feature::MultiAgentV2);
parent_config.developer_instructions = Some("Parent developer instructions.".to_string());
parent_config.multi_agent_v2.root_agent_usage_hint_text =
Some("Parent root guidance.".to_string());
let mut child_config = harness.config.clone();
let _ = child_config.features.enable(Feature::MultiAgentV2);
child_config.developer_instructions = Some("Child developer instructions.".to_string());
child_config.multi_agent_v2.subagent_developer_instructions =
Some("Child developer instructions.".to_string());
child_config.multi_agent_v2.subagent_usage_hint_text =
Some("Child subagent guidance.".to_string());
let new_thread = harness
.manager
.start_thread(StartThreadOptions::new(parent_config))
.await
.expect("start parent thread");
let parent_thread_id = new_thread.thread_id;
let parent_thread = new_thread.thread;
parent_thread
.inject_response_items(vec![user_message("parent task")])
.await
.expect("inject parent task");
let turn_context = parent_thread.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-last-n-usage-hints".to_string();
parent_thread
.session
.record_conversation_items(
turn_context.as_ref(),
turn_context.model_info(),
&[
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent root guidance.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![
ContentItem::InputText {
text: "Parent developer instructions.".to_string(),
},
ContentItem::InputText {
text: "Preserved bounded developer context.".to_string(),
},
ContentItem::InputText {
text: persistent_fragment.to_string(),
},
],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
spawn_agent_call(&parent_spawn_call_id),
],
)
.await;
parent_thread
.session
.ensure_rollout_materialized(PersistContext::Standard)
.await;
parent_thread
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
child_config,
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
..Default::default()
},
)
.await
.expect("bounded forked spawn should sanitize parent usage hints")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.session.clone_history().await;
assert!(
history_contains_text(history.raw_items(), "parent task"),
"bounded fork should retain the requested recent parent turn"
);
assert!(
!history_contains_text(history.raw_items(), "Parent root guidance."),
"bounded fork should strip stale parent root hints before the child rebuilds startup context"
);
assert!(
!history_contains_text(history.raw_items(), "Parent developer instructions."),
"bounded fork should remove parent instructions before the child rebuilds startup context"
);
assert!(
!history_contains_text(history.raw_items(), "Child developer instructions."),
"bounded fork should not inject child instructions before its canonical context rebuild"
);
assert!(
!history_contains_text(history.raw_items(), persistent_fragment),
"bounded fork should remove persistent instructions before rebuilding context for the child's effort"
);
assert!(
history_contains_text(history.raw_items(), "Preserved bounded developer context."),
"bounded fork should preserve unrelated developer fragments"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_respects_legacy_max_threads_alias() {
let max_threads = 1usize;
let (_home, config) = test_config_with_cli_overrides(vec![(
"agents.max_threads".to_string(),
TomlValue::Integer(max_threads as i64),
)])
.await;
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let control = manager.agent_control();
let _ = manager
.start_thread(StartThreadOptions::new(config.clone()))
.await
.expect("start thread");
let first_agent_id = control
.spawn_agent(
config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let err = control
.spawn_agent(
config,
text_input("hello again"),
/*session_source*/ None,
)
.await
.expect_err("spawn_agent should respect max threads");
let CodexErrorDetails::AgentLimitReached {
max_threads: seen_max_threads,
} = err.details()
else {
panic!("expected AgentLimitReached");
};
assert_eq!(*seen_max_threads, max_threads);
let _ = control
.shutdown_live_agent(first_agent_id)
.await
.expect("shutdown agent");
}
#[tokio::test]
async fn spawn_agent_releases_slot_after_shutdown() {
let max_threads = 1usize;
let (_home, config) = test_config_with_cli_overrides(vec![(
"agents.max_concurrent_threads_per_session".to_string(),
TomlValue::Integer(max_threads as i64),
)])
.await;
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let control = manager.agent_control();
let first_agent_id = control
.spawn_agent(
config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let _ = control
.shutdown_live_agent(first_agent_id)
.await
.expect("shutdown agent");
let second_agent_id = control
.spawn_agent(
config.clone(),
text_input("hello again"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed after shutdown");
let _ = control
.shutdown_live_agent(second_agent_id)
.await
.expect("shutdown agent");
}
#[tokio::test]
async fn spawn_agent_limit_shared_across_clones() {
let max_threads = 1usize;
let (_home, config) = test_config_with_cli_overrides(vec![(
"agents.max_concurrent_threads_per_session".to_string(),
TomlValue::Integer(max_threads as i64),
)])
.await;
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let control = manager.agent_control();
let cloned = control.clone();
let first_agent_id = cloned
.spawn_agent(
config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let err = control
.spawn_agent(
config,
text_input("hello again"),
/*session_source*/ None,
)
.await
.expect_err("spawn_agent should respect shared guard");
let CodexErrorDetails::AgentLimitReached { max_threads } = err.details() else {
panic!("expected AgentLimitReached");
};
assert_eq!(*max_threads, 1);
let _ = control
.shutdown_live_agent(first_agent_id)
.await
.expect("shutdown agent");
}
#[tokio::test]
async fn resume_agent_respects_max_threads_limit() {
let max_threads = 1usize;
let (_home, config) = test_config_with_cli_overrides(vec![(
"agents.max_concurrent_threads_per_session".to_string(),
TomlValue::Integer(max_threads as i64),
)])
.await;
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let control = manager.agent_control();
let resumable_id = control
.spawn_agent(
config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed");
let _ = control
.shutdown_live_agent(resumable_id)
.await
.expect("shutdown resumable thread");
let active_id = control
.spawn_agent(
config.clone(),
text_input("occupy"),
/*session_source*/ None,
)
.await
.expect("spawn_agent should succeed for active slot");
let err = control
.resume_agent_from_rollout(config, resumable_id, SessionSource::Exec)
.await
.expect_err("resume should respect max threads");
let CodexErrorDetails::AgentLimitReached {
max_threads: seen_max_threads,
} = err.details()
else {
panic!("expected AgentLimitReached");
};
assert_eq!(*seen_max_threads, max_threads);
let _ = control
.shutdown_live_agent(active_id)
.await
.expect("shutdown active thread");
}
#[tokio::test]
async fn resume_agent_releases_slot_after_resume_failure() {
let max_threads = 1usize;
let (_home, config) = test_config_with_cli_overrides(vec![(
"agents.max_concurrent_threads_per_session".to_string(),
TomlValue::Integer(max_threads as i64),
)])
.await;
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let control = manager.agent_control();
let _ = control
.resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec)
.await
.expect_err("resume should fail for missing rollout path");
let resumed_id = control
.spawn_agent(config, text_input("hello"), /*session_source*/ None)
.await
.expect("spawn should succeed after failed resume");
let _ = control
.shutdown_live_agent(resumed_id)
.await
.expect("shutdown resumed thread");
}
#[tokio::test]
async fn spawn_child_completion_notifies_parent_history() {
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_path: None,
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 _ = child_thread
.submit(Op::Shutdown {})
.await
.expect("child shutdown should submit");
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
}
#[tokio::test]
async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
let harness = AgentControlHarness::new().await;
let mut config = harness.config.clone();
let _ = config.features.enable(Feature::MultiAgentV2);
let root = harness
.manager
.start_thread(StartThreadOptions::new(config.clone()))
.await
.expect("root thread should start");
let root_thread_id = root.thread_id;
let root_thread = root.thread;
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
let worker_thread_id = harness
.control
.spawn_agent(
config.clone(),
text_input("hello worker"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root_thread_id,
depth: 1,
agent_path: Some(worker_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("worker spawn should succeed");
let tester_path = worker_path.join("tester").expect("tester path");
let tester_thread_id = harness
.control
.spawn_agent(
config,
text_input("hello tester"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(tester_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("tester spawn should succeed");
harness
.control
.shutdown_live_agent(worker_thread_id)
.await
.expect("worker shutdown should succeed");
let tester_thread = harness
.manager
.get_thread(tester_thread_id)
.await
.expect("tester thread should exist");
let tester_turn = tester_thread.session.new_default_turn().await;
tester_thread
.session
.send_event(
tester_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: tester_turn.sub_id.clone(),
started_at: None,
last_agent_message: Some("done".to_string()),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
)
.await;
sleep(Duration::from_millis(100)).await;
assert!(
!harness
.manager
.captured_ops()
.into_iter()
.any(|(thread_id, op)| {
thread_id == worker_thread_id
&& matches!(
op,
Op::InterAgentCommunication { communication, .. }
if communication.author == tester_path
&& communication.recipient == worker_path
&& communication.content == "done"
)
})
);
let root_history = root_thread.session.clone_history().await;
assert!(!history_contains_assistant_inter_agent_communication(
root_history.raw_items(),
&InterAgentCommunication::new(
tester_path,
AgentPath::root(),
Vec::new(),
"done".to_string(),
/*trigger_turn*/ true,
)
));
assert!(!has_subagent_notification(root_history.raw_items()));
}
#[tokio::test]
async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
let harness = AgentControlHarness::new().await;
let (_root_thread_id, root_thread) = harness.start_thread().await;
let (worker_thread_id, _worker_thread) = harness.start_thread().await;
let mut tester_config = harness.config.clone();
let _ = tester_config.features.enable(Feature::MultiAgentV2);
let tester_thread_id = harness
.manager
.start_thread(StartThreadOptions::new(tester_config.clone()))
.await
.expect("tester thread should start")
.thread_id;
let tester_thread = harness
.manager
.get_thread(tester_thread_id)
.await
.expect("tester thread should exist");
let worker_path = AgentPath::root().join("worker_a").expect("worker path");
let tester_path = worker_path.join("tester").expect("tester path");
harness.control.maybe_start_completion_watcher(
tester_thread_id,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(tester_path.clone()),
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
tester_path.to_string(),
Some(tester_path.clone()),
);
let tester_turn = tester_thread.session.new_default_turn().await;
tester_thread
.session
.send_event(
tester_turn.as_ref(),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: tester_turn.sub_id.clone(),
started_at: None,
last_agent_message: Some("done".to_string()),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
)
.await;
let expected_message = crate::session_prefix::format_inter_agent_completion_message(
worker_path.clone(),
tester_path.clone(),
&AgentStatus::Completed(Some("done".to_string())),
)
.expect("completed status should render");
let expected = (
worker_thread_id,
Op::InterAgentCommunication {
communication: InterAgentCommunication::new(
tester_path.clone(),
worker_path.clone(),
Vec::new(),
expected_message.clone(),
/*trigger_turn*/ false,
),
start_options: Default::default(),
},
);
timeout(Duration::from_secs(5), async {
loop {
let captured = harness
.manager
.captured_ops()
.into_iter()
.find(|entry| captured_op_matches(entry, &expected));
if captured.is_some() {
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("completion watcher should queue a direct-parent message");
let root_history = root_thread.session.clone_history().await;
assert!(!history_contains_assistant_inter_agent_communication(
root_history.raw_items(),
&InterAgentCommunication::new(
tester_path,
AgentPath::root(),
Vec::new(),
expected_message,
/*trigger_turn*/ false,
)
));
}
#[tokio::test]
async fn completion_watcher_notifies_parent_when_child_is_missing() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
let child_thread_id = ThreadId::new();
harness.control.maybe_start_completion_watcher(
child_thread_id,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
child_thread_id.to_string(),
/*child_agent_path*/ None,
);
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
let history = parent_thread.session.clone_history().await;
assert_eq!(
history_contains_text(
history.raw_items(),
&format!("\"agent_path\":\"{child_thread_id}\"")
),
true
);
assert_eq!(
history_contains_text(history.raw_items(), "\"status\":\"not_found\""),
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_path: None,
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 spawn_thread_subagents_persist_parent_originator_across_new_and_truncated_fork() {
let harness = AgentControlHarness::new().await;
let parent = harness
.manager
.start_thread(StartThreadOptions {
metrics_service_name: Some("codex_work_desktop".to_string()),
environments: Some(Vec::new()),
..StartThreadOptions::new(harness.config.clone())
})
.await
.expect("parent thread should start");
let parent_originator = persisted_originator(&parent.thread).await;
assert_eq!(parent_originator, "codex_work_desktop");
let child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: parent.thread_id,
depth: 1,
agent_path: None,
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 child_originator = persisted_originator(&child_thread).await;
assert_eq!(child_originator, parent_originator);
let child = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("hello forked child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: parent.thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some("spawn-call-last-n".to_string()),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(1)),
..Default::default()
},
)
.await
.expect("forked child spawn should succeed");
let child_thread = harness
.manager
.get_thread(child.thread_id)
.await
.expect("child thread should be registered");
let child_originator = persisted_originator(&child_thread).await;
assert_eq!(child_originator, parent_originator);
}
#[tokio::test]
async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() {
let mut harness = AgentControlHarness::new().await;
harness.config.agent_roles.insert(
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research role".to_string()),
config_file: None,
nickname_candidates: Some(vec!["Atlas".to_string()]),
},
);
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_path: None,
agent_nickname: None,
agent_role: Some("researcher".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 { agent_nickname, .. }) =
snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
};
assert_eq!(agent_nickname, Some("Atlas".to_string()));
}
#[tokio::test]
async fn resume_thread_subagent_restores_stored_metadata() {
let (home, config) = test_config().await;
let thread_store = Arc::new(InMemoryThreadStore::default());
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy"));
let manager = ThreadManager::new(
&config,
auth_manager.clone(),
crate::thread_manager::build_models_manager(&config, auth_manager),
crate::CodexAppsToolsCache::default(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
crate::thread_manager::passthrough_image_store(),
thread_store.clone(),
/*agent_graph_store*/ None,
uuid::Uuid::new_v4().to_string(),
/*attestation_provider*/ None,
/*external_time_provider*/ None,
);
let control = manager.agent_control();
let harness = AgentControlHarness {
_home: home,
config,
state_db: None,
manager,
control,
};
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let agent_path = AgentPath::from_string("/root/explorer".to_string())
.expect("test agent path should be valid");
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_path: Some(agent_path.clone()),
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");
child_thread
.session
.ensure_rollout_materialized(PersistContext::Standard)
.await;
child_thread
.session
.flush_rollout()
.await
.expect("flush child rollout");
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");
timeout(Duration::from_secs(5), async {
loop {
if let Ok(stored_thread) = thread_store
.read_thread(ReadThreadParams {
thread_id: child_thread_id,
include_archived: true,
include_history: false,
})
.await
&& stored_thread.agent_nickname.is_some()
&& stored_thread.agent_role.as_deref() == Some("explorer")
&& stored_thread.agent_path.as_deref() == Some(agent_path.as_str())
{
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("child thread metadata should be persisted to sqlite before shutdown");
let _ = harness
.control
.shutdown_live_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_path: None,
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_path: resumed_agent_path,
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_agent_path, Some(agent_path));
assert_eq!(resumed_nickname, Some(original_nickname));
assert_eq!(resumed_role, Some("explorer".to_string()));
let _ = harness
.control
.shutdown_live_agent(resumed_thread_id)
.await
.expect("resumed child shutdown should submit");
}
#[tokio::test]
async fn resume_agent_from_rollout_reads_archived_rollout_path() {
let harness = AgentControlHarness::new().await;
let child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello"),
/*session_source*/ None,
)
.await
.expect("child spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
persist_thread_for_tree_resume(&child_thread, "persist before archiving").await;
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should succeed");
let store = LocalThreadStore::new(
LocalThreadStoreConfig::from_config(&harness.config),
harness.state_db.clone(),
);
store
.archive_thread(ArchiveThreadParams {
thread_id: child_thread_id,
})
.await
.expect("child thread should archive");
let resumed_thread_id = harness
.control
.resume_agent_from_rollout(harness.config.clone(), child_thread_id, SessionSource::Exec)
.await
.expect("resume should find archived rollout");
assert_eq!(resumed_thread_id, child_thread_id);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("resumed child shutdown should succeed");
}
#[tokio::test]
async fn resume_agent_from_paginated_rollout_loads_model_context() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await;
let child_thread_id = harness
.spawn_anonymous_child(
parent_thread_id,
SpawnAgentOptions {
parent_thread_id: Some(parent_thread_id),
..Default::default()
},
)
.await;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
assert_eq!(
child_thread.config_snapshot().await.history_mode,
ThreadHistoryMode::Paginated
);
persist_thread_for_tree_resume(&child_thread, "persist before resume").await;
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should succeed");
let resumed_thread_id = harness
.control
.resume_agent_from_rollout(harness.config.clone(), child_thread_id, SessionSource::Exec)
.await
.expect("resume should load paginated model context");
assert_eq!(resumed_thread_id, child_thread_id);
let resumed_thread = harness
.manager
.get_thread(resumed_thread_id)
.await
.expect("resumed child thread should exist");
assert!(
history_contains_text(
resumed_thread.session.clone_history().await.raw_items(),
"persist before resume",
),
"resumed child should keep its persisted model context"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("resumed child shutdown should succeed");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let worker_path = AgentPath::root().join("worker").expect("worker path");
let reviewer_path = AgentPath::root().join("reviewer").expect("reviewer path");
let worker_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello worker"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(worker_path.clone()),
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("worker spawn should succeed");
let worker_child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello worker child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: Some(
worker_path
.join("child")
.expect("worker child path should be valid"),
),
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("worker child spawn should succeed");
let no_path_child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello anonymous child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: worker_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("no-path child spawn should succeed");
let no_path_grandchild_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello anonymous grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: no_path_child_thread_id,
depth: 3,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("no-path grandchild spawn should succeed");
let _reviewer_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello reviewer"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(reviewer_path),
agent_nickname: None,
agent_role: Some("reviewer".to_string()),
})),
)
.await
.expect("reviewer spawn should succeed");
let _ = harness
.control
.shutdown_live_agent(no_path_grandchild_thread_id)
.await
.expect("no-path grandchild shutdown should succeed");
let mut worker_subtree_thread_ids = harness
.manager
.list_agent_subtree_thread_ids(worker_thread_id)
.await
.expect("worker subtree thread ids should load");
worker_subtree_thread_ids.sort_by_key(ToString::to_string);
let mut expected_worker_subtree_thread_ids = vec![
worker_thread_id,
worker_child_thread_id,
no_path_child_thread_id,
no_path_grandchild_thread_id,
];
expected_worker_subtree_thread_ids.sort_by_key(ToString::to_string);
assert_eq!(
worker_subtree_thread_ids,
expected_worker_subtree_thread_ids
);
let mut no_path_child_subtree_thread_ids = harness
.manager
.list_agent_subtree_thread_ids(no_path_child_thread_id)
.await
.expect("no-path subtree thread ids should load");
no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string);
let mut expected_no_path_child_subtree_thread_ids =
vec![no_path_child_thread_id, no_path_grandchild_thread_id];
expected_no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string);
assert_eq!(
no_path_child_subtree_thread_ids,
expected_no_path_child_subtree_thread_ids
);
}
#[tokio::test]
async fn list_agent_subtree_thread_ids_finds_live_descendants_of_unloaded_root() {
let (_home, config) = test_config().await;
let manager = ThreadManager::with_models_provider_home_and_state_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
/*state_db*/ None,
);
let control = manager.agent_control();
let parent_thread_id = manager
.start_thread(StartThreadOptions::new(config.clone()))
.await
.expect("parent should start")
.thread_id;
let child_thread_id = control
.spawn_agent(
config.clone(),
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let grandchild_thread_id = control
.spawn_agent(
config,
text_input("hello grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("grandchild spawn should succeed");
manager.remove_thread(&parent_thread_id).await;
let mut subtree_thread_ids = manager
.list_agent_subtree_thread_ids(parent_thread_id)
.await
.expect("live subtree should load");
subtree_thread_ids.sort_by_key(ToString::to_string);
let mut expected_subtree_thread_ids =
vec![parent_thread_id, child_thread_id, grandchild_thread_id];
expected_subtree_thread_ids.sort_by_key(ToString::to_string);
assert_eq!(subtree_thread_ids, expected_subtree_thread_ids);
}
#[tokio::test]
async fn shutdown_agent_tree_closes_live_descendants() {
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_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let grandchild_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("grandchild spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let grandchild_thread = harness
.manager
.get_thread(grandchild_thread_id)
.await
.expect("grandchild thread should exist");
persist_thread_for_tree_resume(&child_thread, "child persisted").await;
persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await;
wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id])
.await;
wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id])
.await;
let _ = harness
.control
.shutdown_agent_tree(parent_thread_id)
.await
.expect("tree shutdown should succeed");
assert_eq!(
harness.control.get_status(parent_thread_id).await,
AgentStatus::NotFound
);
assert_eq!(
harness.control.get_status(child_thread_id).await,
AgentStatus::NotFound
);
assert_eq!(
harness.control.get_status(grandchild_thread_id).await,
AgentStatus::NotFound
);
let shutdown_ids = harness
.manager
.captured_ops()
.into_iter()
.filter_map(|(thread_id, op)| matches!(op, Op::Shutdown).then_some(thread_id))
.collect::<Vec<_>>();
let mut expected_shutdown_ids = vec![parent_thread_id, child_thread_id, grandchild_thread_id];
expected_shutdown_ids.sort_by_key(std::string::ToString::to_string);
let mut shutdown_ids = shutdown_ids;
shutdown_ids.sort_by_key(std::string::ToString::to_string);
assert_eq!(shutdown_ids, expected_shutdown_ids);
}
#[tokio::test]
async fn shutdown_agent_tree_closes_descendants_when_started_at_child() {
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_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let grandchild_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("grandchild spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let grandchild_thread = harness
.manager
.get_thread(grandchild_thread_id)
.await
.expect("grandchild thread should exist");
persist_thread_for_tree_resume(&child_thread, "child persisted").await;
persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await;
wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id])
.await;
wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id])
.await;
let _ = harness
.control
.close_agent(child_thread_id)
.await
.expect("child close should succeed");
let _ = harness
.control
.shutdown_agent_tree(parent_thread_id)
.await
.expect("tree shutdown should succeed");
assert_eq!(
harness.control.get_status(child_thread_id).await,
AgentStatus::NotFound
);
assert_eq!(
harness.control.get_status(grandchild_thread_id).await,
AgentStatus::NotFound
);
assert_eq!(
harness.control.get_status(parent_thread_id).await,
AgentStatus::NotFound
);
let shutdown_ids = harness
.manager
.captured_ops()
.into_iter()
.filter_map(|(thread_id, op)| matches!(op, Op::Shutdown).then_some(thread_id))
.collect::<Vec<_>>();
let mut expected_shutdown_ids = vec![parent_thread_id, child_thread_id, grandchild_thread_id];
expected_shutdown_ids.sort_by_key(std::string::ToString::to_string);
let mut shutdown_ids = shutdown_ids;
shutdown_ids.sort_by_key(std::string::ToString::to_string);
assert_eq!(shutdown_ids, expected_shutdown_ids);
}
#[tokio::test]
async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() {
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_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let grandchild_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("grandchild spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let grandchild_thread = harness
.manager
.get_thread(grandchild_thread_id)
.await
.expect("grandchild thread should exist");
persist_thread_for_tree_resume(&parent_thread, "parent persisted").await;
persist_thread_for_tree_resume(&child_thread, "child persisted").await;
persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await;
wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id])
.await;
wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id])
.await;
let _ = harness
.control
.close_agent(child_thread_id)
.await
.expect("child close should succeed");
let _ = harness
.control
.shutdown_live_agent(parent_thread_id)
.await
.expect("parent shutdown should succeed");
let resumed_parent_thread_id = harness
.control
.resume_agent_from_rollout(
harness.config.clone(),
parent_thread_id,
SessionSource::Exec,
)
.await
.expect("single-thread resume should succeed");
assert_eq!(resumed_parent_thread_id, parent_thread_id);
assert_ne!(
harness.control.get_status(parent_thread_id).await,
AgentStatus::NotFound
);
assert_eq!(
harness.control.get_status(child_thread_id).await,
AgentStatus::NotFound
);
assert_eq!(
harness.control.get_status(grandchild_thread_id).await,
AgentStatus::NotFound
);
let _ = harness
.control
.shutdown_agent_tree(parent_thread_id)
.await
.expect("tree shutdown after resume should succeed");
}
#[tokio::test]
async fn resume_closed_child_reopens_open_descendants() {
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_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let grandchild_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("grandchild spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let grandchild_thread = harness
.manager
.get_thread(grandchild_thread_id)
.await
.expect("grandchild thread should exist");
persist_thread_for_tree_resume(&parent_thread, "parent persisted").await;
persist_thread_for_tree_resume(&child_thread, "child persisted").await;
persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await;
wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id])
.await;
wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id])
.await;
let _ = harness
.control
.close_agent(child_thread_id)
.await
.expect("child close should succeed");
let resumed_child_thread_id = harness
.control
.resume_agent_from_rollout(
harness.config.clone(),
child_thread_id,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
}),
)
.await
.expect("child resume should succeed");
assert_eq!(resumed_child_thread_id, child_thread_id);
assert_ne!(
harness.control.get_status(child_thread_id).await,
AgentStatus::NotFound
);
assert_ne!(
harness.control.get_status(grandchild_thread_id).await,
AgentStatus::NotFound
);
let _ = harness
.control
.close_agent(child_thread_id)
.await
.expect("child close after resume should succeed");
let _ = harness
.control
.shutdown_live_agent(parent_thread_id)
.await
.expect("parent shutdown should succeed");
}
#[tokio::test]
async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdown() {
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_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let grandchild_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("grandchild spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let grandchild_thread = harness
.manager
.get_thread(grandchild_thread_id)
.await
.expect("grandchild thread should exist");
persist_thread_for_tree_resume(&parent_thread, "parent persisted").await;
persist_thread_for_tree_resume(&child_thread, "child persisted").await;
persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await;
wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id])
.await;
wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id])
.await;
let report = harness
.manager
.shutdown_all_threads_bounded(Duration::from_secs(5))
.await;
assert_eq!(report.submit_failed, Vec::<ThreadId>::new());
assert_eq!(report.timed_out, Vec::<ThreadId>::new());
let resumed_parent_thread_id = harness
.control
.resume_agent_from_rollout(
harness.config.clone(),
parent_thread_id,
SessionSource::Exec,
)
.await
.expect("tree resume should succeed");
assert_eq!(resumed_parent_thread_id, parent_thread_id);
assert_ne!(
harness.control.get_status(parent_thread_id).await,
AgentStatus::NotFound
);
assert_ne!(
harness.control.get_status(child_thread_id).await,
AgentStatus::NotFound
);
assert_ne!(
harness.control.get_status(grandchild_thread_id).await,
AgentStatus::NotFound
);
let _ = harness
.control
.shutdown_agent_tree(parent_thread_id)
.await
.expect("tree shutdown after subtree resume should succeed");
}
#[tokio::test]
async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_source_is_stale() {
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_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let grandchild_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("grandchild spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let grandchild_thread = harness
.manager
.get_thread(grandchild_thread_id)
.await
.expect("grandchild thread should exist");
persist_thread_for_tree_resume(&parent_thread, "parent persisted").await;
persist_thread_for_tree_resume(&child_thread, "child persisted").await;
persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await;
wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id])
.await;
wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id])
.await;
let state_db = grandchild_thread
.state_db()
.expect("sqlite state db should be available");
let mut stale_metadata = state_db
.get_thread(grandchild_thread_id)
.await
.expect("grandchild metadata query should succeed")
.expect("grandchild metadata should exist");
stale_metadata.source =
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 99,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
}))
.expect("stale session source should serialize");
state_db
.upsert_thread(&stale_metadata)
.await
.expect("stale grandchild metadata should persist");
let report = harness
.manager
.shutdown_all_threads_bounded(Duration::from_secs(5))
.await;
assert_eq!(report.submit_failed, Vec::<ThreadId>::new());
assert_eq!(report.timed_out, Vec::<ThreadId>::new());
let resumed_parent_thread_id = harness
.control
.resume_agent_from_rollout(
harness.config.clone(),
parent_thread_id,
SessionSource::Exec,
)
.await
.expect("tree resume should succeed");
assert_eq!(resumed_parent_thread_id, parent_thread_id);
assert_ne!(
harness.control.get_status(parent_thread_id).await,
AgentStatus::NotFound
);
assert_ne!(
harness.control.get_status(child_thread_id).await,
AgentStatus::NotFound
);
assert_ne!(
harness.control.get_status(grandchild_thread_id).await,
AgentStatus::NotFound
);
let resumed_grandchild_snapshot = harness
.manager
.get_thread(grandchild_thread_id)
.await
.expect("resumed grandchild thread should exist")
.config_snapshot()
.await;
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: resumed_parent_thread_id,
depth: resumed_depth,
..
}) = resumed_grandchild_snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
};
assert_eq!(resumed_parent_thread_id, child_thread_id);
assert_eq!(resumed_depth, 2);
let _ = harness
.control
.shutdown_agent_tree(parent_thread_id)
.await
.expect("tree shutdown after subtree resume should succeed");
}
#[tokio::test]
async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails() {
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_path: None,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let grandchild_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello grandchild"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: child_thread_id,
depth: 2,
agent_path: None,
agent_nickname: None,
agent_role: Some("worker".to_string()),
})),
)
.await
.expect("grandchild spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let grandchild_thread = harness
.manager
.get_thread(grandchild_thread_id)
.await
.expect("grandchild thread should exist");
persist_thread_for_tree_resume(&parent_thread, "parent persisted").await;
persist_thread_for_tree_resume(&child_thread, "child persisted").await;
persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await;
wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id])
.await;
wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id])
.await;
let child_rollout_path = child_thread
.rollout_path()
.expect("child thread should have rollout path");
let report = harness
.manager
.shutdown_all_threads_bounded(Duration::from_secs(5))
.await;
assert_eq!(report.submit_failed, Vec::<ThreadId>::new());
assert_eq!(report.timed_out, Vec::<ThreadId>::new());
tokio::fs::remove_file(&child_rollout_path)
.await
.expect("child rollout path should be removable");
let resumed_parent_thread_id = harness
.control
.resume_agent_from_rollout(
harness.config.clone(),
parent_thread_id,
SessionSource::Exec,
)
.await
.expect("root resume should succeed");
assert_eq!(resumed_parent_thread_id, parent_thread_id);
assert_ne!(
harness.control.get_status(parent_thread_id).await,
AgentStatus::NotFound
);
assert_eq!(
harness.control.get_status(child_thread_id).await,
AgentStatus::NotFound
);
assert_eq!(
harness.control.get_status(grandchild_thread_id).await,
AgentStatus::NotFound
);
let _ = harness
.control
.shutdown_agent_tree(parent_thread_id)
.await
.expect("tree shutdown after partial subtree resume should succeed");
}