Download codex-rs/core/src/agent/control_tests.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 178 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/agent/control_tests.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/core/src/agent/control_tests.rs
-
curl -L -o control_tests.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/agent/control_tests.rs
178 kB
| 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::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::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, | |
| } | |
| } | |
| 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"), | |
| } | |
| } | |
| 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" | |
| ); | |
| } | |
| 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); | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| } | |
| 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)); | |
| } | |
| 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)); | |
| } | |
| async fn on_event_updates_status_from_shutdown_complete() { | |
| let status = agent_status_from_event(&EventMsg::ShutdownComplete); | |
| assert_eq!(status, Some(AgentStatus::Shutdown)); | |
| } | |
| 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" | |
| ); | |
| } | |
| 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" | |
| ); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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); | |
| } | |
| 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; | |
| } | |
| 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 | |
| )); | |
| } | |
| async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { | |
| check_v2_agent_reload(V2ReloadRoute::Sender).await; | |
| } | |
| async fn ensure_v2_child_loaded_preserves_evicted_parent_authority() { | |
| check_v2_agent_reload(V2ReloadRoute::NestedParent).await; | |
| } | |
| 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 thread_config = environment.config().clone(); | |
| let mut owner_config = thread_config.clone(); | |
| owner_config.allow_login_shell = false; | |
| let mut selection = environment.selection(); | |
| selection.config = EnvironmentConfigState::Ready(owner_config); | |
| parent_thread | |
| .session | |
| .services | |
| .turn_environments | |
| .update_selections(std::slice::from_ref(&selection), &thread_config); | |
| parent_turn = parent_thread.session.new_default_turn().await; | |
| 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"); | |
| 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()); | |
| } | |
| 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(), | |
| } | |
| }) | |
| } | |
| } | |
| 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), | |
| ); | |
| } | |
| 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; | |
| } | |
| 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" | |
| ); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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, | |
| }) | |
| ); | |
| } | |
| 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"); | |
| } | |
| 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::unmarked( | |
| "Child subagent guidance.", | |
| )), | |
| ]; | |
| 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"); | |
| } | |
| 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 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(MultiAgentRoleInstructions::catalog( | |
| "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 mut retained_context = codex_history::RetainedContext::default(); | |
| retained_context.record(&answer_event); | |
| parent_thread | |
| .session | |
| .persist_rollout_items(&[ | |
| RolloutItem::Compacted(CompactedItem { | |
| message: String::new(), | |
| replacement_history: Some( | |
| replacement_history.into_iter().map(Into::into).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::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(MultiAgentRoleInstructions::catalog( | |
| "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. | |
| 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. | |
| 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"); | |
| } | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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); | |
| } | |
| 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())); | |
| } | |
| 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, | |
| ) | |
| )); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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())); | |
| } | |
| 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); | |
| } | |
| 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())); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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 | |
| ); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |
| 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"); | |
| } | |