| 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, |
| } |
| } |
|
|
| #[test] |
| fn register_session_root_skips_threads_with_explicit_parent() { |
| let control = AgentControl::default(); |
|
|
| control.register_session_root(ThreadId::new(), Some(ThreadId::new())); |
|
|
| assert_eq!(control.state.agent_id_for_path(&AgentPath::root()), None); |
| } |
|
|
| fn spawn_agent_call(call_id: &str) -> ResponseItem { |
| ResponseItem::FunctionCall { |
| id: None, |
| name: "spawn_agent".to_string(), |
| namespace: None, |
| arguments: "{}".to_string(), |
| call_id: call_id.to_string(), |
| encrypted_function_args: None, |
| internal_chat_message_metadata_passthrough: None, |
| } |
| } |
|
|
| struct AgentControlHarness { |
| _home: TempDir, |
| config: Config, |
| state_db: Option<StateDbHandle>, |
| manager: ThreadManager, |
| control: AgentControl, |
| } |
|
|
| impl AgentControlHarness { |
| async fn new() -> Self { |
| let (home, config) = test_config().await; |
| Self::new_with_config(home, config).await |
| } |
|
|
| async fn new_with_config(home: TempDir, config: Config) -> Self { |
| let state_db = init_state_db(&config).await; |
| let manager = ThreadManager::with_models_provider_home_and_state_for_tests( |
| CodexAuth::from_api_key("dummy"), |
| config.model_provider.clone(), |
| config.codex_home.to_path_buf(), |
| std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| state_db.clone(), |
| ); |
| let control = manager.agent_control(); |
| Self { |
| _home: home, |
| config, |
| state_db, |
| manager, |
| control, |
| } |
| } |
|
|
| async fn start_thread(&self) -> (ThreadId, Arc<CodexThread>) { |
| let new_thread = self |
| .manager |
| .start_thread(StartThreadOptions::new(self.config.clone())) |
| .await |
| .expect("start thread"); |
| (new_thread.thread_id, new_thread.thread) |
| } |
|
|
| async fn start_paginated_thread(&self) -> (ThreadId, Arc<CodexThread>) { |
| let new_thread = self |
| .manager |
| .start_thread(StartThreadOptions { |
| history_mode: Some(ThreadHistoryMode::Paginated), |
| environments: Some(Vec::new()), |
| ..StartThreadOptions::new(self.config.clone()) |
| }) |
| .await |
| .expect("start paginated thread"); |
| (new_thread.thread_id, new_thread.thread) |
| } |
|
|
| async fn spawn_anonymous_child( |
| &self, |
| parent_thread_id: ThreadId, |
| options: SpawnAgentOptions, |
| ) -> ThreadId { |
| self.control |
| .spawn_agent_with_metadata( |
| self.config.clone(), |
| text_input("child task"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| })), |
| options, |
| ) |
| .await |
| .expect("child spawn should succeed") |
| .thread_id |
| } |
| } |
|
|
| async fn persisted_originator(thread: &CodexThread) -> String { |
| thread.ensure_rollout_materialized().await; |
| thread |
| .flush_rollout() |
| .await |
| .expect("thread rollout should flush"); |
| let stored_thread = thread |
| .read_thread( |
| true, 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, |
| }) |
| }) |
| } |
|
|
| |
| 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; |
| } |
| }; |
| |
| |
| timeout(Duration::from_secs(10), wait).await.is_ok() |
| } |
|
|
| async fn persist_thread_for_tree_resume(thread: &Arc<CodexThread>, message: &str) { |
| |
| |
| |
| thread |
| .session |
| .abort_all_tasks(TurnAbortReason::Interrupted) |
| .await; |
| thread |
| .inject_response_items(vec![user_message(message)]) |
| .await |
| .expect("inject thread resume context"); |
| thread |
| .session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| thread |
| .session |
| .flush_rollout() |
| .await |
| .expect("test thread rollout should flush"); |
| } |
|
|
| async fn wait_for_live_thread_spawn_children( |
| control: &AgentControl, |
| parent_thread_id: ThreadId, |
| expected_children: &[ThreadId], |
| ) { |
| let mut expected_children = expected_children.to_vec(); |
| expected_children.sort_by_key(std::string::ToString::to_string); |
|
|
| timeout(Duration::from_secs(5), async { |
| loop { |
| let mut child_ids = control |
| .open_thread_spawn_children(parent_thread_id) |
| .await |
| .expect("live child list should load") |
| .into_iter() |
| .map(|(thread_id, _)| thread_id) |
| .collect::<Vec<_>>(); |
| child_ids.sort_by_key(std::string::ToString::to_string); |
| if child_ids == expected_children { |
| break; |
| } |
| sleep(Duration::from_millis(25)).await; |
| } |
| }) |
| .await |
| .expect("expected persisted child tree"); |
| } |
|
|
| async fn assert_thread_not_loaded(manager: &ThreadManager, thread_id: ThreadId) { |
| match manager.get_thread(thread_id).await { |
| Err(err) => match err.details() { |
| CodexErrorDetails::ThreadNotFound(id) => assert_eq!(*id, thread_id), |
| _ => panic!("expected ThreadNotFound, got {err:?}"), |
| }, |
| Ok(_) => panic!("expected thread not to be loaded"), |
| } |
| } |
|
|
| #[tokio::test] |
| async fn send_input_errors_when_manager_dropped() { |
| let control = AgentControl::default(); |
| let err = control |
| .send_input( |
| ThreadId::new(), |
| vec![UserInput::Text { |
| text: "hello".to_string(), |
| text_elements: Vec::new(), |
| }], |
| Default::default(), |
| ) |
| .await |
| .expect_err("send_input should fail without a manager"); |
| assert_eq!( |
| err.to_string(), |
| "unsupported operation: thread manager dropped" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn get_status_returns_not_found_without_manager() { |
| let control = AgentControl::default(); |
| let got = control.get_status(ThreadId::new()).await; |
| assert_eq!(got, AgentStatus::NotFound); |
| } |
|
|
| #[tokio::test] |
| async fn on_event_updates_status_from_task_started() { |
| let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { |
| turn_id: "turn-1".to_string(), |
| root_turn_id: None, |
| trace_id: None, |
| started_at: None, |
| model_context_window: None, |
| collaboration_mode_kind: ModeKind::Default, |
| })); |
| assert_eq!(status, Some(AgentStatus::Running)); |
| } |
|
|
| #[tokio::test] |
| async fn on_event_updates_status_from_task_complete() { |
| for (error, expected) in [ |
| (None, AgentStatus::Completed(Some("done".to_string()))), |
| ( |
| Some(ErrorEvent { |
| misalignment: None, |
| message: "denied".to_string(), |
| codex_error_info: None, |
| }), |
| AgentStatus::Errored("denied".to_string()), |
| ), |
| ] { |
| let status = agent_status_from_event(&EventMsg::TurnComplete(TurnCompleteEvent { |
| turn_id: "turn-1".to_string(), |
| started_at: None, |
| last_agent_message: Some("done".to_string()), |
| error, |
| completed_at: None, |
| duration_ms: None, |
| time_to_first_token_ms: None, |
| })); |
| assert_eq!(status, Some(expected)); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn on_event_updates_status_from_error() { |
| let status = agent_status_from_event(&EventMsg::Error(ErrorEvent { |
| misalignment: None, |
| message: "boom".to_string(), |
| codex_error_info: None, |
| })); |
|
|
| let expected = AgentStatus::Errored("boom".to_string()); |
| assert_eq!(status, Some(expected)); |
| } |
|
|
| #[tokio::test] |
| async fn on_event_updates_status_from_turn_aborted() { |
| let status = agent_status_from_event(&EventMsg::TurnAborted(TurnAbortedEvent { |
| turn_id: Some("turn-1".to_string()), |
| started_at: None, |
| reason: TurnAbortReason::Interrupted, |
| completed_at: None, |
| duration_ms: None, |
| })); |
|
|
| let expected = AgentStatus::Interrupted; |
| assert_eq!(status, Some(expected)); |
| } |
|
|
| #[tokio::test] |
| async fn on_event_updates_status_from_shutdown_complete() { |
| let status = agent_status_from_event(&EventMsg::ShutdownComplete); |
| assert_eq!(status, Some(AgentStatus::Shutdown)); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_errors_when_manager_dropped() { |
| let control = AgentControl::default(); |
| let (_home, config) = test_config().await; |
| let err = control |
| .spawn_agent(config, text_input("hello"), None) |
| .await |
| .expect_err("spawn_agent should fail without a manager"); |
| assert_eq!( |
| err.to_string(), |
| "unsupported operation: thread manager dropped" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_errors_when_manager_dropped() { |
| let control = AgentControl::default(); |
| let (_home, config) = test_config().await; |
| let err = control |
| .resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec) |
| .await |
| .expect_err("resume_agent should fail without a manager"); |
| assert_eq!( |
| err.to_string(), |
| "unsupported operation: thread manager dropped" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn send_input_errors_when_thread_missing() { |
| let harness = AgentControlHarness::new().await; |
| let thread_id = ThreadId::new(); |
| let err = harness |
| .control |
| .send_input( |
| thread_id, |
| vec![UserInput::Text { |
| text: "hello".to_string(), |
| text_elements: Vec::new(), |
| }], |
| Default::default(), |
| ) |
| .await |
| .expect_err("send_input should fail for missing thread"); |
| assert_matches!( |
| err.details(), |
| CodexErrorDetails::ThreadNotFound(id) if *id == thread_id |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn get_status_returns_not_found_for_missing_thread() { |
| let harness = AgentControlHarness::new().await; |
| let status = harness.control.get_status(ThreadId::new()).await; |
| assert_eq!(status, AgentStatus::NotFound); |
| } |
|
|
| #[tokio::test] |
| async fn get_status_returns_pending_init_for_new_thread() { |
| let harness = AgentControlHarness::new().await; |
| let (thread_id, _) = harness.start_thread().await; |
| let status = harness.control.get_status(thread_id).await; |
| assert_eq!(status, AgentStatus::PendingInit); |
| } |
|
|
| #[tokio::test] |
| async fn subscribe_status_errors_for_missing_thread() { |
| let harness = AgentControlHarness::new().await; |
| let thread_id = ThreadId::new(); |
| let err = harness |
| .control |
| .subscribe_status(thread_id) |
| .await |
| .expect_err("subscribe_status should fail for missing thread"); |
| assert_matches!( |
| err.details(), |
| CodexErrorDetails::ThreadNotFound(id) if *id == thread_id |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn subscribe_status_updates_on_shutdown() { |
| let harness = AgentControlHarness::new().await; |
| let (thread_id, thread) = harness.start_thread().await; |
| let mut status_rx = harness |
| .control |
| .subscribe_status(thread_id) |
| .await |
| .expect("subscribe_status should succeed"); |
| assert_eq!(status_rx.borrow().clone(), AgentStatus::PendingInit); |
|
|
| let _ = thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("shutdown should submit"); |
|
|
| let _ = status_rx.changed().await; |
| assert_eq!(status_rx.borrow().clone(), AgentStatus::Shutdown); |
| } |
|
|
| #[tokio::test] |
| async fn send_input_submits_user_message() { |
| let harness = AgentControlHarness::new().await; |
| let (thread_id, thread) = harness.start_thread().await; |
|
|
| let submission_id = harness |
| .control |
| .send_input( |
| thread_id, |
| vec![UserInput::Text { |
| text: "hello from tests".to_string(), |
| text_elements: Vec::new(), |
| }], |
| Default::default(), |
| ) |
| .await |
| .expect("send_input should succeed"); |
| assert!(!submission_id.is_empty()); |
| wait_for_recorded_user_message(thread.as_ref(), "hello from tests").await; |
| } |
|
|
| #[tokio::test] |
| async fn send_inter_agent_communication_without_turn_queues_message_without_triggering_turn() { |
| let harness = AgentControlHarness::new().await; |
| let (thread_id, thread) = harness.start_thread().await; |
| let communication = InterAgentCommunication::new( |
| AgentPath::root(), |
| AgentPath::try_from("/root/worker").expect("agent path"), |
| Vec::new(), |
| "hello from tests".to_string(), |
| false, |
| ); |
|
|
| let submission_id = harness |
| .control |
| .send_inter_agent_communication( |
| thread_id, |
| communication.clone(), |
| AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()), |
| Default::default(), |
| ) |
| .await |
| .expect("send_inter_agent_communication should succeed"); |
| assert!(!submission_id.is_empty()); |
|
|
| let expected = ( |
| thread_id, |
| Op::InterAgentCommunication { |
| communication: communication.clone(), |
| start_options: Default::default(), |
| }, |
| ); |
| let captured = harness |
| .manager |
| .captured_ops() |
| .into_iter() |
| .find(|entry| captured_op_matches(entry, &expected)); |
| assert!(captured.is_some()); |
|
|
| timeout(Duration::from_secs(5), async { |
| loop { |
| if thread |
| .session |
| .input_queue |
| .has_pending_input(&thread.session.active_turn) |
| .await |
| { |
| break; |
| } |
| sleep(Duration::from_millis(10)).await; |
| } |
| }) |
| .await |
| .expect("inter-agent communication should stay pending"); |
|
|
| let history = thread.session.clone_history().await; |
| assert!(!history_contains_assistant_inter_agent_communication( |
| history.raw_items(), |
| &communication |
| )); |
| } |
|
|
| #[tokio::test] |
| async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { |
| check_v2_agent_reload(V2ReloadRoute::Sender).await; |
| } |
|
|
| #[tokio::test] |
| async fn ensure_v2_child_loaded_preserves_evicted_parent_authority() { |
| check_v2_agent_reload(V2ReloadRoute::NestedParent).await; |
| } |
|
|
| #[derive(Clone, Copy)] |
| enum V2ReloadRoute { |
| Sender, |
| NestedParent, |
| } |
|
|
| async fn spawn_v2_reload_test_child( |
| control: &AgentControl, |
| config: Config, |
| parent: &CodexThread, |
| task_name: &str, |
| ) -> LiveAgent { |
| let source = thread_spawn_source( |
| parent.session.thread_id, |
| &parent.session_source, |
| next_thread_spawn_depth(&parent.session_source), |
| 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( |
| true, 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, 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(); |
| |
| *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(), |
| false, |
| ); |
| control |
| .send_inter_agent_communication( |
| spawned_agent.thread_id, |
| communication.clone(), |
| AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()), |
| Default::default(), |
| ) |
| .await |
| .expect("send_inter_agent_communication should succeed after reload"); |
| let expected = ( |
| spawned_agent.thread_id, |
| Op::InterAgentCommunication { |
| communication, |
| start_options: Default::default(), |
| }, |
| ); |
| let captured = harness |
| .manager |
| .captured_ops() |
| .into_iter() |
| .find(|entry| captured_op_matches(entry, &expected)); |
| assert!(captured.is_some()); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_from_rollout_does_not_reopen_v2_descendants() { |
| let (home, mut config) = test_config().await; |
| let _ = config.features.enable(Feature::MultiAgentV2); |
| let _ = config.features.enable(Feature::Sqlite); |
| let harness = AgentControlHarness::new_with_config(home, config).await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
| let worker_path = AgentPath::root().join("worker").expect("worker path"); |
| let worker_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello worker"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: Some(worker_path.clone()), |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("worker spawn should succeed"); |
| let reviewer_path = worker_path.join("reviewer").expect("reviewer path"); |
| let reviewer_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello reviewer"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: worker_thread_id, |
| depth: 2, |
| agent_path: Some(reviewer_path.clone()), |
| agent_nickname: None, |
| agent_role: Some("reviewer".to_string()), |
| })), |
| ) |
| .await |
| .expect("reviewer spawn should succeed"); |
| let sibling_thread_id = harness |
| .spawn_anonymous_child(parent_thread_id, SpawnAgentOptions::default()) |
| .await; |
|
|
| let worker_thread = harness |
| .manager |
| .get_thread(worker_thread_id) |
| .await |
| .expect("worker thread should exist"); |
| let reviewer_thread = harness |
| .manager |
| .get_thread(reviewer_thread_id) |
| .await |
| .expect("reviewer thread should exist"); |
| let sibling_thread = harness |
| .manager |
| .get_thread(sibling_thread_id) |
| .await |
| .expect("sibling thread should exist"); |
| persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; |
| persist_thread_for_tree_resume(&worker_thread, "worker persisted").await; |
| persist_thread_for_tree_resume(&reviewer_thread, "reviewer persisted").await; |
| persist_thread_for_tree_resume(&sibling_thread, "sibling persisted").await; |
| wait_for_live_thread_spawn_children( |
| &harness.control, |
| parent_thread_id, |
| &[worker_thread_id, sibling_thread_id], |
| ) |
| .await; |
| wait_for_live_thread_spawn_children(&harness.control, worker_thread_id, &[reviewer_thread_id]) |
| .await; |
|
|
| let report = harness |
| .manager |
| .shutdown_all_threads_bounded(Duration::from_secs(5)) |
| .await; |
| assert_eq!(report.submit_failed, Vec::<ThreadId>::new()); |
| assert_eq!(report.timed_out, Vec::<ThreadId>::new()); |
|
|
| let resumed_manager = ThreadManager::with_models_provider_home_and_state_for_tests( |
| CodexAuth::from_api_key("dummy"), |
| harness.config.model_provider.clone(), |
| harness.config.codex_home.to_path_buf(), |
| std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| harness.state_db.clone(), |
| ); |
| let resumed_control = resumed_manager.agent_control(); |
| let resumed_parent_thread_id = resumed_control |
| .resume_agent_from_rollout( |
| harness.config.clone(), |
| parent_thread_id, |
| SessionSource::Exec, |
| ) |
| .await |
| .expect("v2 root resume should succeed"); |
| assert_eq!(resumed_parent_thread_id, parent_thread_id); |
| assert_ne!( |
| resumed_control.get_status(parent_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_thread_not_loaded(&resumed_manager, worker_thread_id).await; |
| assert_thread_not_loaded(&resumed_manager, reviewer_thread_id).await; |
| assert_thread_not_loaded(&resumed_manager, sibling_thread_id).await; |
| resumed_control |
| .restore_v2_agent_metadata(&harness.config, parent_thread_id) |
| .await; |
| for thread_id in [worker_thread_id, sibling_thread_id] { |
| assert!(resumed_control.ensure_agent_known(thread_id).is_ok()); |
| } |
|
|
| resumed_control |
| .close_agent(worker_thread_id) |
| .await |
| .expect("closing a restored sibling should succeed"); |
|
|
| let closed_worker = resumed_control.ensure_agent_known(worker_thread_id); |
| let surviving_sibling = resumed_control.ensure_agent_known(sibling_thread_id); |
| assert!(closed_worker.is_err()); |
| assert!(surviving_sibling.is_ok()); |
| assert_thread_not_loaded(&resumed_manager, sibling_thread_id).await; |
| } |
|
|
| struct StaticThreadInstructionsProvider(&'static str); |
|
|
| impl ThreadInstructionsProvider for StaticThreadInstructionsProvider { |
| fn load_thread_instructions(&self) -> LoadInstructionsFuture<'_> { |
| let instructions = Instructions { |
| text: self.0.to_string(), |
| source: None, |
| }; |
| Box::pin(async move { |
| LoadedUserInstructions { |
| instructions: Some(instructions), |
| warnings: Vec::new(), |
| } |
| }) |
| } |
| } |
|
|
| #[tokio::test] |
| async fn cold_resume_with_thread_instructions_preserves_lazy_v2_child_inheritance() { |
| let (home, mut config) = test_config().await; |
| let _ = config.features.enable(Feature::MultiAgentV2); |
| let _ = config.features.enable(Feature::Sqlite); |
| let harness = AgentControlHarness::new_with_config(home, config).await; |
| let parent = harness |
| .manager |
| .start_thread(StartThreadOptions { |
| thread_instructions_provider: Some(Arc::new(StaticThreadInstructionsProvider( |
| "initial thread instructions", |
| ))), |
| ..StartThreadOptions::new(harness.config.clone()) |
| }) |
| .await |
| .expect("start parent with thread instructions"); |
| let parent_thread_id = parent.thread_id; |
| let control = &parent.thread.session.services.agent_control; |
| let worker_thread_id = |
| spawn_v2_reload_test_child(control, harness.config.clone(), &parent.thread, "worker") |
| .await |
| .thread_id; |
| let worker = harness |
| .manager |
| .get_thread(worker_thread_id) |
| .await |
| .expect("worker should be loaded"); |
| persist_thread_for_tree_resume(&parent.thread, "parent persisted").await; |
| persist_thread_for_tree_resume(&worker, "worker persisted").await; |
| wait_for_live_thread_spawn_children(control, parent_thread_id, &[worker_thread_id]).await; |
|
|
| let stored_parent = parent |
| .thread |
| .read_thread( |
| true, true, |
| ) |
| .await |
| .expect("read parent history"); |
| let initial_history = InitialHistory::Resumed(ResumedHistory { |
| conversation_id: parent_thread_id, |
| history: Arc::new(stored_parent.history.expect("parent history").items), |
| rollout_path: stored_parent.rollout_path, |
| }); |
| let report = harness |
| .manager |
| .shutdown_all_threads_bounded(Duration::from_secs(5)) |
| .await; |
| assert_eq!(report.submit_failed, Vec::<ThreadId>::new()); |
| assert_eq!(report.timed_out, Vec::<ThreadId>::new()); |
|
|
| let resumed_manager = ThreadManager::with_models_provider_home_and_state_for_tests( |
| CodexAuth::from_api_key("dummy"), |
| harness.config.model_provider.clone(), |
| harness.config.codex_home.to_path_buf(), |
| Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| harness.state_db.clone(), |
| ); |
| let resumed_parent = resumed_manager |
| .start_thread(StartThreadOptions { |
| initial_history, |
| session_source: Some(SessionSource::Exec), |
| thread_instructions_provider: Some(Arc::new(StaticThreadInstructionsProvider( |
| "resumed thread instructions", |
| ))), |
| ..StartThreadOptions::new(harness.config.clone()) |
| }) |
| .await |
| .expect("cold resume parent with updated thread instructions"); |
| let thread_instructions = Instructions { |
| text: "resumed thread instructions".to_string(), |
| source: None, |
| }; |
| assert_eq!( |
| resumed_parent |
| .thread |
| .session |
| .inherited_instructions() |
| .await |
| .thread, |
| Some(thread_instructions.clone()), |
| ); |
| assert_thread_not_loaded(&resumed_manager, worker_thread_id).await; |
|
|
| resumed_manager |
| .ensure_multi_agent_v2_child_loaded(worker_thread_id) |
| .await |
| .expect("resume v2 worker on demand"); |
| let resumed_worker = resumed_manager |
| .get_thread(worker_thread_id) |
| .await |
| .expect("resumed worker should be loaded"); |
| assert_eq!( |
| resumed_worker.session.inherited_instructions().await.thread, |
| Some(thread_instructions), |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_creates_thread_and_sends_prompt() { |
| let harness = AgentControlHarness::new().await; |
| let thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("spawned"), |
| None, |
| ) |
| .await |
| .expect("spawn_agent should succeed"); |
| let thread = harness |
| .manager |
| .get_thread(thread_id) |
| .await |
| .expect("thread should be registered"); |
| wait_for_recorded_user_message(thread.as_ref(), "spawned").await; |
| } |
|
|
| #[tokio::test] |
| async fn ephemeral_spawn_does_not_persist_agent_graph_edge() { |
| let (home, mut config) = test_config().await; |
| config.ephemeral = true; |
| let harness = AgentControlHarness::new_with_config(home, config).await; |
| let (parent_thread_id, _parent_thread) = harness.start_thread().await; |
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("spawned"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| })), |
| ) |
| .await |
| .expect("ephemeral agent spawn should succeed"); |
|
|
| let persisted_children = harness |
| .state_db |
| .as_ref() |
| .expect("manager should retain state db") |
| .list_thread_spawn_children(parent_thread_id) |
| .await |
| .expect("persisted child list should load"); |
| assert_eq!(persisted_children, Vec::<ThreadId>::new()); |
| assert!( |
| harness.manager.get_thread(child_thread_id).await.is_ok(), |
| "ephemeral child should remain live" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_fork_from_paginated_parent_uses_model_context_prefix() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await; |
| parent_thread |
| .inject_response_items(vec![user_message("paginated parent context")]) |
| .await |
| .expect("inject paginated parent context"); |
| let turn_context = parent_thread.session.new_default_turn().await; |
| let parent_spawn_call_id = "spawn-call-paginated".to_string(); |
| parent_thread |
| .session |
| .record_conversation_items( |
| turn_context.as_ref(), |
| turn_context.model_info(), |
| &[spawn_agent_call(&parent_spawn_call_id)], |
| ) |
| .await; |
| parent_thread |
| .session |
| .persist_rollout_items(&[ |
| rollout_response_item(ResponseItem::Message { |
| id: None, |
| role: "developer".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "id-less inherited context".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }), |
| RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent { |
| thread_id: parent_thread_id, |
| turn_id: "parent-turn".to_string(), |
| item: TurnItem::UserMessage(UserMessageItem { |
| id: "parent-user".to_string(), |
| client_id: None, |
| content: Vec::new(), |
| }), |
| started_at_ms: Some(0), |
| completed_at_ms: 1, |
| })), |
| RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied( |
| ThreadSettingsAppliedEvent { |
| thread_id: Some(parent_thread_id), |
| thread_settings: ThreadSettingsSnapshot { |
| disabled_plugin_ids: Vec::new(), |
| model: "parent-only-model".to_string(), |
| model_provider_id: "parent-only-provider".to_string(), |
| service_tier: None, |
| approval_policy: AskForApproval::Never, |
| approvals_reviewer: ApprovalsReviewer::User, |
| permission_profile: PermissionProfile::workspace_write(), |
| active_permission_profile: None, |
| cwd: harness.config.cwd.clone(), |
| runtime_workspace_roots: None, |
| reasoning_effort: None, |
| reasoning_summary: None, |
| personality: None, |
| collaboration_mode: CollaborationMode { |
| mode: ModeKind::Default, |
| settings: Settings { |
| model: "parent-only-model".to_string(), |
| reasoning_effort: None, |
| developer_instructions: None, |
| }, |
| }, |
| }, |
| }, |
| )), |
| ]) |
| .await; |
|
|
| let child_thread_id = harness |
| .spawn_anonymous_child( |
| parent_thread_id, |
| SpawnAgentOptions { |
| fork_parent_spawn_call_id: Some(parent_spawn_call_id), |
| fork_mode: Some(SpawnAgentForkMode::FullHistory), |
| ..Default::default() |
| }, |
| ) |
| .await; |
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| assert!( |
| history_contains_text( |
| child_thread.session.clone_history().await.raw_items(), |
| "paginated parent context", |
| ), |
| "bounded parent context should remain model-visible to the child" |
| ); |
| child_thread.ensure_rollout_materialized().await; |
| child_thread |
| .flush_rollout() |
| .await |
| .expect("child rollout should flush"); |
| let rollout_path = child_thread |
| .rollout_path() |
| .expect("child rollout should exist"); |
| let lines = std::fs::read_to_string(&rollout_path) |
| .expect("read child rollout") |
| .lines() |
| .map(|line| codex_rollout::parse_rollout_line(line).expect("parse rollout line")) |
| .collect::<Vec<_>>(); |
| let RolloutItem::SessionMeta(meta_line) = &lines[0].item else { |
| panic!("child rollout should start with session metadata"); |
| }; |
| assert_eq!(meta_line.meta.history_mode, ThreadHistoryMode::Paginated); |
| assert_eq!(meta_line.meta.parent_thread_id, Some(parent_thread_id)); |
| assert_eq!(meta_line.meta.forked_from_id, Some(parent_thread_id)); |
| let prefix_end = usize::try_from( |
| meta_line |
| .meta |
| .subagent_history_start_ordinal |
| .expect("paginated child should mark its local history boundary"), |
| ) |
| .expect("history boundary should fit in usize"); |
| let copied_prefix = &lines[1..prefix_end]; |
| let copied_idless_context = copied_prefix |
| .iter() |
| .find_map(|line| match &line.item { |
| RolloutItem::ResponseItem(response_item) |
| if serde_json::to_string(&response_item.item) |
| .expect("serialize response item") |
| .contains("id-less inherited context") => |
| { |
| Some(response_item) |
| } |
| _ => None, |
| }) |
| .expect("copied prefix should contain inherited response item"); |
| assert!( |
| copied_idless_context.id().is_some_and(|id| !id.is_empty()), |
| "copied model context should receive response item ids before persistence" |
| ); |
| let copied_parent_context_count = lines |
| .iter() |
| .filter(|line| { |
| serde_json::to_string(&line.item) |
| .expect("serialize rollout item") |
| .contains("paginated parent context") |
| }) |
| .count(); |
| assert_eq!( |
| copied_parent_context_count, 1, |
| "copied model context should be persisted once" |
| ); |
| assert!( |
| !copied_prefix.iter().any(|line| { |
| matches!( |
| &line.item, |
| RolloutItem::EventMsg( |
| EventMsg::ItemCompleted(_) | EventMsg::ThreadSettingsApplied(_) |
| ) |
| ) |
| }), |
| "copied non-structural presentation and metadata records should not enter the child rollout" |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should submit"); |
| let _ = parent_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("parent shutdown should submit"); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_without_fork_from_paginated_parent_stays_fresh_and_paginated() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await; |
| parent_thread |
| .inject_response_items(vec![user_message("parent-only context")]) |
| .await |
| .expect("inject parent-only context"); |
|
|
| let child_thread_id = harness |
| .spawn_anonymous_child( |
| parent_thread_id, |
| SpawnAgentOptions { |
| parent_thread_id: Some(parent_thread_id), |
| ..Default::default() |
| }, |
| ) |
| .await; |
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| assert!( |
| !history_contains_text( |
| child_thread.session.clone_history().await.raw_items(), |
| "parent-only context", |
| ), |
| "fork_turns=none should not copy parent context" |
| ); |
| child_thread.ensure_rollout_materialized().await; |
| child_thread |
| .flush_rollout() |
| .await |
| .expect("child rollout should flush"); |
| let meta = codex_rollout::read_session_meta_line( |
| &child_thread |
| .rollout_path() |
| .expect("child rollout should exist"), |
| ) |
| .await |
| .expect("read child session metadata"); |
| assert_eq!(meta.meta.history_mode, ThreadHistoryMode::Paginated); |
| assert_eq!(meta.meta.subagent_history_start_ordinal, None); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should submit"); |
| let _ = parent_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("parent shutdown should submit"); |
| } |
|
|
| #[test_case::test_case(true; "thread context enabled")] |
| #[test_case::test_case(false; "thread context disabled")] |
| #[tokio::test] |
| async fn spawn_agent_fork_drops_inherited_token_usage_state(thread_context_enabled: bool) { |
| let mut harness = AgentControlHarness::new().await; |
| let _ = harness.config.features.disable(Feature::MultiAgentV2); |
| harness |
| .config |
| .features |
| .set_enabled(Feature::GuardianThreadContext, thread_context_enabled) |
| .expect("test context mode"); |
| let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await; |
| let parent_usage = TokenUsage { |
| total_tokens: 120, |
| ..TokenUsage::default() |
| }; |
| let parent_record = TokenUsageRecord { |
| thread_id: parent_thread_id, |
| turn_id: "parent-turn".to_string(), |
| session_id: parent_thread.session.session_id(), |
| root_turn_id: "parent-turn".to_string(), |
| response_id: "parent-response".to_string(), |
| usage: parent_usage.clone(), |
| turn_token_usage: parent_usage.clone(), |
| thread_token_usage: parent_usage, |
| }; |
| let parent_spawn_call_id = "spawn-call-token-usage".to_string(); |
| parent_thread |
| .session |
| .persist_rollout_items(&[ |
| RolloutItem::Compacted(CompactedItem { |
| message: String::new(), |
| replacement_history: Some(vec![user_message("compacted parent context").into()]), |
| retained_context: None, |
| guardian_history: None, |
| mcp_resource_origins: None, |
| window_number: None, |
| first_window_id: None, |
| previous_window_id: None, |
| window_id: None, |
| compaction_response_id: None, |
| latest_token_usage_record: Some(parent_record.clone()), |
| }), |
| RolloutItem::TokenUsageRecord(parent_record), |
| rollout_response_item(spawn_agent_call(&parent_spawn_call_id)), |
| ]) |
| .await; |
|
|
| let child_thread_id = harness |
| .spawn_anonymous_child( |
| parent_thread_id, |
| SpawnAgentOptions { |
| fork_parent_spawn_call_id: Some(parent_spawn_call_id), |
| fork_mode: Some(SpawnAgentForkMode::FullHistory), |
| ..Default::default() |
| }, |
| ) |
| .await; |
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
|
|
| let child_usage = TokenUsage { |
| total_tokens: 80, |
| ..TokenUsage::default() |
| }; |
| assert!( |
| !child_thread |
| .session |
| .clone_history() |
| .await |
| .retained_context() |
| .user_messages_complete(), |
| "V1 forks lack complete retained authorization in both context modes" |
| ); |
| let turn_context = child_thread.session.new_default_turn().await; |
| child_thread |
| .session |
| .record_observed_response_completed( |
| turn_context.as_ref(), |
| "child-response", |
| Some(&child_usage), |
| None, |
| ) |
| .await; |
| child_thread |
| .flush_rollout() |
| .await |
| .expect("child rollout should flush"); |
| let rollout_path = child_thread |
| .rollout_path() |
| .expect("child rollout should exist"); |
| let lines = std::fs::read_to_string(&rollout_path) |
| .expect("read child rollout") |
| .lines() |
| .map(|line| codex_rollout::parse_rollout_line(line).expect("parse rollout line")) |
| .collect::<Vec<_>>(); |
| assert!( |
| !lines.iter().any(|line| { |
| matches!( |
| &line.item, |
| RolloutItem::TokenUsageRecord(record) if record.thread_id == parent_thread_id |
| ) |
| }), |
| "child rollout should not inherit parent token usage records" |
| ); |
| assert!( |
| lines.iter().all(|line| { |
| !matches!( |
| &line.item, |
| RolloutItem::Compacted(compacted) |
| if compacted.latest_token_usage_record.is_some() |
| ) |
| }), |
| "child rollout should not inherit parent token usage checkpoints" |
| ); |
| let child_record = lines.iter().rev().find_map(|line| match &line.item { |
| RolloutItem::TokenUsageRecord(record) => Some(record), |
| _ => None, |
| }); |
| assert_eq!( |
| child_record, |
| Some(&TokenUsageRecord { |
| thread_id: child_thread_id, |
| turn_id: turn_context.sub_id.clone(), |
| session_id: child_thread.session.session_id(), |
| root_turn_id: turn_context.sub_id.clone(), |
| response_id: "child-response".to_string(), |
| usage: child_usage.clone(), |
| turn_token_usage: child_usage.clone(), |
| thread_token_usage: child_usage, |
| }) |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_numeric_fork_from_compacted_paginated_parent_clamps_to_provable_turns() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await; |
| let parent_spawn_call_id = "spawn-call-paginated-numeric".to_string(); |
| parent_thread |
| .session |
| .persist_rollout_items(&[ |
| RolloutItem::Compacted(CompactedItem { |
| message: String::new(), |
| replacement_history: Some(vec![ |
| ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "compacted summary".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| } |
| .into(), |
| ]), |
| retained_context: None, |
| guardian_history: None, |
| mcp_resource_origins: None, |
| window_number: None, |
| first_window_id: None, |
| previous_window_id: None, |
| window_id: None, |
| compaction_response_id: None, |
| latest_token_usage_record: None, |
| }), |
| rollout_response_item(ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "recent parent turn".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }), |
| rollout_response_item(spawn_agent_call(&parent_spawn_call_id)), |
| ]) |
| .await; |
|
|
| let clamped_child_thread_id = harness |
| .spawn_anonymous_child( |
| parent_thread_id, |
| SpawnAgentOptions { |
| fork_parent_spawn_call_id: Some(parent_spawn_call_id), |
| fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), |
| ..Default::default() |
| }, |
| ) |
| .await; |
| let clamped_child_thread = harness |
| .manager |
| .get_thread(clamped_child_thread_id) |
| .await |
| .expect("clamped child thread should be registered"); |
| let clamped_history = clamped_child_thread.session.clone_history().await; |
| assert!( |
| history_contains_text(clamped_history.raw_items(), "recent parent turn"), |
| "clamped numeric fork should keep the provable recent turn" |
| ); |
| assert!( |
| !history_contains_text(clamped_history.raw_items(), "compacted summary"), |
| "clamped numeric fork should not expand into compacted parent context" |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(clamped_child_thread_id) |
| .await |
| .expect("clamped child shutdown should submit"); |
| let _ = parent_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("parent shutdown should submit"); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { |
| let managed_fragment = "<managed_developer_instructions>\nParent developer instructions.\n</managed_developer_instructions>"; |
| let persistent_fragment = |
| "<persistent_mode>\nParent developer instructions.\n</persistent_mode>"; |
| let harness = AgentControlHarness::new().await; |
| let mut parent_config = harness.config.clone(); |
| let _ = parent_config.features.enable(Feature::MultiAgentV2); |
| parent_config.developer_instructions = Some("Parent developer instructions.".to_string()); |
| parent_config.multi_agent_v2.root_agent_usage_hint_text = |
| Some("Parent root guidance.".to_string()); |
| parent_config.multi_agent_v2.subagent_usage_hint_text = |
| Some("Parent subagent guidance.".to_string()); |
| let mut child_config = harness.config.clone(); |
| let _ = child_config.features.enable(Feature::MultiAgentV2); |
| child_config.developer_instructions = Some("Child developer instructions.".to_string()); |
| child_config.multi_agent_v2.subagent_developer_instructions = |
| Some("Child developer instructions.".to_string()); |
| child_config.multi_agent_v2.root_agent_usage_hint_text = |
| Some("Child root guidance.".to_string()); |
| child_config.multi_agent_v2.subagent_usage_hint_text = |
| Some("Child subagent guidance.".to_string()); |
| let new_thread = harness |
| .manager |
| .start_thread(StartThreadOptions::new(parent_config.clone())) |
| .await |
| .expect("start parent thread"); |
| let parent_thread_id = new_thread.thread_id; |
| let parent_thread = new_thread.thread; |
| parent_thread |
| .session |
| .inject_no_new_turn( |
| vec![user_message("parent seed context")], |
| 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(), |
| 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", 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"); |
| } |
|
|
| #[test_case::test_case(true; "thread context enabled")] |
| #[test_case::test_case(false; "thread context disabled")] |
| #[tokio::test] |
| async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history( |
| thread_context_enabled: bool, |
| ) { |
| let harness = AgentControlHarness::new().await; |
| let mut parent_config = harness.config.clone(); |
| parent_config |
| .features |
| .set_enabled(Feature::GuardianThreadContext, thread_context_enabled) |
| .expect("test context mode"); |
| let _ = parent_config.features.enable(Feature::MultiAgentV2); |
| parent_config.developer_instructions = Some("Parent developer instructions.".to_string()); |
| parent_config.multi_agent_v2.root_agent_usage_hint_text = |
| Some("Parent root guidance.".to_string()); |
| parent_config.multi_agent_v2.subagent_usage_hint_text = |
| Some("Parent subagent guidance.".to_string()); |
| let mut child_config = harness.config.clone(); |
| child_config |
| .features |
| .set_enabled(Feature::GuardianThreadContext, thread_context_enabled) |
| .expect("test context mode"); |
| let _ = child_config.features.enable(Feature::MultiAgentV2); |
| child_config.developer_instructions = Some("Child developer instructions.".to_string()); |
| child_config.multi_agent_v2.subagent_developer_instructions = |
| Some("Child developer instructions.".to_string()); |
| child_config.multi_agent_v2.root_agent_usage_hint_text = |
| Some("Child root guidance.".to_string()); |
| child_config.multi_agent_v2.subagent_usage_hint_text = |
| Some("Child subagent guidance.".to_string()); |
| let new_thread = harness |
| .manager |
| .start_thread(StartThreadOptions::new(parent_config)) |
| .await |
| .expect("start parent thread"); |
| let parent_thread_id = new_thread.thread_id; |
| let parent_thread = new_thread.thread; |
| let turn_context = parent_thread.session.new_default_turn().await; |
| let parent_spawn_call_id = "spawn-call-compacted-usage-hints".to_string(); |
| let parent_task = InterAgentCommunication::new( |
| AgentPath::root(), |
| AgentPath::root().join("worker").expect("valid worker path"), |
| Vec::new(), |
| "compacted parent delegated task".to_string(), |
| 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"); |
| } |
|
|
| |
| |
| #[tokio::test] |
| async fn spawn_agent_full_fork_restores_instructions_after_compaction_discards_parent_fragment() { |
| let harness = AgentControlHarness::new().await; |
| let mut parent_config = harness.config.clone(); |
| let _ = parent_config.features.enable(Feature::MultiAgentV2); |
| parent_config.developer_instructions = Some("Parent developer instructions.".to_string()); |
| let mut child_config = parent_config.clone(); |
| child_config.developer_instructions = Some("Child developer instructions.".to_string()); |
| child_config.multi_agent_v2.subagent_developer_instructions = |
| Some("Child developer instructions.".to_string()); |
|
|
| let new_thread = harness |
| .manager |
| .start_thread(StartThreadOptions::new(parent_config)) |
| .await |
| .expect("start parent thread"); |
| let parent_thread_id = new_thread.thread_id; |
| let parent_thread = new_thread.thread; |
| let turn_context = parent_thread.session.new_default_turn().await; |
| let parent_spawn_call_id = "spawn-call-compacted-stale-instructions".to_string(); |
| let replacement_history = vec![ |
| ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "compacted parent summary".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }, |
| ResponseItem::Message { |
| id: None, |
| role: "developer".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "Preserved compacted developer context.".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }, |
| ]; |
|
|
| |
| |
| 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"); |
| } |
|
|
| |
| |
| #[tokio::test] |
| async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_once() { |
| let managed_policy = "Managed policy for every agent."; |
| let current_managed_fragment = format!( |
| "<managed_developer_instructions>\n{managed_policy}\n</managed_developer_instructions>" |
| ); |
| let stale_managed_fragment = |
| "<managed_developer_instructions>\nOld managed policy.\n</managed_developer_instructions>"; |
| for (case, parent_developer_instructions) in [ |
| ("without parent instructions", None), |
| ( |
| "with parent instructions", |
| Some("Parent developer instructions."), |
| ), |
| ] { |
| let harness = AgentControlHarness::new().await; |
| let mut parent_config = harness.config.clone(); |
| let _ = parent_config.features.enable(Feature::MultiAgentV2); |
| parent_config.developer_instructions = parent_developer_instructions.map(str::to_string); |
| let mut requirements = parent_config.config_layer_stack.requirements().clone(); |
| requirements.additional_developer_instructions = Some(codex_config::Sourced::new( |
| managed_policy.to_string(), |
| codex_config::RequirementSource::Unknown, |
| )); |
| let mut requirements_toml = parent_config.config_layer_stack.requirements_toml().clone(); |
| requirements_toml.additional_developer_instructions = Some(managed_policy.to_string()); |
| parent_config.config_layer_stack = codex_config::ConfigLayerStack::new( |
| parent_config |
| .config_layer_stack |
| .all_layers_low_to_high() |
| .cloned() |
| .collect(), |
| requirements, |
| requirements_toml, |
| ) |
| .expect("managed requirements stack"); |
| let mut child_config = parent_config.clone(); |
| child_config.developer_instructions = Some("Child developer instructions.".to_string()); |
| child_config.multi_agent_v2.subagent_developer_instructions = |
| Some("Child developer instructions.".to_string()); |
|
|
| let new_thread = harness |
| .manager |
| .start_thread(StartThreadOptions::new(parent_config)) |
| .await |
| .expect("start parent thread"); |
| let parent_thread_id = new_thread.thread_id; |
| let parent_thread = new_thread.thread; |
| let turn_context = parent_thread.session.new_default_turn().await; |
| let parent_spawn_call_id = match parent_developer_instructions { |
| Some(_) => "spawn-call-legacy-compact-with-parent", |
| None => "spawn-call-legacy-compact-without-parent", |
| }; |
| let parent_user_message = ResponseItem::Message { |
| id: None, |
| role: "user".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "parent task before legacy compaction".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }; |
|
|
| |
| |
| parent_thread |
| .session |
| .replace_history( |
| vec![parent_user_message.clone()], |
| Some(turn_context.to_turn_context_item()), |
| ) |
| .await; |
| let mut rollout_items = vec![ |
| rollout_response_item(parent_user_message), |
| RolloutItem::Compacted(CompactedItem { |
| message: "legacy compacted summary".to_string(), |
| replacement_history: None, |
| retained_context: None, |
| guardian_history: None, |
| mcp_resource_origins: None, |
| window_number: None, |
| first_window_id: None, |
| previous_window_id: None, |
| window_id: None, |
| compaction_response_id: None, |
| latest_token_usage_record: None, |
| }), |
| ]; |
| if let Some(instructions) = parent_developer_instructions { |
| rollout_items.push(rollout_response_item(ResponseItem::Message { |
| id: None, |
| role: "developer".to_string(), |
| content: vec![ContentItem::InputText { |
| text: instructions.to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| })); |
| } |
| rollout_items.push(rollout_response_item(ResponseItem::Message { |
| id: None, |
| role: "developer".to_string(), |
| content: vec![ContentItem::InputText { |
| text: stale_managed_fragment.to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| })); |
| rollout_items.push(RolloutItem::TurnContext( |
| turn_context.to_turn_context_item(), |
| )); |
| rollout_items.push(rollout_response_item(spawn_agent_call( |
| parent_spawn_call_id, |
| ))); |
| parent_thread |
| .session |
| .persist_rollout_items(&rollout_items) |
| .await; |
| parent_thread |
| .session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| parent_thread |
| .session |
| .flush_rollout() |
| .await |
| .expect("parent rollout should flush"); |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent_with_metadata( |
| child_config, |
| text_input("child task"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| })), |
| SpawnAgentOptions { |
| fork_parent_spawn_call_id: Some(parent_spawn_call_id.to_string()), |
| fork_mode: Some(SpawnAgentForkMode::FullHistory), |
| ..Default::default() |
| }, |
| ) |
| .await |
| .expect("forked spawn should preserve legacy compacted history") |
| .thread_id; |
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| while child_thread |
| .session |
| .reference_context_item() |
| .await |
| .is_none() |
| { |
| tokio::task::yield_now().await; |
| } |
| let history = child_thread.session.clone_history().await; |
| let mut instruction_count = 0; |
| let mut managed_instructions = Vec::new(); |
| for item in history.raw_items() { |
| let ResponseItem::Message { role, content, .. } = item else { |
| continue; |
| }; |
| if role != "developer" { |
| continue; |
| } |
| for content_item in content { |
| if let ContentItem::InputText { text } = content_item { |
| instruction_count += usize::from(text == "Child developer instructions."); |
| if ManagedDeveloperInstructions::matches_text(text) { |
| managed_instructions.push(text.as_str()); |
| } |
| } |
| } |
| } |
| assert_eq!( |
| (instruction_count, managed_instructions), |
| (1, vec![current_managed_fragment.as_str()]), |
| "{case}: canonical context reconstruction must keep only the current child and managed developer instructions" |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should submit"); |
| let _ = parent_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("parent shutdown should submit"); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
| let turn_context = parent_thread.session.new_default_turn().await; |
| let parent_spawn_call_id = "spawn-call-unflushed".to_string(); |
| parent_thread |
| .session |
| .record_conversation_items( |
| turn_context.as_ref(), |
| turn_context.model_info(), |
| &[ |
| assistant_message("unflushed final answer", Some(MessagePhase::FinalAnswer)), |
| spawn_agent_call(&parent_spawn_call_id), |
| ], |
| ) |
| .await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent_with_metadata( |
| harness.config.clone(), |
| text_input("child task"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| })), |
| SpawnAgentOptions { |
| fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), |
| fork_mode: Some(SpawnAgentForkMode::FullHistory), |
| ..Default::default() |
| }, |
| ) |
| .await |
| .expect("forked spawn should flush parent rollout before loading history") |
| .thread_id; |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| let history = child_thread.session.clone_history().await; |
| assert!( |
| history_contains_text(history.raw_items(), "unflushed final answer"), |
| "forked child history should include unflushed assistant final answers after flushing the parent rollout" |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should submit"); |
| let _ = parent_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("parent shutdown should submit"); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
|
|
| parent_thread |
| .inject_response_items(vec![user_message("old parent context")]) |
| .await |
| .expect("inject old parent context"); |
| let queued_communication = InterAgentCommunication::new( |
| AgentPath::root(), |
| AgentPath::try_from("/root/worker").expect("agent path"), |
| Vec::new(), |
| "queued message".to_string(), |
| 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(), |
| true, |
| ); |
| let triggered_turn_context = parent_thread.session.new_default_turn().await; |
| parent_thread |
| .session |
| .record_conversation_items( |
| triggered_turn_context.as_ref(), |
| triggered_turn_context.model_info(), |
| &[triggered_communication.to_response_input_item().into()], |
| ) |
| .await; |
| parent_thread |
| .inject_response_items(vec![user_message("current parent task")]) |
| .await |
| .expect("inject current parent task"); |
| let spawn_turn_context = parent_thread.session.new_default_turn().await; |
| let parent_spawn_call_id = "spawn-call-last-n".to_string(); |
| parent_thread |
| .session |
| .record_conversation_items( |
| spawn_turn_context.as_ref(), |
| spawn_turn_context.model_info(), |
| &[spawn_agent_call(&parent_spawn_call_id)], |
| ) |
| .await; |
| parent_thread |
| .session |
| .persist_rollout_items(&[RolloutItem::TurnContext( |
| spawn_turn_context.to_turn_context_item(), |
| )]) |
| .await; |
| parent_thread |
| .session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| parent_thread |
| .session |
| .flush_rollout() |
| .await |
| .expect("parent rollout should flush"); |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent_with_metadata( |
| harness.config.clone(), |
| text_input("child task"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| })), |
| SpawnAgentOptions { |
| fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), |
| fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), |
| ..Default::default() |
| }, |
| ) |
| .await |
| .expect("forked spawn should keep only the last two turns") |
| .thread_id; |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| let history = child_thread.session.clone_history().await; |
|
|
| assert!( |
| !history_contains_text(history.raw_items(), "old parent context"), |
| "forked child history should drop parent context outside the requested last-N turn window" |
| ); |
| assert!( |
| !history_contains_text(history.raw_items(), "queued message"), |
| "forked child history should drop queued inter-agent messages outside the requested last-N turn window" |
| ); |
| assert!( |
| !history_contains_text(history.raw_items(), "triggered context"), |
| "forked child history should filter assistant inter-agent messages even when they fall inside the requested last-N turn window" |
| ); |
| assert!( |
| history_contains_text(history.raw_items(), "current parent task"), |
| "forked child history should keep the parent user message from the requested last-N turn window" |
| ); |
| assert!( |
| child_thread |
| .session |
| .reference_context_item() |
| .await |
| .is_none(), |
| "last-N forked child should rebuild context after truncating the cached prefix" |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should submit"); |
| let _ = parent_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("parent shutdown should submit"); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_limit() { |
| let harness = AgentControlHarness::new().await; |
| let selected_capability_roots = vec![SelectedCapabilityRoot { |
| id: "demo@1".to_string(), |
| location: CapabilityRootLocation::Environment { |
| environment_id: "build".to_string(), |
| path: PathUri::parse("file:///plugins/demo").expect("plugin root URI"), |
| }, |
| }]; |
| let mut thread_extension_init = ExtensionDataInit::new(); |
| thread_extension_init.insert(selected_capability_roots.clone()); |
| let parent = harness |
| .manager |
| .start_thread(StartThreadOptions { |
| environments: Some(Vec::new()), |
| thread_extension_init, |
| ..StartThreadOptions::new(harness.config.clone()) |
| }) |
| .await |
| .expect("start parent thread"); |
| let parent_thread_id = parent.thread_id; |
| let parent_thread = parent.thread; |
| let startup_turn_context = parent_thread.session.new_default_turn().await; |
| parent_thread |
| .session |
| .record_conversation_items( |
| startup_turn_context.as_ref(), |
| startup_turn_context.model_info(), |
| &[ResponseItem::Message { |
| id: None, |
| role: "developer".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "parent startup developer context".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }], |
| ) |
| .await; |
| parent_thread |
| .inject_response_items(vec![user_message("current parent task")]) |
| .await |
| .expect("inject current parent task"); |
| let spawn_turn_context = parent_thread.session.new_default_turn().await; |
| let parent_spawn_call_id = "spawn-call-last-n-under-limit".to_string(); |
| parent_thread |
| .session |
| .record_conversation_items( |
| spawn_turn_context.as_ref(), |
| spawn_turn_context.model_info(), |
| &[spawn_agent_call(&parent_spawn_call_id)], |
| ) |
| .await; |
| parent_thread |
| .session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| parent_thread |
| .session |
| .flush_rollout() |
| .await |
| .expect("parent rollout should flush"); |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent_with_metadata( |
| harness.config.clone(), |
| text_input("child task"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| })), |
| SpawnAgentOptions { |
| fork_parent_spawn_call_id: Some(parent_spawn_call_id), |
| fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), |
| ..Default::default() |
| }, |
| ) |
| .await |
| .expect("bounded forked spawn should drop startup prefix") |
| .thread_id; |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| let history = child_thread.session.clone_history().await; |
| assert!( |
| history_contains_text(history.raw_items(), "current parent task"), |
| "bounded fork should retain the requested recent parent turn" |
| ); |
| assert!( |
| !history_contains_text(history.raw_items(), "parent startup developer context"), |
| "bounded fork should drop parent startup context even when fewer turns exist than requested" |
| ); |
| assert_eq!( |
| &child_thread.session.services.selected_capability_roots, |
| &selected_capability_roots |
| ); |
| assert!( |
| child_thread |
| .session |
| .reference_context_item() |
| .await |
| .is_none(), |
| "bounded forked child should still rebuild context after truncating the cached prefix" |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should submit"); |
| let _ = parent_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("parent shutdown should submit"); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { |
| let persistent_fragment = |
| "<persistent_mode>\nParent persistent instructions.\n</persistent_mode>"; |
| let harness = AgentControlHarness::new().await; |
| let mut parent_config = harness.config.clone(); |
| let _ = parent_config.features.enable(Feature::MultiAgentV2); |
| parent_config.developer_instructions = Some("Parent developer instructions.".to_string()); |
| parent_config.multi_agent_v2.root_agent_usage_hint_text = |
| Some("Parent root guidance.".to_string()); |
| let mut child_config = harness.config.clone(); |
| let _ = child_config.features.enable(Feature::MultiAgentV2); |
| child_config.developer_instructions = Some("Child developer instructions.".to_string()); |
| child_config.multi_agent_v2.subagent_developer_instructions = |
| Some("Child developer instructions.".to_string()); |
| child_config.multi_agent_v2.subagent_usage_hint_text = |
| Some("Child subagent guidance.".to_string()); |
| let new_thread = harness |
| .manager |
| .start_thread(StartThreadOptions::new(parent_config)) |
| .await |
| .expect("start parent thread"); |
| let parent_thread_id = new_thread.thread_id; |
| let parent_thread = new_thread.thread; |
| parent_thread |
| .inject_response_items(vec![user_message("parent task")]) |
| .await |
| .expect("inject parent task"); |
| let turn_context = parent_thread.session.new_default_turn().await; |
| let parent_spawn_call_id = "spawn-call-last-n-usage-hints".to_string(); |
| parent_thread |
| .session |
| .record_conversation_items( |
| turn_context.as_ref(), |
| turn_context.model_info(), |
| &[ |
| ResponseItem::Message { |
| id: None, |
| role: "developer".to_string(), |
| content: vec![ContentItem::InputText { |
| text: "Parent root guidance.".to_string(), |
| }], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }, |
| ResponseItem::Message { |
| id: None, |
| role: "developer".to_string(), |
| content: vec![ |
| ContentItem::InputText { |
| text: "Parent developer instructions.".to_string(), |
| }, |
| ContentItem::InputText { |
| text: "Preserved bounded developer context.".to_string(), |
| }, |
| ContentItem::InputText { |
| text: persistent_fragment.to_string(), |
| }, |
| ], |
| phase: None, |
| internal_chat_message_metadata_passthrough: None, |
| }, |
| spawn_agent_call(&parent_spawn_call_id), |
| ], |
| ) |
| .await; |
| parent_thread |
| .session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| parent_thread |
| .session |
| .flush_rollout() |
| .await |
| .expect("parent rollout should flush"); |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent_with_metadata( |
| child_config, |
| text_input("child task"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| })), |
| SpawnAgentOptions { |
| fork_parent_spawn_call_id: Some(parent_spawn_call_id), |
| fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), |
| ..Default::default() |
| }, |
| ) |
| .await |
| .expect("bounded forked spawn should sanitize parent usage hints") |
| .thread_id; |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| let history = child_thread.session.clone_history().await; |
| assert!( |
| history_contains_text(history.raw_items(), "parent task"), |
| "bounded fork should retain the requested recent parent turn" |
| ); |
| assert!( |
| !history_contains_text(history.raw_items(), "Parent root guidance."), |
| "bounded fork should strip stale parent root hints before the child rebuilds startup context" |
| ); |
| assert!( |
| !history_contains_text(history.raw_items(), "Parent developer instructions."), |
| "bounded fork should remove parent instructions before the child rebuilds startup context" |
| ); |
| assert!( |
| !history_contains_text(history.raw_items(), "Child developer instructions."), |
| "bounded fork should not inject child instructions before its canonical context rebuild" |
| ); |
| assert!( |
| !history_contains_text(history.raw_items(), persistent_fragment), |
| "bounded fork should remove persistent instructions before rebuilding context for the child's effort" |
| ); |
| assert!( |
| history_contains_text(history.raw_items(), "Preserved bounded developer context."), |
| "bounded fork should preserve unrelated developer fragments" |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should submit"); |
| let _ = parent_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("parent shutdown should submit"); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_respects_legacy_max_threads_alias() { |
| let max_threads = 1usize; |
| let (_home, config) = test_config_with_cli_overrides(vec![( |
| "agents.max_threads".to_string(), |
| TomlValue::Integer(max_threads as i64), |
| )]) |
| .await; |
| let manager = ThreadManager::with_models_provider_and_home_for_tests( |
| CodexAuth::from_api_key("dummy"), |
| config.model_provider.clone(), |
| config.codex_home.to_path_buf(), |
| std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| ); |
| let control = manager.agent_control(); |
|
|
| let _ = manager |
| .start_thread(StartThreadOptions::new(config.clone())) |
| .await |
| .expect("start thread"); |
|
|
| let first_agent_id = control |
| .spawn_agent( |
| config.clone(), |
| text_input("hello"), |
| None, |
| ) |
| .await |
| .expect("spawn_agent should succeed"); |
|
|
| let err = control |
| .spawn_agent( |
| config, |
| text_input("hello again"), |
| None, |
| ) |
| .await |
| .expect_err("spawn_agent should respect max threads"); |
| let CodexErrorDetails::AgentLimitReached { |
| max_threads: seen_max_threads, |
| } = err.details() |
| else { |
| panic!("expected AgentLimitReached"); |
| }; |
| assert_eq!(*seen_max_threads, max_threads); |
|
|
| let _ = control |
| .shutdown_live_agent(first_agent_id) |
| .await |
| .expect("shutdown agent"); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_releases_slot_after_shutdown() { |
| let max_threads = 1usize; |
| let (_home, config) = test_config_with_cli_overrides(vec![( |
| "agents.max_concurrent_threads_per_session".to_string(), |
| TomlValue::Integer(max_threads as i64), |
| )]) |
| .await; |
| let manager = ThreadManager::with_models_provider_and_home_for_tests( |
| CodexAuth::from_api_key("dummy"), |
| config.model_provider.clone(), |
| config.codex_home.to_path_buf(), |
| std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| ); |
| let control = manager.agent_control(); |
|
|
| let first_agent_id = control |
| .spawn_agent( |
| config.clone(), |
| text_input("hello"), |
| 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"), |
| None, |
| ) |
| .await |
| .expect("spawn_agent should succeed after shutdown"); |
| let _ = control |
| .shutdown_live_agent(second_agent_id) |
| .await |
| .expect("shutdown agent"); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_agent_limit_shared_across_clones() { |
| let max_threads = 1usize; |
| let (_home, config) = test_config_with_cli_overrides(vec![( |
| "agents.max_concurrent_threads_per_session".to_string(), |
| TomlValue::Integer(max_threads as i64), |
| )]) |
| .await; |
| let manager = ThreadManager::with_models_provider_and_home_for_tests( |
| CodexAuth::from_api_key("dummy"), |
| config.model_provider.clone(), |
| config.codex_home.to_path_buf(), |
| std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| ); |
| let control = manager.agent_control(); |
| let cloned = control.clone(); |
|
|
| let first_agent_id = cloned |
| .spawn_agent( |
| config.clone(), |
| text_input("hello"), |
| None, |
| ) |
| .await |
| .expect("spawn_agent should succeed"); |
|
|
| let err = control |
| .spawn_agent( |
| config, |
| text_input("hello again"), |
| None, |
| ) |
| .await |
| .expect_err("spawn_agent should respect shared guard"); |
| let CodexErrorDetails::AgentLimitReached { max_threads } = err.details() else { |
| panic!("expected AgentLimitReached"); |
| }; |
| assert_eq!(*max_threads, 1); |
|
|
| let _ = control |
| .shutdown_live_agent(first_agent_id) |
| .await |
| .expect("shutdown agent"); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_respects_max_threads_limit() { |
| let max_threads = 1usize; |
| let (_home, config) = test_config_with_cli_overrides(vec![( |
| "agents.max_concurrent_threads_per_session".to_string(), |
| TomlValue::Integer(max_threads as i64), |
| )]) |
| .await; |
| let manager = ThreadManager::with_models_provider_and_home_for_tests( |
| CodexAuth::from_api_key("dummy"), |
| config.model_provider.clone(), |
| config.codex_home.to_path_buf(), |
| std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| ); |
| let control = manager.agent_control(); |
|
|
| let resumable_id = control |
| .spawn_agent( |
| config.clone(), |
| text_input("hello"), |
| 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"), |
| None, |
| ) |
| .await |
| .expect("spawn_agent should succeed for active slot"); |
|
|
| let err = control |
| .resume_agent_from_rollout(config, resumable_id, SessionSource::Exec) |
| .await |
| .expect_err("resume should respect max threads"); |
| let CodexErrorDetails::AgentLimitReached { |
| max_threads: seen_max_threads, |
| } = err.details() |
| else { |
| panic!("expected AgentLimitReached"); |
| }; |
| assert_eq!(*seen_max_threads, max_threads); |
|
|
| let _ = control |
| .shutdown_live_agent(active_id) |
| .await |
| .expect("shutdown active thread"); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_releases_slot_after_resume_failure() { |
| let max_threads = 1usize; |
| let (_home, config) = test_config_with_cli_overrides(vec![( |
| "agents.max_concurrent_threads_per_session".to_string(), |
| TomlValue::Integer(max_threads as i64), |
| )]) |
| .await; |
| let manager = ThreadManager::with_models_provider_and_home_for_tests( |
| CodexAuth::from_api_key("dummy"), |
| config.model_provider.clone(), |
| config.codex_home.to_path_buf(), |
| std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| ); |
| let control = manager.agent_control(); |
|
|
| let _ = control |
| .resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec) |
| .await |
| .expect_err("resume should fail for missing rollout path"); |
|
|
| let resumed_id = control |
| .spawn_agent(config, text_input("hello"), None) |
| .await |
| .expect("spawn should succeed after failed resume"); |
| let _ = control |
| .shutdown_live_agent(resumed_id) |
| .await |
| .expect("shutdown resumed thread"); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_child_completion_notifies_parent_history() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| let _ = child_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("child shutdown should submit"); |
|
|
| assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); |
| } |
|
|
| #[tokio::test] |
| async fn multi_agent_v2_completion_ignores_dead_direct_parent() { |
| let harness = AgentControlHarness::new().await; |
| let mut config = harness.config.clone(); |
| let _ = config.features.enable(Feature::MultiAgentV2); |
| let root = harness |
| .manager |
| .start_thread(StartThreadOptions::new(config.clone())) |
| .await |
| .expect("root thread should start"); |
| let root_thread_id = root.thread_id; |
| let root_thread = root.thread; |
| let worker_path = AgentPath::root().join("worker_a").expect("worker path"); |
| let worker_thread_id = harness |
| .control |
| .spawn_agent( |
| config.clone(), |
| text_input("hello worker"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: root_thread_id, |
| depth: 1, |
| agent_path: Some(worker_path.clone()), |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("worker spawn should succeed"); |
| let tester_path = worker_path.join("tester").expect("tester path"); |
| let tester_thread_id = harness |
| .control |
| .spawn_agent( |
| config, |
| text_input("hello tester"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: worker_thread_id, |
| depth: 2, |
| agent_path: Some(tester_path.clone()), |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("tester spawn should succeed"); |
| harness |
| .control |
| .shutdown_live_agent(worker_thread_id) |
| .await |
| .expect("worker shutdown should succeed"); |
|
|
| let tester_thread = harness |
| .manager |
| .get_thread(tester_thread_id) |
| .await |
| .expect("tester thread should exist"); |
| let tester_turn = tester_thread.session.new_default_turn().await; |
| tester_thread |
| .session |
| .send_event( |
| tester_turn.as_ref(), |
| EventMsg::TurnComplete(TurnCompleteEvent { |
| turn_id: tester_turn.sub_id.clone(), |
| started_at: None, |
| last_agent_message: Some("done".to_string()), |
| error: None, |
| completed_at: None, |
| duration_ms: None, |
| time_to_first_token_ms: None, |
| }), |
| ) |
| .await; |
|
|
| sleep(Duration::from_millis(100)).await; |
|
|
| assert!( |
| !harness |
| .manager |
| .captured_ops() |
| .into_iter() |
| .any(|(thread_id, op)| { |
| thread_id == worker_thread_id |
| && matches!( |
| op, |
| Op::InterAgentCommunication { communication, .. } |
| if communication.author == tester_path |
| && communication.recipient == worker_path |
| && communication.content == "done" |
| ) |
| }) |
| ); |
|
|
| let root_history = root_thread.session.clone_history().await; |
| assert!(!history_contains_assistant_inter_agent_communication( |
| root_history.raw_items(), |
| &InterAgentCommunication::new( |
| tester_path, |
| AgentPath::root(), |
| Vec::new(), |
| "done".to_string(), |
| true, |
| ) |
| )); |
| assert!(!has_subagent_notification(root_history.raw_items())); |
| } |
|
|
| #[tokio::test] |
| async fn multi_agent_v2_completion_queues_message_for_direct_parent() { |
| let harness = AgentControlHarness::new().await; |
| let (_root_thread_id, root_thread) = harness.start_thread().await; |
| let (worker_thread_id, _worker_thread) = harness.start_thread().await; |
| let mut tester_config = harness.config.clone(); |
| let _ = tester_config.features.enable(Feature::MultiAgentV2); |
| let tester_thread_id = harness |
| .manager |
| .start_thread(StartThreadOptions::new(tester_config.clone())) |
| .await |
| .expect("tester thread should start") |
| .thread_id; |
| let tester_thread = harness |
| .manager |
| .get_thread(tester_thread_id) |
| .await |
| .expect("tester thread should exist"); |
| let worker_path = AgentPath::root().join("worker_a").expect("worker path"); |
| let tester_path = worker_path.join("tester").expect("tester path"); |
| harness.control.maybe_start_completion_watcher( |
| tester_thread_id, |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: worker_thread_id, |
| depth: 2, |
| agent_path: Some(tester_path.clone()), |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| tester_path.to_string(), |
| Some(tester_path.clone()), |
| ); |
| let tester_turn = tester_thread.session.new_default_turn().await; |
| tester_thread |
| .session |
| .send_event( |
| tester_turn.as_ref(), |
| EventMsg::TurnComplete(TurnCompleteEvent { |
| turn_id: tester_turn.sub_id.clone(), |
| started_at: None, |
| last_agent_message: Some("done".to_string()), |
| error: None, |
| completed_at: None, |
| duration_ms: None, |
| time_to_first_token_ms: None, |
| }), |
| ) |
| .await; |
|
|
| let expected_message = crate::session_prefix::format_inter_agent_completion_message( |
| worker_path.clone(), |
| tester_path.clone(), |
| &AgentStatus::Completed(Some("done".to_string())), |
| ) |
| .expect("completed status should render"); |
| let expected = ( |
| worker_thread_id, |
| Op::InterAgentCommunication { |
| communication: InterAgentCommunication::new( |
| tester_path.clone(), |
| worker_path.clone(), |
| Vec::new(), |
| expected_message.clone(), |
| 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, |
| false, |
| ) |
| )); |
| } |
|
|
| #[tokio::test] |
| async fn completion_watcher_notifies_parent_when_child_is_missing() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
| let child_thread_id = ThreadId::new(); |
|
|
| harness.control.maybe_start_completion_watcher( |
| child_thread_id, |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| child_thread_id.to_string(), |
| None, |
| ); |
|
|
| assert_eq!(wait_for_subagent_notification(&parent_thread).await, true); |
|
|
| let history = parent_thread.session.clone_history().await; |
| assert_eq!( |
| history_contains_text( |
| history.raw_items(), |
| &format!("\"agent_path\":\"{child_thread_id}\"") |
| ), |
| true |
| ); |
| assert_eq!( |
| history_contains_text(history.raw_items(), "\"status\":\"not_found\""), |
| true |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_thread_subagent_gets_random_nickname_in_session_source() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, _parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| let snapshot = child_thread.config_snapshot().await; |
|
|
| let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: seen_parent_thread_id, |
| depth, |
| agent_nickname, |
| agent_role, |
| .. |
| }) = snapshot.session_source |
| else { |
| panic!("expected thread-spawn sub-agent source"); |
| }; |
| assert_eq!(seen_parent_thread_id, parent_thread_id); |
| assert_eq!(depth, 1); |
| assert!(agent_nickname.is_some()); |
| assert_eq!(agent_role, Some("explorer".to_string())); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_thread_subagents_persist_parent_originator_across_new_and_truncated_fork() { |
| let harness = AgentControlHarness::new().await; |
| let parent = harness |
| .manager |
| .start_thread(StartThreadOptions { |
| metrics_service_name: Some("codex_work_desktop".to_string()), |
| environments: Some(Vec::new()), |
| ..StartThreadOptions::new(harness.config.clone()) |
| }) |
| .await |
| .expect("parent thread should start"); |
| let parent_originator = persisted_originator(&parent.thread).await; |
| assert_eq!(parent_originator, "codex_work_desktop"); |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: parent.thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| let child_originator = persisted_originator(&child_thread).await; |
| assert_eq!(child_originator, parent_originator); |
|
|
| let child = harness |
| .control |
| .spawn_agent_with_metadata( |
| harness.config.clone(), |
| text_input("hello forked child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: parent.thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| SpawnAgentOptions { |
| fork_parent_spawn_call_id: Some("spawn-call-last-n".to_string()), |
| fork_mode: Some(SpawnAgentForkMode::LastNTurns(1)), |
| ..Default::default() |
| }, |
| ) |
| .await |
| .expect("forked child spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child.thread_id) |
| .await |
| .expect("child thread should be registered"); |
| let child_originator = persisted_originator(&child_thread).await; |
| assert_eq!(child_originator, parent_originator); |
| } |
|
|
| #[tokio::test] |
| async fn spawn_thread_subagent_uses_role_specific_nickname_candidates() { |
| let mut harness = AgentControlHarness::new().await; |
| harness.config.agent_roles.insert( |
| "researcher".to_string(), |
| AgentRoleConfig { |
| description: Some("Research role".to_string()), |
| config_file: None, |
| nickname_candidates: Some(vec!["Atlas".to_string()]), |
| }, |
| ); |
| let (parent_thread_id, _parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("researcher".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should be registered"); |
| let snapshot = child_thread.config_snapshot().await; |
|
|
| let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_nickname, .. }) = |
| snapshot.session_source |
| else { |
| panic!("expected thread-spawn sub-agent source"); |
| }; |
| assert_eq!(agent_nickname, Some("Atlas".to_string())); |
| } |
|
|
| #[tokio::test] |
| async fn resume_thread_subagent_restores_stored_metadata() { |
| let (home, config) = test_config().await; |
| let thread_store = Arc::new(InMemoryThreadStore::default()); |
| let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")); |
| let manager = ThreadManager::new( |
| &config, |
| auth_manager.clone(), |
| crate::thread_manager::build_models_manager(&config, auth_manager), |
| crate::CodexAppsToolsCache::default(), |
| SessionSource::Exec, |
| Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| empty_extension_registry(), |
| Arc::new(crate::test_support::EmptyUserInstructionsProvider), |
| None, |
| crate::thread_manager::passthrough_image_store(), |
| thread_store.clone(), |
| None, |
| uuid::Uuid::new_v4().to_string(), |
| None, |
| None, |
| ); |
| let control = manager.agent_control(); |
| let harness = AgentControlHarness { |
| _home: home, |
| config, |
| state_db: None, |
| manager, |
| control, |
| }; |
| let (parent_thread_id, _parent_thread) = harness.start_thread().await; |
| let agent_path = AgentPath::from_string("/root/explorer".to_string()) |
| .expect("test agent path should be valid"); |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: Some(agent_path.clone()), |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| child_thread |
| .session |
| .ensure_rollout_materialized(PersistContext::Standard) |
| .await; |
| child_thread |
| .session |
| .flush_rollout() |
| .await |
| .expect("flush child rollout"); |
| let mut status_rx = harness |
| .control |
| .subscribe_status(child_thread_id) |
| .await |
| .expect("status subscription should succeed"); |
| if matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { |
| timeout(Duration::from_secs(5), async { |
| loop { |
| status_rx |
| .changed() |
| .await |
| .expect("child status should advance past pending init"); |
| if !matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) { |
| break; |
| } |
| } |
| }) |
| .await |
| .expect("child should initialize before shutdown"); |
| } |
| let original_snapshot = child_thread.config_snapshot().await; |
| let original_nickname = original_snapshot |
| .session_source |
| .get_nickname() |
| .expect("spawned sub-agent should have a nickname"); |
| timeout(Duration::from_secs(5), async { |
| loop { |
| if let Ok(stored_thread) = thread_store |
| .read_thread(ReadThreadParams { |
| thread_id: child_thread_id, |
| include_archived: true, |
| include_history: false, |
| }) |
| .await |
| && stored_thread.agent_nickname.is_some() |
| && stored_thread.agent_role.as_deref() == Some("explorer") |
| && stored_thread.agent_path.as_deref() == Some(agent_path.as_str()) |
| { |
| break; |
| } |
| sleep(Duration::from_millis(10)).await; |
| } |
| }) |
| .await |
| .expect("child thread metadata should be persisted to sqlite before shutdown"); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should submit"); |
|
|
| let resumed_thread_id = harness |
| .control |
| .resume_agent_from_rollout( |
| harness.config.clone(), |
| child_thread_id, |
| SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| }), |
| ) |
| .await |
| .expect("resume should succeed"); |
| assert_eq!(resumed_thread_id, child_thread_id); |
|
|
| let resumed_snapshot = harness |
| .manager |
| .get_thread(resumed_thread_id) |
| .await |
| .expect("resumed child thread should exist") |
| .config_snapshot() |
| .await; |
| let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: resumed_parent_thread_id, |
| depth: resumed_depth, |
| agent_path: resumed_agent_path, |
| agent_nickname: resumed_nickname, |
| agent_role: resumed_role, |
| .. |
| }) = resumed_snapshot.session_source |
| else { |
| panic!("expected thread-spawn sub-agent source"); |
| }; |
| assert_eq!(resumed_parent_thread_id, parent_thread_id); |
| assert_eq!(resumed_depth, 1); |
| assert_eq!(resumed_agent_path, Some(agent_path)); |
| assert_eq!(resumed_nickname, Some(original_nickname)); |
| assert_eq!(resumed_role, Some("explorer".to_string())); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(resumed_thread_id) |
| .await |
| .expect("resumed child shutdown should submit"); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_from_rollout_reads_archived_rollout_path() { |
| let harness = AgentControlHarness::new().await; |
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello"), |
| None, |
| ) |
| .await |
| .expect("child spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| persist_thread_for_tree_resume(&child_thread, "persist before archiving").await; |
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should succeed"); |
| let store = LocalThreadStore::new( |
| LocalThreadStoreConfig::from_config(&harness.config), |
| harness.state_db.clone(), |
| ); |
| store |
| .archive_thread(ArchiveThreadParams { |
| thread_id: child_thread_id, |
| }) |
| .await |
| .expect("child thread should archive"); |
|
|
| let resumed_thread_id = harness |
| .control |
| .resume_agent_from_rollout(harness.config.clone(), child_thread_id, SessionSource::Exec) |
| .await |
| .expect("resume should find archived rollout"); |
| assert_eq!(resumed_thread_id, child_thread_id); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("resumed child shutdown should succeed"); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_from_paginated_rollout_loads_model_context() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await; |
| let child_thread_id = harness |
| .spawn_anonymous_child( |
| parent_thread_id, |
| SpawnAgentOptions { |
| parent_thread_id: Some(parent_thread_id), |
| ..Default::default() |
| }, |
| ) |
| .await; |
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| assert_eq!( |
| child_thread.config_snapshot().await.history_mode, |
| ThreadHistoryMode::Paginated |
| ); |
| persist_thread_for_tree_resume(&child_thread, "persist before resume").await; |
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("child shutdown should succeed"); |
|
|
| let resumed_thread_id = harness |
| .control |
| .resume_agent_from_rollout(harness.config.clone(), child_thread_id, SessionSource::Exec) |
| .await |
| .expect("resume should load paginated model context"); |
| assert_eq!(resumed_thread_id, child_thread_id); |
| let resumed_thread = harness |
| .manager |
| .get_thread(resumed_thread_id) |
| .await |
| .expect("resumed child thread should exist"); |
| assert!( |
| history_contains_text( |
| resumed_thread.session.clone_history().await.raw_items(), |
| "persist before resume", |
| ), |
| "resumed child should keep its persisted model context" |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(child_thread_id) |
| .await |
| .expect("resumed child shutdown should succeed"); |
| let _ = parent_thread |
| .submit(Op::Shutdown {}) |
| .await |
| .expect("parent shutdown should submit"); |
| } |
|
|
| #[tokio::test] |
| async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, _parent_thread) = harness.start_thread().await; |
| let worker_path = AgentPath::root().join("worker").expect("worker path"); |
| let reviewer_path = AgentPath::root().join("reviewer").expect("reviewer path"); |
|
|
| let worker_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello worker"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: Some(worker_path.clone()), |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("worker spawn should succeed"); |
| let worker_child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello worker child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: worker_thread_id, |
| depth: 2, |
| agent_path: Some( |
| worker_path |
| .join("child") |
| .expect("worker child path should be valid"), |
| ), |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("worker child spawn should succeed"); |
| let no_path_child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello anonymous child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: worker_thread_id, |
| depth: 2, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("no-path child spawn should succeed"); |
| let no_path_grandchild_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello anonymous grandchild"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: no_path_child_thread_id, |
| depth: 3, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("no-path grandchild spawn should succeed"); |
| let _reviewer_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello reviewer"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: Some(reviewer_path), |
| agent_nickname: None, |
| agent_role: Some("reviewer".to_string()), |
| })), |
| ) |
| .await |
| .expect("reviewer spawn should succeed"); |
|
|
| let _ = harness |
| .control |
| .shutdown_live_agent(no_path_grandchild_thread_id) |
| .await |
| .expect("no-path grandchild shutdown should succeed"); |
|
|
| let mut worker_subtree_thread_ids = harness |
| .manager |
| .list_agent_subtree_thread_ids(worker_thread_id) |
| .await |
| .expect("worker subtree thread ids should load"); |
| worker_subtree_thread_ids.sort_by_key(ToString::to_string); |
| let mut expected_worker_subtree_thread_ids = vec![ |
| worker_thread_id, |
| worker_child_thread_id, |
| no_path_child_thread_id, |
| no_path_grandchild_thread_id, |
| ]; |
| expected_worker_subtree_thread_ids.sort_by_key(ToString::to_string); |
| assert_eq!( |
| worker_subtree_thread_ids, |
| expected_worker_subtree_thread_ids |
| ); |
|
|
| let mut no_path_child_subtree_thread_ids = harness |
| .manager |
| .list_agent_subtree_thread_ids(no_path_child_thread_id) |
| .await |
| .expect("no-path subtree thread ids should load"); |
| no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string); |
| let mut expected_no_path_child_subtree_thread_ids = |
| vec![no_path_child_thread_id, no_path_grandchild_thread_id]; |
| expected_no_path_child_subtree_thread_ids.sort_by_key(ToString::to_string); |
| assert_eq!( |
| no_path_child_subtree_thread_ids, |
| expected_no_path_child_subtree_thread_ids |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn list_agent_subtree_thread_ids_finds_live_descendants_of_unloaded_root() { |
| let (_home, config) = test_config().await; |
| let manager = ThreadManager::with_models_provider_home_and_state_for_tests( |
| CodexAuth::from_api_key("dummy"), |
| config.model_provider.clone(), |
| config.codex_home.to_path_buf(), |
| std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), |
| None, |
| ); |
| let control = manager.agent_control(); |
| let parent_thread_id = manager |
| .start_thread(StartThreadOptions::new(config.clone())) |
| .await |
| .expect("parent should start") |
| .thread_id; |
|
|
| let child_thread_id = control |
| .spawn_agent( |
| config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
| let grandchild_thread_id = control |
| .spawn_agent( |
| config, |
| text_input("hello grandchild"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: child_thread_id, |
| depth: 2, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("grandchild spawn should succeed"); |
|
|
| manager.remove_thread(&parent_thread_id).await; |
|
|
| let mut subtree_thread_ids = manager |
| .list_agent_subtree_thread_ids(parent_thread_id) |
| .await |
| .expect("live subtree should load"); |
| subtree_thread_ids.sort_by_key(ToString::to_string); |
| let mut expected_subtree_thread_ids = |
| vec![parent_thread_id, child_thread_id, grandchild_thread_id]; |
| expected_subtree_thread_ids.sort_by_key(ToString::to_string); |
|
|
| assert_eq!(subtree_thread_ids, expected_subtree_thread_ids); |
| } |
|
|
| #[tokio::test] |
| async fn shutdown_agent_tree_closes_live_descendants() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, _parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
| let grandchild_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello grandchild"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: child_thread_id, |
| depth: 2, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("grandchild spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| let grandchild_thread = harness |
| .manager |
| .get_thread(grandchild_thread_id) |
| .await |
| .expect("grandchild thread should exist"); |
| persist_thread_for_tree_resume(&child_thread, "child persisted").await; |
| persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; |
| wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) |
| .await; |
| wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) |
| .await; |
|
|
| let _ = harness |
| .control |
| .shutdown_agent_tree(parent_thread_id) |
| .await |
| .expect("tree shutdown should succeed"); |
|
|
| assert_eq!( |
| harness.control.get_status(parent_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_eq!( |
| harness.control.get_status(child_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_eq!( |
| harness.control.get_status(grandchild_thread_id).await, |
| AgentStatus::NotFound |
| ); |
|
|
| let shutdown_ids = harness |
| .manager |
| .captured_ops() |
| .into_iter() |
| .filter_map(|(thread_id, op)| matches!(op, Op::Shutdown).then_some(thread_id)) |
| .collect::<Vec<_>>(); |
| let mut expected_shutdown_ids = vec![parent_thread_id, child_thread_id, grandchild_thread_id]; |
| expected_shutdown_ids.sort_by_key(std::string::ToString::to_string); |
| let mut shutdown_ids = shutdown_ids; |
| shutdown_ids.sort_by_key(std::string::ToString::to_string); |
| assert_eq!(shutdown_ids, expected_shutdown_ids); |
| } |
|
|
| #[tokio::test] |
| async fn shutdown_agent_tree_closes_descendants_when_started_at_child() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, _parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
| let grandchild_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello grandchild"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: child_thread_id, |
| depth: 2, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("grandchild spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| let grandchild_thread = harness |
| .manager |
| .get_thread(grandchild_thread_id) |
| .await |
| .expect("grandchild thread should exist"); |
| persist_thread_for_tree_resume(&child_thread, "child persisted").await; |
| persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; |
| wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) |
| .await; |
| wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) |
| .await; |
|
|
| let _ = harness |
| .control |
| .close_agent(child_thread_id) |
| .await |
| .expect("child close should succeed"); |
|
|
| let _ = harness |
| .control |
| .shutdown_agent_tree(parent_thread_id) |
| .await |
| .expect("tree shutdown should succeed"); |
|
|
| assert_eq!( |
| harness.control.get_status(child_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_eq!( |
| harness.control.get_status(grandchild_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_eq!( |
| harness.control.get_status(parent_thread_id).await, |
| AgentStatus::NotFound |
| ); |
|
|
| let shutdown_ids = harness |
| .manager |
| .captured_ops() |
| .into_iter() |
| .filter_map(|(thread_id, op)| matches!(op, Op::Shutdown).then_some(thread_id)) |
| .collect::<Vec<_>>(); |
| let mut expected_shutdown_ids = vec![parent_thread_id, child_thread_id, grandchild_thread_id]; |
| expected_shutdown_ids.sort_by_key(std::string::ToString::to_string); |
| let mut shutdown_ids = shutdown_ids; |
| shutdown_ids.sort_by_key(std::string::ToString::to_string); |
| assert_eq!(shutdown_ids, expected_shutdown_ids); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_from_rollout_does_not_reopen_closed_descendants() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
| let grandchild_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello grandchild"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: child_thread_id, |
| depth: 2, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("grandchild spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| let grandchild_thread = harness |
| .manager |
| .get_thread(grandchild_thread_id) |
| .await |
| .expect("grandchild thread should exist"); |
| persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; |
| persist_thread_for_tree_resume(&child_thread, "child persisted").await; |
| persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; |
| wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) |
| .await; |
| wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) |
| .await; |
|
|
| let _ = harness |
| .control |
| .close_agent(child_thread_id) |
| .await |
| .expect("child close should succeed"); |
| let _ = harness |
| .control |
| .shutdown_live_agent(parent_thread_id) |
| .await |
| .expect("parent shutdown should succeed"); |
|
|
| let resumed_parent_thread_id = harness |
| .control |
| .resume_agent_from_rollout( |
| harness.config.clone(), |
| parent_thread_id, |
| SessionSource::Exec, |
| ) |
| .await |
| .expect("single-thread resume should succeed"); |
| assert_eq!(resumed_parent_thread_id, parent_thread_id); |
| assert_ne!( |
| harness.control.get_status(parent_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_eq!( |
| harness.control.get_status(child_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_eq!( |
| harness.control.get_status(grandchild_thread_id).await, |
| AgentStatus::NotFound |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_agent_tree(parent_thread_id) |
| .await |
| .expect("tree shutdown after resume should succeed"); |
| } |
|
|
| #[tokio::test] |
| async fn resume_closed_child_reopens_open_descendants() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
| let grandchild_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello grandchild"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: child_thread_id, |
| depth: 2, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("grandchild spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| let grandchild_thread = harness |
| .manager |
| .get_thread(grandchild_thread_id) |
| .await |
| .expect("grandchild thread should exist"); |
| persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; |
| persist_thread_for_tree_resume(&child_thread, "child persisted").await; |
| persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; |
| wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) |
| .await; |
| wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) |
| .await; |
|
|
| let _ = harness |
| .control |
| .close_agent(child_thread_id) |
| .await |
| .expect("child close should succeed"); |
|
|
| let resumed_child_thread_id = harness |
| .control |
| .resume_agent_from_rollout( |
| harness.config.clone(), |
| child_thread_id, |
| SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: None, |
| }), |
| ) |
| .await |
| .expect("child resume should succeed"); |
| assert_eq!(resumed_child_thread_id, child_thread_id); |
| assert_ne!( |
| harness.control.get_status(child_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_ne!( |
| harness.control.get_status(grandchild_thread_id).await, |
| AgentStatus::NotFound |
| ); |
|
|
| let _ = harness |
| .control |
| .close_agent(child_thread_id) |
| .await |
| .expect("child close after resume should succeed"); |
| let _ = harness |
| .control |
| .shutdown_live_agent(parent_thread_id) |
| .await |
| .expect("parent shutdown should succeed"); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_from_rollout_reopens_open_descendants_after_manager_shutdown() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
| let grandchild_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello grandchild"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: child_thread_id, |
| depth: 2, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("grandchild spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| let grandchild_thread = harness |
| .manager |
| .get_thread(grandchild_thread_id) |
| .await |
| .expect("grandchild thread should exist"); |
| persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; |
| persist_thread_for_tree_resume(&child_thread, "child persisted").await; |
| persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; |
| wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) |
| .await; |
| wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) |
| .await; |
|
|
| let report = harness |
| .manager |
| .shutdown_all_threads_bounded(Duration::from_secs(5)) |
| .await; |
| assert_eq!(report.submit_failed, Vec::<ThreadId>::new()); |
| assert_eq!(report.timed_out, Vec::<ThreadId>::new()); |
|
|
| let resumed_parent_thread_id = harness |
| .control |
| .resume_agent_from_rollout( |
| harness.config.clone(), |
| parent_thread_id, |
| SessionSource::Exec, |
| ) |
| .await |
| .expect("tree resume should succeed"); |
| assert_eq!(resumed_parent_thread_id, parent_thread_id); |
| assert_ne!( |
| harness.control.get_status(parent_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_ne!( |
| harness.control.get_status(child_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_ne!( |
| harness.control.get_status(grandchild_thread_id).await, |
| AgentStatus::NotFound |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_agent_tree(parent_thread_id) |
| .await |
| .expect("tree shutdown after subtree resume should succeed"); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_source_is_stale() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
| let grandchild_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello grandchild"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: child_thread_id, |
| depth: 2, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("grandchild spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| let grandchild_thread = harness |
| .manager |
| .get_thread(grandchild_thread_id) |
| .await |
| .expect("grandchild thread should exist"); |
| persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; |
| persist_thread_for_tree_resume(&child_thread, "child persisted").await; |
| persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; |
| wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) |
| .await; |
| wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) |
| .await; |
|
|
| let state_db = grandchild_thread |
| .state_db() |
| .expect("sqlite state db should be available"); |
| let mut stale_metadata = state_db |
| .get_thread(grandchild_thread_id) |
| .await |
| .expect("grandchild metadata query should succeed") |
| .expect("grandchild metadata should exist"); |
| stale_metadata.source = |
| serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: ThreadId::new(), |
| depth: 99, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })) |
| .expect("stale session source should serialize"); |
| state_db |
| .upsert_thread(&stale_metadata) |
| .await |
| .expect("stale grandchild metadata should persist"); |
|
|
| let report = harness |
| .manager |
| .shutdown_all_threads_bounded(Duration::from_secs(5)) |
| .await; |
| assert_eq!(report.submit_failed, Vec::<ThreadId>::new()); |
| assert_eq!(report.timed_out, Vec::<ThreadId>::new()); |
|
|
| let resumed_parent_thread_id = harness |
| .control |
| .resume_agent_from_rollout( |
| harness.config.clone(), |
| parent_thread_id, |
| SessionSource::Exec, |
| ) |
| .await |
| .expect("tree resume should succeed"); |
| assert_eq!(resumed_parent_thread_id, parent_thread_id); |
| assert_ne!( |
| harness.control.get_status(parent_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_ne!( |
| harness.control.get_status(child_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_ne!( |
| harness.control.get_status(grandchild_thread_id).await, |
| AgentStatus::NotFound |
| ); |
|
|
| let resumed_grandchild_snapshot = harness |
| .manager |
| .get_thread(grandchild_thread_id) |
| .await |
| .expect("resumed grandchild thread should exist") |
| .config_snapshot() |
| .await; |
| let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: resumed_parent_thread_id, |
| depth: resumed_depth, |
| .. |
| }) = resumed_grandchild_snapshot.session_source |
| else { |
| panic!("expected thread-spawn sub-agent source"); |
| }; |
| assert_eq!(resumed_parent_thread_id, child_thread_id); |
| assert_eq!(resumed_depth, 2); |
|
|
| let _ = harness |
| .control |
| .shutdown_agent_tree(parent_thread_id) |
| .await |
| .expect("tree shutdown after subtree resume should succeed"); |
| } |
|
|
| #[tokio::test] |
| async fn resume_agent_from_rollout_skips_descendants_when_parent_resume_fails() { |
| let harness = AgentControlHarness::new().await; |
| let (parent_thread_id, parent_thread) = harness.start_thread().await; |
|
|
| let child_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello child"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id, |
| depth: 1, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("explorer".to_string()), |
| })), |
| ) |
| .await |
| .expect("child spawn should succeed"); |
| let grandchild_thread_id = harness |
| .control |
| .spawn_agent( |
| harness.config.clone(), |
| text_input("hello grandchild"), |
| Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { |
| parent_thread_id: child_thread_id, |
| depth: 2, |
| agent_path: None, |
| agent_nickname: None, |
| agent_role: Some("worker".to_string()), |
| })), |
| ) |
| .await |
| .expect("grandchild spawn should succeed"); |
|
|
| let child_thread = harness |
| .manager |
| .get_thread(child_thread_id) |
| .await |
| .expect("child thread should exist"); |
| let grandchild_thread = harness |
| .manager |
| .get_thread(grandchild_thread_id) |
| .await |
| .expect("grandchild thread should exist"); |
| persist_thread_for_tree_resume(&parent_thread, "parent persisted").await; |
| persist_thread_for_tree_resume(&child_thread, "child persisted").await; |
| persist_thread_for_tree_resume(&grandchild_thread, "grandchild persisted").await; |
| wait_for_live_thread_spawn_children(&harness.control, parent_thread_id, &[child_thread_id]) |
| .await; |
| wait_for_live_thread_spawn_children(&harness.control, child_thread_id, &[grandchild_thread_id]) |
| .await; |
|
|
| let child_rollout_path = child_thread |
| .rollout_path() |
| .expect("child thread should have rollout path"); |
| let report = harness |
| .manager |
| .shutdown_all_threads_bounded(Duration::from_secs(5)) |
| .await; |
| assert_eq!(report.submit_failed, Vec::<ThreadId>::new()); |
| assert_eq!(report.timed_out, Vec::<ThreadId>::new()); |
| tokio::fs::remove_file(&child_rollout_path) |
| .await |
| .expect("child rollout path should be removable"); |
|
|
| let resumed_parent_thread_id = harness |
| .control |
| .resume_agent_from_rollout( |
| harness.config.clone(), |
| parent_thread_id, |
| SessionSource::Exec, |
| ) |
| .await |
| .expect("root resume should succeed"); |
| assert_eq!(resumed_parent_thread_id, parent_thread_id); |
| assert_ne!( |
| harness.control.get_status(parent_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_eq!( |
| harness.control.get_status(child_thread_id).await, |
| AgentStatus::NotFound |
| ); |
| assert_eq!( |
| harness.control.get_status(grandchild_thread_id).await, |
| AgentStatus::NotFound |
| ); |
|
|
| let _ = harness |
| .control |
| .shutdown_agent_tree(parent_thread_id) |
| .await |
| .expect("tree shutdown after partial subtree resume should succeed"); |
| } |
|
|