use codex_utils_absolute_path::test_support::PathExt; use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::start_analytics_events_server; use app_test_support::write_chatgpt_auth; #[cfg(unix)] use codex_app_server_protocol::ConfigReadParams; #[cfg(unix)] use codex_app_server_protocol::ConfigReadResponse; #[cfg(unix)] use codex_app_server_protocol::ConfigRequirementsReadResponse; #[cfg(unix)] use codex_app_server_protocol::ConfigValueWriteParams; #[cfg(unix)] use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse; use codex_app_server_protocol::ExternalAgentConfigImportHistoryRecordResponse; use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification; use codex_app_server_protocol::ExternalAgentConfigImportResponse; #[cfg(unix)] use codex_app_server_protocol::ExternalAgentConfigImportTypeResult; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; use codex_app_server_protocol::ExternalAgentImportedConnectorCandidate; use codex_app_server_protocol::ExternalAgentImportedConnectorSource; #[cfg(unix)] use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; #[cfg(unix)] use codex_app_server_protocol::WriteStatus; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; #[cfg(unix)] use tokio::io::AsyncWriteExt; use tokio::time::timeout; use super::analytics::wait_for_analytics_event; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); const SECONDARY_MIGRATION_SOURCE: &str = concat!("cur", "sor"); fn external_agent_home(codex_home: &Path) -> PathBuf { codex_home.join(concat!(".", "cla", "ude")) } fn connector_metadata_root(home: &Path) -> PathBuf { #[cfg(target_os = "macos")] { home.join("Library/Application Support/Claude") } #[cfg(target_os = "windows")] { home.join("AppData/Roaming/Claude") } #[cfg(not(any(target_os = "macos", target_os = "windows")))] { home.join(".config/Claude") } } fn secondary_external_agent_home(codex_home: &Path) -> PathBuf { codex_home.join(concat!(".", "cur", "sor")) } fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String { assert!(!response.import_id.is_empty()); response.import_id } #[tokio::test] async fn external_agent_config_detect_accepts_migration_source_and_defaults_unknown_values() -> Result<()> { let codex_home = TempDir::new()?; let source_home = external_agent_home(codex_home.path()); std::fs::create_dir_all(&source_home)?; std::fs::write(source_home.join("CLAUDE.md"), "project instructions")?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let mut responses = Vec::new(); for params in [ serde_json::json!({ "includeHome": true }), serde_json::json!({ "includeHome": true, "migrationSource": "claude-code", }), serde_json::json!({ "includeHome": true, "migrationSource": "unknown-source", }), serde_json::json!({ "includeHome": true, "source": SECONDARY_MIGRATION_SOURCE, }), ] { let request_id = mcp .send_raw_request("externalAgentConfig/detect", Some(params)) .await?; responses.push( timeout( DEFAULT_TIMEOUT, mcp.read_response::(request_id), ) .await??, ); } assert_eq!(responses[0].items.len(), 1); assert_eq!( responses[0].items[0].item_type, ExternalAgentConfigMigrationItemType::AgentsMd ); let expected = responses[0].clone(); assert_eq!(responses, vec![expected; 4]); Ok(()) } #[cfg(unix)] #[tokio::test] async fn external_agent_config_import_skips_repository_redirect_after_detection() -> Result<()> { let codex_home = TempDir::new()?; let repository = TempDir::new()?; let repo_root = repository.path(); let repo_config_dir = repo_root.join(".codex"); let global_config = codex_home.path().join("config.toml"); std::fs::create_dir(repo_root.join(".git"))?; std::fs::create_dir(&repo_config_dir)?; std::fs::write( repo_root.join(".mcp.json"), r#"{"mcpServers":{"repository-server":{"command":"repository-server"}}}"#, )?; std::fs::write(&global_config, "model = \"gpt-5.4\"\n")?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let original_global_config = std::fs::read_to_string(&global_config)?; let detect_request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": false, "cwds": [repo_root], })), ) .await?; let detection: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(detect_request_id)).await??; assert_eq!(detection.items.len(), 1); assert_eq!( detection.items[0].item_type, ExternalAgentConfigMigrationItemType::McpServerConfig ); std::fs::remove_dir(&repo_config_dir)?; std::os::unix::fs::symlink(codex_home.path(), &repo_config_dir)?; let import_request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": detection.items, })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(import_request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!( completed, ExternalAgentConfigImportCompletedNotification { import_id, item_type_results: vec![ExternalAgentConfigImportTypeResult { item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, successes: Vec::new(), failures: Vec::new(), }], } ); assert_eq!( std::fs::read_to_string(global_config)?, original_global_config ); Ok(()) } #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_agent_config_detect_does_not_block_configuration_reads() -> Result<()> { let codex_home = TempDir::new()?; let project_root = codex_home.path().join("repo"); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; let status = std::process::Command::new("mkfifo") .arg(&session_path) .status()?; assert!(status.success()); let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let detect_request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true })), ) .await?; // Opening the FIFO for writing succeeds only after detection opens it for // reading. Keep the writer open without writing so transcript parsing stays // blocked while unrelated configuration requests run. let mut blocked_session_writer = timeout( DEFAULT_TIMEOUT, tokio::fs::OpenOptions::new() .write(true) .open(&session_path), ) .await??; let config_request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd: None, }) .await?; let _: ConfigReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(config_request_id)).await??; let requirements_request_id = mcp.send_config_requirements_read_request().await?; let _: ConfigRequirementsReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(requirements_request_id)).await??; let write_request_id = mcp .send_config_value_write_request(ConfigValueWriteParams { file_path: None, key_path: "model".to_string(), value: serde_json::json!("gpt-concurrent"), merge_strategy: MergeStrategy::Replace, expected_version: None, }) .await?; let write: ConfigWriteResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(write_request_id)).await??; assert_eq!(write.status, WriteStatus::Ok); let import_request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": [] })), ) .await?; let import_response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(import_request_id)).await??; assert!(!import_response.import_id.is_empty()); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_contents = serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, "message": { "content": "first request" }, }) .to_string(); // Later connector discovery reopens the transcript. Replace the named FIFO // before releasing its existing reader so subsequent opens see a real file. std::fs::remove_file(&session_path)?; std::fs::write(&session_path, &session_contents)?; blocked_session_writer .write_all(session_contents.as_bytes()) .await?; drop(blocked_session_writer); let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(detect_request_id)).await??; assert_eq!(detected.items.len(), 1); assert_eq!( detected.items[0].item_type, ExternalAgentConfigMigrationItemType::Sessions ); assert!( std::fs::read_to_string(codex_home.path().join("config.toml"))? .contains("model = \"gpt-concurrent\"") ); Ok(()) } #[tokio::test] async fn external_agent_config_migration_source_drives_detect_and_import() -> Result<()> { let codex_home = TempDir::new()?; let source_home = secondary_external_agent_home(codex_home.path()); std::fs::create_dir_all(&source_home)?; std::fs::write( source_home.join("cli-config.json"), r#"{"env":{"SOURCE":"secondary"}}"#, )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true, "migrationSource": SECONDARY_MIGRATION_SOURCE, })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(detected.items.len(), 1); assert_eq!( detected.items[0].item_type, ExternalAgentConfigMigrationItemType::Config ); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationSource": SECONDARY_MIGRATION_SOURCE, "migrationItems": detected.items, })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); assert_eq!(completed.item_type_results.len(), 1); assert_eq!(completed.item_type_results[0].successes.len(), 1); assert_eq!(completed.item_type_results[0].failures, Vec::new()); assert!( std::fs::read_to_string(codex_home.path().join("config.toml"))? .contains("SOURCE = \"secondary\"") ); Ok(()) } #[tokio::test] async fn external_agent_config_import_source_remains_attribution_only() -> Result<()> { let codex_home = TempDir::new()?; let source_home = external_agent_home(codex_home.path()); std::fs::create_dir_all(&source_home)?; std::fs::write(source_home.join("CLAUDE.md"), "Claude guidance")?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(detected.items.len(), 1); assert_eq!( detected.items[0].item_type, ExternalAgentConfigMigrationItemType::AgentsMd ); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "source": SECONDARY_MIGRATION_SOURCE, "migrationItems": detected.items, })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); assert_eq!(completed.item_type_results.len(), 1); assert_eq!(completed.item_type_results[0].successes.len(), 1); assert_eq!(completed.item_type_results[0].failures, Vec::new()); assert_eq!( std::fs::read_to_string(codex_home.path().join("AGENTS.md"))?, "Codex guidance" ); Ok(()) } #[tokio::test] async fn external_agent_config_secondary_source_imports_session_and_plugin_end_to_end() -> Result<()> { let codex_home = TempDir::new()?; let source_home = secondary_external_agent_home(codex_home.path()); let project_root = codex_home.path().join("my-project"); std::fs::create_dir_all(&project_root)?; let encoded_project = project_root .to_string_lossy() .trim_start_matches(['/', '\\']) .replace([':', '/', '\\'], "-"); let session_path = source_home .join("projects") .join(encoded_project) .join("agent-transcripts/session-1/session-1.jsonl"); std::fs::create_dir_all(session_path.parent().expect("session parent"))?; std::fs::write( &session_path, [ serde_json::json!({ "role": "user", "message": { "content": [{ "type": "text", "text": "\n/verify\n\n2026-07-26T18:00:00Z\nfirst request" }] } }) .to_string(), serde_json::json!({ "role": "assistant", "message": { "content": [{"type": "text", "text": "first answer"}] } }) .to_string(), ] .join("\n"), )?; let marketplace_root = source_home.join("plugins/marketplaces/debug"); let plugin_root = marketplace_root.join("plugins/sample"); let configured_marketplace_root = codex_home.path().join("configured-marketplace"); let configured_marketplace_manifest = configured_marketplace_root.join(".agents/plugins/marketplace.json"); let configured_plugin_root = configured_marketplace_root.join("plugins/sample"); std::fs::create_dir_all(marketplace_root.join(".cursor-plugin"))?; std::fs::create_dir_all(plugin_root.join(".cursor-plugin"))?; std::fs::create_dir_all(source_home.join("plugins/cache/debug/sample"))?; std::fs::create_dir_all( configured_marketplace_manifest .parent() .expect("configured marketplace manifest parent"), )?; std::fs::create_dir_all(configured_plugin_root.join(".codex-plugin"))?; std::fs::write( marketplace_root.join(".cursor-plugin/marketplace.json"), r#"{ "name": "debug", "plugins": [{"name": "sample", "source": "plugins/sample"}] }"#, )?; std::fs::write( plugin_root.join(".cursor-plugin/plugin.json"), r#"{"name":"sample","version":"0.2.0"}"#, )?; std::fs::write( &configured_marketplace_manifest, r#"{ "name": "debug", "plugins": [{ "name": "sample", "source": {"source": "local", "path": "./plugins/sample"} }] }"#, )?; std::fs::write( configured_plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample","version":"0.1.0"}"#, )?; std::fs::write( codex_home.path().join("config.toml"), format!( r#"[marketplaces.debug] source_type = "local" source = {:?} "#, configured_marketplace_root.display().to_string() ), )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true, "migrationSource": SECONDARY_MIGRATION_SOURCE, })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(detected.items.len(), 2); assert!( detected .items .iter() .any(|item| item.item_type == ExternalAgentConfigMigrationItemType::Sessions) ); assert!( detected .items .iter() .any(|item| item.item_type == ExternalAgentConfigMigrationItemType::Plugins) ); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationSource": SECONDARY_MIGRATION_SOURCE, "migrationItems": detected.items, })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); assert_eq!(completed.item_type_results.len(), 2); assert!( completed .item_type_results .iter() .all(|result| result.failures.is_empty()) ); let request_id = mcp .send_thread_list_request(ThreadListParams { originators: None, cursor: None, limit: None, sort_key: None, sort_direction: None, model_providers: None, source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: false, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }) .await?; let response: ThreadListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let thread = response.data.first().expect("imported session"); assert_eq!(thread.cwd.as_path(), project_root); assert_eq!(thread.preview, "first request"); assert_eq!(thread.name, None); let request_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread.id.clone(), include_turns: true, }) .await?; let response: ThreadReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.thread.turns.len(), 1); let imported_items = &response.thread.turns[0].items; assert_eq!(imported_items.len(), 3); match &imported_items[0] { ThreadItem::UserMessage { content, .. } => assert_eq!( content, &vec![UserInput::Text { text: "first request".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected user message item, got {other:?}"), } match &imported_items[1] { ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "first answer"), other => panic!("expected agent message item, got {other:?}"), } let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: None, marketplace_kinds: None, force_refetch: false, }) .await?; let response: PluginListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let marketplace = response .marketplaces .iter() .find(|marketplace| marketplace.name == "debug") .expect("configured marketplace"); assert_eq!( marketplace .path .as_ref() .map(codex_config::AbsolutePathBuf::as_path), Some(configured_marketplace_manifest.as_path()) ); let plugin = marketplace .plugins .iter() .find(|plugin| plugin.name == "sample") .expect("imported plugin"); assert_eq!(plugin.local_version.as_deref(), Some("0.1.0")); assert!(plugin.installed); assert!(plugin.enabled); Ok(()) } #[tokio::test] async fn external_agent_config_import_sends_completion_notification_for_sync_only_import() -> Result<()> { let codex_home = TempDir::new()?; let sqlite_home = TempDir::new()?; let home_dir = codex_home.path().display().to_string(); let sqlite_home_dir = sqlite_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[ ("HOME", Some(home_dir.as_str())), ("CODEX_SQLITE_HOME", Some(sqlite_home_dir.as_str())), ]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": [{ "itemType": "CONFIG", "description": "Import config", "cwd": null }] })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let progress: ExternalAgentConfigImportProgressNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/progress"), ) .await??; assert_eq!(progress.import_id, import_id); assert_eq!(progress.item_type_results.len(), 1); assert_eq!( progress.item_type_results[0].item_type, ExternalAgentConfigMigrationItemType::Config ); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); let state_db = codex_state::StateRuntime::init( codex_state::SqliteConfig::new_for_testing(sqlite_home.path().abs()), "mock_provider".into(), ) .await?; let details_record = state_db .external_agent_config_import_details_record(&import_id) .await? .expect("completed import details should be recorded by import id"); let expected_successes = completed .item_type_results .iter() .flat_map(|type_result| type_result.successes.iter()) .collect::>(); let expected_failures = completed .item_type_results .iter() .flat_map(|type_result| type_result.failures.iter()) .collect::>(); assert_eq!( serde_json::to_value(&details_record.successes)?, serde_json::to_value(&expected_successes)? ); assert_eq!( serde_json::to_value(&details_record.failures)?, serde_json::to_value(&expected_failures)? ); let request_id = mcp .send_raw_request( "externalAgentConfig/import/readHistories", /*params*/ None, ) .await?; let response: ExternalAgentConfigImportHistoriesReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.connectors, Vec::new()); let entry = response .data .iter() .find(|entry| entry.import_id == import_id) .expect("import history entry should be available"); assert!(entry.completed_at_ms > 0); assert_eq!( serde_json::to_value(&entry.successes)?, serde_json::to_value(&expected_successes)? ); assert_eq!( serde_json::to_value(&entry.failures)?, serde_json::to_value(&expected_failures)? ); Ok(()) } #[tokio::test] async fn external_agent_config_records_externally_completed_import_history() -> Result<()> { let codex_home = TempDir::new()?; let sqlite_home = TempDir::new()?; let home_dir = codex_home.path().display().to_string(); let sqlite_home_dir = sqlite_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[ ("HOME", Some(home_dir.as_str())), ("CODEX_SQLITE_HOME", Some(sqlite_home_dir.as_str())), ]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/import/recordHistory", Some(serde_json::json!({ "providerId": "external-provider", "itemTypeResults": [{ "itemType": "SESSIONS", "successes": [{ "itemType": "SESSIONS", "cwd": "/repo", "source": "/source/session.jsonl", "target": "thread-1", }], "failures": [], }], })), ) .await?; let record_response: ExternalAgentConfigImportHistoryRecordResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert!(!record_response.import_id.is_empty()); let request_id = mcp .send_raw_request( "externalAgentConfig/import/readHistories", /*params*/ None, ) .await?; let history_response: ExternalAgentConfigImportHistoriesReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let entry = history_response .data .iter() .find(|entry| entry.import_id == record_response.import_id) .expect("externally completed import history entry should be available"); assert_eq!(entry.provider_id.as_deref(), Some("external-provider")); assert!(entry.completed_at_ms > 0); assert_eq!( serde_json::to_value(&entry.successes)?, serde_json::json!([{ "itemType": "SESSIONS", "cwd": "/repo", "source": "/source/session.jsonl", "target": "thread-1", "title": null, }]) ); assert_eq!(entry.failures, Vec::new()); Ok(()) } #[tokio::test] async fn external_agent_memory_import_requires_feature_config() -> Result<()> { let codex_home = TempDir::new()?; let source_home = external_agent_home(codex_home.path()); let source_memory = source_home.join("projects/project-a/memory"); std::fs::create_dir_all(&source_memory)?; let source_file = source_memory.join("MEMORY.md"); std::fs::write(&source_file, "project A memory")?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(detected.items, Vec::new()); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": [{ "itemType": "MEMORY", "description": "Import memory", "cwd": null, "details": { "memory": ["project-a"] } }] })), ) .await?; let error = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!( error.error.message, "external agent memory import is disabled" ); assert!( !codex_home .path() .join("memories/extensions/external_agent_import") .exists() ); Ok(()) } #[tokio::test] async fn external_agent_config_detects_non_memory_items_when_config_reload_fails() -> Result<()> { let codex_home = TempDir::new()?; let source_home = external_agent_home(codex_home.path()); std::fs::create_dir_all(&source_home)?; std::fs::write(source_home.join("CLAUDE.md"), "project instructions")?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; std::fs::write( codex_home.path().join("config.toml"), "this is not valid = [toml", )?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!( detected .items .iter() .map(|item| item.item_type) .collect::>(), vec![ExternalAgentConfigMigrationItemType::AgentsMd] ); Ok(()) } #[tokio::test] async fn external_agent_config_detects_and_imports_project_memory_files() -> Result<()> { let codex_home = TempDir::new()?; let source_home = external_agent_home(codex_home.path()); let source_project = source_home.join("projects/project-a"); let source_memory = source_project.join("memory"); let project_cwd = codex_home.path().join("project-a"); std::fs::create_dir_all(&source_memory)?; std::fs::create_dir_all(&project_cwd)?; let project_cwd = std::fs::canonicalize(project_cwd)?; let source_file = source_memory.join("MEMORY.md"); let source_topic = source_memory.join("release-process.md"); std::fs::write(&source_file, "project A memory")?; std::fs::write(&source_topic, "project A release process")?; std::fs::write( source_project.join("session.jsonl"), serde_json::json!({ "type": "user", "cwd": &project_cwd, "timestamp": "2026-07-13T00:00:00Z", "message": { "content": "remember this" }, }) .to_string(), )?; std::fs::write( codex_home.path().join("config.toml"), "[features]\nexternal_agent_memory_import = true\n", )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; for details in [serde_json::json!({}), serde_json::json!({ "memory": [] })] { let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": [{ "itemType": "MEMORY", "description": "Import memory", "cwd": null, "details": details, }] })), ) .await?; let error = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!( error.error.message, "memory import requires at least one selected memory" ); } let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true })), ) .await?; let mut detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; detected .items .retain(|item| item.item_type == ExternalAgentConfigMigrationItemType::Memory); assert_eq!(detected.items.len(), 1); let memory_item = &detected.items[0]; assert_eq!( memory_item.item_type, ExternalAgentConfigMigrationItemType::Memory ); assert_eq!(memory_item.cwd, None); assert_eq!( memory_item .details .as_ref() .expect("memory details") .memory .iter() .map(String::as_str) .collect::>(), vec!["project-a"] ); detected.items[0] .details .as_mut() .expect("memory details") .memory .push("missing-project".to_string()); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": detected.items })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); assert_eq!(completed.item_type_results.len(), 1); let memory_result = &completed.item_type_results[0]; assert_eq!( memory_result.item_type, ExternalAgentConfigMigrationItemType::Memory ); assert_eq!(memory_result.failures.len(), 1); assert_eq!( memory_result.failures[0].source.as_deref(), Some("missing-project") ); assert_eq!(memory_result.failures[0].failure_stage, "memory_import"); assert_eq!(memory_result.successes.len(), 1); assert_eq!( memory_result.successes[0].source.as_deref(), Some("project-a") ); let imported_resources_root = PathBuf::from( memory_result.successes[0] .target .as_deref() .expect("memory target"), ); let expected_resources_root = codex_home .path() .join("memories/extensions/external_agent_import/resources"); assert_eq!( std::fs::canonicalize(&imported_resources_root)?, std::fs::canonicalize(expected_resources_root)?, ); let imported_files = [ imported_resources_root.join("project-a/MEMORY.md"), imported_resources_root.join("project-a/release-process.md"), ]; assert_eq!( std::fs::read_to_string(&imported_files[0])?, "project A memory" ); assert_eq!( std::fs::read_to_string(&imported_files[1])?, "project A release process" ); let imported_scope: serde_json::Value = serde_json::from_slice(&std::fs::read( imported_resources_root.join("project-a/scope.json"), )?)?; assert_eq!(imported_scope, serde_json::json!({ "cwd": project_cwd })); let memory_root = codex_home.path().join("memories"); let memory_diff = codex_git_utils::diff_since_latest_init(&memory_root).await?; for relative_path in [ "extensions/external_agent_import/resources/project-a/MEMORY.md", "extensions/external_agent_import/resources/project-a/release-process.md", "extensions/external_agent_import/resources/project-a/scope.json", ] { assert!( memory_diff .changes .iter() .any(|change| change.path == relative_path) ); } codex_memories_write::workspace::reset_memory_workspace_baseline(&memory_root).await?; std::fs::remove_dir_all(&source_project)?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true })), ) .await?; let mut detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; detected .items .retain(|item| item.item_type == ExternalAgentConfigMigrationItemType::Memory); assert_eq!(detected.items.len(), 1); assert_eq!( detected.items[0] .details .as_ref() .expect("memory details") .memory, vec!["project-a"] ); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": detected.items })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); assert_eq!(completed.item_type_results.len(), 1); assert_eq!(completed.item_type_results[0].failures, Vec::new()); assert_eq!(completed.item_type_results[0].successes.len(), 1); assert_eq!( completed.item_type_results[0].successes[0] .source .as_deref(), Some("project-a") ); assert!(!imported_resources_root.join("project-a").exists()); let memory_diff = codex_git_utils::diff_since_latest_init(&memory_root).await?; assert_eq!( memory_diff .changes .iter() .map(|change| (change.status, change.path.as_str())) .collect::>(), vec![ ( codex_git_utils::GitBaselineChangeStatus::Deleted, "extensions/external_agent_import/resources/project-a/MEMORY.md", ), ( codex_git_utils::GitBaselineChangeStatus::Deleted, "extensions/external_agent_import/resources/project-a/release-process.md", ), ( codex_git_utils::GitBaselineChangeStatus::Deleted, "extensions/external_agent_import/resources/project-a/scope.json", ), ] ); Ok(()) } #[tokio::test] async fn external_agent_config_import_reports_failed_sync_import_in_completion() -> Result<()> { let codex_home = TempDir::new()?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("chatgpt-token") .account_id("account-123") .chatgpt_user_id("user-123") .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; let source_home = external_agent_home(codex_home.path()); std::fs::create_dir_all(&source_home)?; std::fs::write( source_home.join("settings.json"), r#"{"env":{"FOO":"bar"}}"#, )?; std::fs::write(codex_home.path().join("config.toml"), "invalid = [")?; let home_dir = codex_home.path().display().to_string(); let analytics_capture_file = codex_home.path().join("analytics-events.jsonl"); let analytics_capture_file = analytics_capture_file.display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[ ("HOME", Some(home_dir.as_str())), ( "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE", Some(analytics_capture_file.as_str()), ), ]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "source": "test_import", "providerId": "test-provider-42", "migrationItems": [ { "itemType": "CONFIG", "description": "Import config", "cwd": null }, { "itemType": "COMMANDS", "description": "Import commands", "cwd": null } ] })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); let config_result = completed .item_type_results .iter() .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Config) .expect("config result"); assert!(config_result.successes.is_empty()); assert_eq!(config_result.failures.len(), 1); let config_failure = &config_result.failures[0]; assert_eq!( config_failure.error_type.as_deref(), Some("invalid_existing_config") ); assert_eq!(config_failure.failure_stage, "import_request_failed"); assert!( config_failure .message .contains("invalid existing config.toml"), "unexpected failure: {config_failure:?}" ); let commands_result = completed .item_type_results .iter() .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Commands) .expect("commands result"); assert!(commands_result.successes.is_empty()); assert!(commands_result.failures.is_empty()); let events = timeout(DEFAULT_TIMEOUT, async { loop { let contents = match std::fs::read_to_string(&analytics_capture_file) { Ok(contents) => contents, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { tokio::time::sleep(Duration::from_millis(25)).await; continue; } Err(err) => return Err(err.into()), }; let mut captured_events = Vec::new(); for line in contents.lines() { let payload: serde_json::Value = serde_json::from_str(line)?; let Some(events) = payload["events"].as_array() else { continue; }; captured_events.extend(events.iter().cloned()); } if captured_events.iter().any(|event| { event["event_type"] == "codex_onboarding_external_agent_import_complete" && event["event_params"]["type"] == "COMMANDS" }) { return Ok::, anyhow::Error>(captured_events); } tokio::time::sleep(Duration::from_millis(25)).await; } }) .await??; let event = events .iter() .find(|event| { event["event_type"] == "codex_onboarding_external_agent_import_failure" && event["event_params"]["type"] == "CONFIG" }) .expect("config failure analytics event"); let event_params = &event["event_params"]; assert_eq!(event_params["import_id"], import_id); assert_eq!(event_params["source"], "test_import"); assert_eq!(event_params["provider_id"], "test-provider-42"); assert_eq!(event_params["type"], "CONFIG"); assert_eq!(event_params["failure_stage"], "import_request_failed"); assert_eq!(event_params["error_type"], "invalid_existing_config"); assert!(event_params.get("raw_errors").is_none()); assert!(event_params.get("message").is_none()); assert!(!events.iter().any(|event| { event["event_type"] == "codex_onboarding_external_agent_import_failure" && event["event_params"]["type"] == "COMMANDS" })); Ok(()) } #[tokio::test] async fn external_agent_config_import_completed_tracks_analytics_event() -> Result<()> { let analytics_server = start_analytics_events_server().await?; let codex_home = TempDir::new()?; write_analytics_config(codex_home.path(), &analytics_server.uri())?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("chatgpt-token") .account_id("account-123") .chatgpt_user_id("user-123") .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; let missing_session_path = external_agent_home(codex_home.path()).join("projects/repo/missing.jsonl"); let project_root = codex_home.path().join("repo"); let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "source": "test_import", "providerId": "test-provider-42", "migrationSource": SECONDARY_MIGRATION_SOURCE, "migrationItems": [{ "itemType": "SESSIONS", "description": "Migrate recent sessions", "cwd": null, "details": { "sessions": [{ "path": missing_session_path, "cwd": project_root, "title": "missing session" }] } }] })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); assert_eq!(completed.item_type_results.len(), 1); assert_eq!(completed.item_type_results[0].successes.len(), 0); assert_eq!(completed.item_type_results[0].failures.len(), 1); assert_eq!( completed.item_type_results[0].failures[0] .sub_error_type .as_deref(), Some("session_not_detected") ); let event = wait_for_analytics_event( &analytics_server, DEFAULT_TIMEOUT, "codex_onboarding_external_agent_import_complete", ) .await?; let event_params = &event["event_params"]; assert_eq!(event_params["import_id"], serde_json::json!(import_id)); assert_eq!(event_params["source"], "test_import"); assert_eq!(event_params["provider_id"], "test-provider-42"); assert_eq!(event_params["type"], "SESSIONS"); assert_eq!(event_params["success_count"], 0); assert_eq!(event_params["failed_count"], 1); assert!(event_params.get("raw_errors").is_none()); let event = wait_for_analytics_event( &analytics_server, DEFAULT_TIMEOUT, "codex_onboarding_external_agent_import_failure", ) .await?; let event_params = &event["event_params"]; assert_eq!(event_params["import_id"], serde_json::json!(import_id)); assert_eq!(event_params["source"], "test_import"); assert_eq!(event_params["provider_id"], "test-provider-42"); assert_eq!(event_params["type"], "SESSIONS"); assert_eq!(event_params["failure_stage"], "session_missing"); assert_eq!(event_params["error_type"], "session_missing"); assert_eq!(event_params["sub_error_type"], "session_not_detected"); assert!(event_params.get("raw_errors").is_none()); assert!(event_params.get("message").is_none()); Ok(()) } #[tokio::test] async fn external_agent_config_import_reports_session_config_error_subtype() -> Result<()> { let analytics_server = start_analytics_events_server().await?; let codex_home = TempDir::new()?; write_analytics_config(codex_home.path(), &analytics_server.uri())?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("chatgpt-token") .account_id("account-123") .chatgpt_user_id("user-123") .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; let project_root = codex_home.path().join("repo"); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; std::fs::write( &session_path, serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, "message": { "content": "first request" }, }) .to_string(), )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; std::fs::write( codex_home.path().join("config.toml"), "chatgpt_base_url = [", )?; let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "source": "test_import", "providerId": "test-provider-42", "migrationItems": [{ "itemType": "SESSIONS", "description": "Migrate recent sessions", "cwd": null, "details": { "sessions": [{ "path": session_path, "cwd": project_root, "title": "first request" }] } }] })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); assert_eq!(completed.item_type_results.len(), 1); assert_eq!(completed.item_type_results[0].successes.len(), 0); assert_eq!(completed.item_type_results[0].failures.len(), 1); let failure = &completed.item_type_results[0].failures[0]; assert_eq!(failure.failure_stage, "session_persist"); assert_eq!( failure.sub_error_type.as_deref(), Some("failed_to_load_session_config_invalid_data") ); let event = wait_for_analytics_event( &analytics_server, DEFAULT_TIMEOUT, "codex_onboarding_external_agent_import_failure", ) .await?; let event_params = &event["event_params"]; assert_eq!(event_params["import_id"], serde_json::json!(import_id)); assert_eq!(event_params["source"], "test_import"); assert_eq!(event_params["provider_id"], "test-provider-42"); assert_eq!(event_params["type"], "SESSIONS"); assert_eq!(event_params["failure_stage"], "session_persist"); assert_eq!( event_params["sub_error_type"], "failed_to_load_session_config_invalid_data" ); assert!(event_params.get("raw_errors").is_none()); assert!(event_params.get("message").is_none()); Ok(()) } #[tokio::test] async fn external_agent_config_import_reinstalls_plugins_from_known_marketplaces() -> Result<()> { let codex_home = TempDir::new()?; let analytics_server = start_analytics_events_server().await?; write_analytics_config(codex_home.path(), &analytics_server.uri())?; write_chatgpt_auth( codex_home.path(), ChatGptAuthFixture::new("chatgpt-token") .account_id("account-123") .chatgpt_user_id("user-123") .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; let marketplace_root = codex_home.path().join("marketplace"); let plugin_root = marketplace_root.join("plugins").join("sample"); std::fs::create_dir_all(marketplace_root.join(".agents/plugins"))?; std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( marketplace_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "debug", "plugins": [ { "name": "sample", "source": { "source": "local", "path": "./plugins/sample" } } ] }"#, )?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample","version":"0.1.0"}"#, )?; let source_home = external_agent_home(codex_home.path()); std::fs::create_dir_all(source_home.join("plugins"))?; let settings = serde_json::json!({ "enabledPlugins": { "missing@debug": true, "sample@debug": true, }, "extraKnownMarketplaces": { "debug": { "source": { "source": "file", "path": marketplace_root.join(".agents/plugins/marketplace.json"), } } } }); std::fs::write( source_home.join("settings.json"), serde_json::to_string_pretty(&settings)?, )?; std::fs::write( source_home.join("plugins/known_marketplaces.json"), serde_json::to_string_pretty(&serde_json::json!({ "debug": { "source": { "source": "file", "path": marketplace_root.join(".agents/plugins/marketplace.json"), }, "installLocation": marketplace_root, "lastUpdated": "2026-07-09T00:16:23.611Z", } }))?, )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(detected.items.len(), 1); assert_eq!( detected.items[0].item_type, ExternalAgentConfigMigrationItemType::Plugins ); assert_eq!( detected.items[0] .details .as_ref() .map(|details| details.plugins.clone()), Some(vec![codex_app_server_protocol::PluginsMigration { marketplace_name: "debug".to_string(), plugin_names: vec!["missing".to_string(), "sample".to_string()], }]) ); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": detected.items })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); assert_eq!(completed.item_type_results.len(), 1); let plugin_result = &completed.item_type_results[0]; assert_eq!( plugin_result.item_type, ExternalAgentConfigMigrationItemType::Plugins ); assert_eq!(plugin_result.successes.len(), 1); assert_eq!( plugin_result.successes[0].source.as_deref(), Some("sample@debug") ); assert_eq!(plugin_result.failures.len(), 1); assert_eq!( plugin_result.failures[0].source.as_deref(), Some("missing@debug") ); assert_eq!( plugin_result.failures[0].error_type.as_deref(), Some("plugin_not_found") ); assert_eq!(plugin_result.failures[0].failure_stage, "plugin_import"); assert_eq!( plugin_result.failures[0].message, "plugin `missing` was not found in marketplace `debug`" ); let event = wait_for_analytics_event( &analytics_server, DEFAULT_TIMEOUT, "codex_plugin_install_failed", ) .await?; let event_params = &event["event_params"]; assert_eq!(event_params["plugin_id"], "missing@debug"); assert_eq!(event_params["plugin_name"], "missing"); assert_eq!(event_params["marketplace_name"], "debug"); assert_eq!(event_params["source"], "external_agent_migration"); assert_eq!(event_params["error_type"], "plugin_not_found"); let event = wait_for_analytics_event( &analytics_server, DEFAULT_TIMEOUT, "codex_onboarding_external_agent_import_failure", ) .await?; let event_params = &event["event_params"]; assert_eq!(event_params["type"], "PLUGINS"); assert_eq!(event_params["failure_stage"], "plugin_import"); assert_eq!(event_params["error_type"], "plugin_not_found"); let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: None, marketplace_kinds: None, force_refetch: false, }) .await?; let response: PluginListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let plugin = response .marketplaces .iter() .find(|marketplace| marketplace.name == "debug") .and_then(|marketplace| { marketplace .plugins .iter() .find(|plugin| plugin.name == "sample") }) .expect("expected imported plugin to be listed"); assert!(plugin.installed); assert!(plugin.enabled); Ok(()) } #[tokio::test] async fn external_agent_config_import_sends_completion_notification_after_pending_plugins_finish() -> Result<()> { let codex_home = TempDir::new()?; let source_home = external_agent_home(codex_home.path()); std::fs::create_dir_all(&source_home)?; // This test only needs a pending non-local plugin import. Use an invalid // source so the background completion path cannot make a real network clone. std::fs::write( source_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true }, "extraKnownMarketplaces": { "acme-tools": { "source": "not a valid marketplace source" } } }"#, )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": [{ "itemType": "PLUGINS", "description": "Import plugins", "cwd": null, "details": { "plugins": [{ "marketplaceName": "acme-tools", "pluginNames": ["formatter"] }] } }] })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); Ok(()) } #[tokio::test] async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("follow-up answer").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let project_root = codex_home.path().join("repo"); let source_created_at_text = "2024-01-02T03:04:05Z"; let source_updated_at_text = "2024-03-01T04:05:06Z"; let source_created_at = chrono::DateTime::parse_from_rfc3339(source_created_at_text)?.timestamp(); let source_updated_at = chrono::DateTime::parse_from_rfc3339(source_updated_at_text)?.timestamp(); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); let manifest_dir = connector_metadata_root(codex_home.path()) .join("claude-code-sessions/account/organization"); let control_request = "src/auth.rs:1-5"; let first_request = "Fix auth flow"; std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; std::fs::create_dir_all(&manifest_dir)?; std::fs::write( manifest_dir.join("session.json"), serde_json::json!({ "cliSessionId": "session", "remoteMcpServersConfig": [ { "name": "Gmail", "uuid": "gmail-server" }, { "name": "Slack", "uuid": "slack-server" }, ], }) .to_string(), )?; std::fs::write( &session_path, [ serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": source_created_at_text, "message": { "content": control_request }, }) .to_string(), serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": "2024-01-03T00:00:00Z", "message": { "content": first_request }, }) .to_string(), serde_json::json!({ "type": "assistant", "cwd": &project_root, "timestamp": source_updated_at_text, "attributionMcpServer": "gmail", "message": { "content": "first answer" }, }) .to_string(), ] .join("\n"), )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true, })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(detected.items.len(), 1); assert_eq!( detected.items[0] .details .as_ref() .and_then(|details| details.sessions.first()) .and_then(|session| session.title.as_deref()), Some("Fix auth flow") ); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": detected.items })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); assert_eq!(completed.item_type_results.len(), 1); let session_result = &completed.item_type_results[0]; assert_eq!( session_result.item_type, ExternalAgentConfigMigrationItemType::Sessions ); assert_eq!(session_result.failures, Vec::new()); assert_eq!(session_result.successes.len(), 1); let session_success = &session_result.successes[0]; assert_eq!( session_success.item_type, ExternalAgentConfigMigrationItemType::Sessions ); let session_source = std::fs::canonicalize(&session_path)?.display().to_string(); assert_eq!( session_success.source.as_deref(), Some(session_source.as_str()) ); let imported_thread_id = session_success .target .as_deref() .expect("session success should include imported thread id") .to_string(); let request_id = mcp .send_raw_request( "externalAgentConfig/import/readHistories", /*params*/ None, ) .await?; let response: ExternalAgentConfigImportHistoriesReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!( response.connectors, vec![ExternalAgentImportedConnectorCandidate { name: "Gmail".to_string(), session_count: 1, source: ExternalAgentImportedConnectorSource::RemoteMcpServersConfig, }] ); let imported_session = response .data .iter() .flat_map(|history| history.successes.iter()) .find(|success| success.item_type == ExternalAgentConfigMigrationItemType::Sessions) .expect("imported session history should be available"); assert_eq!(imported_session.title.as_deref(), Some("Fix auth flow")); let request_id = mcp .send_thread_list_request(ThreadListParams { originators: None, cursor: None, limit: None, sort_key: None, sort_direction: None, model_providers: None, source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: true, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }) .await?; let response: ThreadListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let thread = response .data .first() .expect("expected imported thread") .clone(); assert_eq!(imported_thread_id, thread.id.to_string()); assert_eq!(thread.preview, control_request); assert_eq!(thread.name.as_deref(), Some("Fix auth flow")); assert_eq!(thread.created_at, source_created_at); assert_eq!(thread.updated_at, source_updated_at); assert_eq!(thread.recency_at, Some(source_updated_at)); let request_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread.id.clone(), include_turns: true, }) .await?; let response: ThreadReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.thread.turns.len(), 2); let control_items = &response.thread.turns[0].items; assert_eq!(control_items.len(), 1); match &control_items[0] { ThreadItem::UserMessage { content, .. } => { assert_eq!( content, &vec![UserInput::Text { text: control_request.to_string(), text_elements: Vec::new(), }] ); } other => panic!("expected user message item, got {other:?}"), } let imported_items = &response.thread.turns[1].items; assert_eq!(imported_items.len(), 3); match &imported_items[0] { ThreadItem::UserMessage { content, .. } => { assert_eq!( content, &vec![UserInput::Text { text: first_request.to_string(), text_elements: Vec::new(), }] ); } other => panic!("expected user message item, got {other:?}"), } assert_eq!( imported_items.last(), Some(&ThreadItem::AgentMessage { id: "item-4".into(), text: "".into(), phase: None, memory_citation: None, delivery: None, questions: None, }) ); let request_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread.id.clone(), ..Default::default() }) .await?; let _: ThreadResumeResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let request_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), client_user_message_id: None, input: vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; let _: TurnStartResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; let request_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread.id, include_turns: true, }) .await?; let response: ThreadReadResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.thread.turns.len(), 3); match &response.thread.turns[2].items[1] { ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "follow-up answer"), other => panic!("expected agent message item, got {other:?}"), } Ok(()) } #[tokio::test] async fn external_agent_config_import_does_not_initialize_required_mcp() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("unused").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let mut config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; config.push_str( r#" [mcp_servers.required_broken] command = "this-command-does-not-exist" required = true "#, ); std::fs::write(codex_home.path().join("config.toml"), config)?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; std::fs::write( &session_path, serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, "message": { "content": "first request" }, }) .to_string(), )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": [{ "itemType": "SESSIONS", "description": "Migrate recent sessions", "cwd": null, "details": { "sessions": [{ "path": session_path, "cwd": project_root, "title": "first request" }] } }] })), ) .await?; let _: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), ) .await??; let request_id = mcp .send_thread_list_request(ThreadListParams { originators: None, cursor: None, limit: None, sort_key: None, sort_direction: None, model_providers: None, source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: false, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }) .await?; let response: ThreadListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.data.len(), 1); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_agent_config_import_accepts_detected_session_payload_after_restart() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("unused").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; std::fs::write( &session_path, serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, "message": { "content": "first request" }, }) .to_string(), )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": [{ "itemType": "SESSIONS", "description": "Migrate recent sessions", "cwd": null, "details": { "sessions": [{ "path": session_path, "cwd": project_root, "title": "first request" }] } }] })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); let request_id = mcp .send_thread_list_request(ThreadListParams { originators: None, cursor: None, limit: None, sort_key: None, sort_direction: None, model_providers: None, source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: false, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }) .await?; let response: ThreadListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.data.len(), 1); Ok(()) } #[tokio::test] async fn external_agent_config_import_skips_already_imported_session_versions() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("unused").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; std::fs::write( &session_path, serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, "message": { "content": "first request" }, }) .to_string(), )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; for _ in 0..2 { let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": detected.items.clone() })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); } let request_id = mcp .send_thread_list_request(ThreadListParams { originators: None, cursor: None, limit: None, sort_key: None, sort_direction: None, model_providers: None, source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: false, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }) .await?; let response: ThreadListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.data.len(), 1); Ok(()) } #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_agent_config_import_returns_before_background_session_import_finishes() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("unused").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; let session_contents = serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, "message": { "content": "first request" }, }) .to_string(); std::fs::write(&session_path, &session_contents)?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(detected.items.len(), 1); let detected_items = detected.items; std::fs::remove_file(&session_path)?; let status = std::process::Command::new("mkfifo") .arg(&session_path) .status()?; assert!(status.success()); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": detected_items.clone() })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(Duration::from_secs(5), mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); assert!( timeout( Duration::from_millis(200), mcp.read_stream_until_notification_message("externalAgentConfig/import/completed") ) .await .is_err(), "session import completed before the blocked background import was unblocked" ); let duplicate_request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": detected_items })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout( Duration::from_secs(5), mcp.read_response(duplicate_request_id), ) .await??; let duplicate_import_id = assert_import_response(response); let mut completed_import_ids = Vec::new(); for _ in 0..2 { timeout(DEFAULT_TIMEOUT, async { let mut file = tokio::fs::OpenOptions::new() .write(true) .open(&session_path) .await?; file.write_all(session_contents.as_bytes()).await }) .await??; let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; completed_import_ids.push(completed.import_id); } completed_import_ids.sort(); let mut expected_import_ids = vec![import_id, duplicate_import_id]; expected_import_ids.sort(); assert_eq!(completed_import_ids, expected_import_ids); let request_id = mcp .send_thread_list_request(ThreadListParams { originators: None, cursor: None, limit: None, sort_key: None, sort_direction: None, model_providers: None, source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: false, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }) .await?; let response: ThreadListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(response.data.len(), 1); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn external_agent_config_import_compacts_huge_session_before_first_follow_up() -> Result<()> { let server = responses::start_mock_server().await; let response_log = responses::mount_sse_sequence( &server, vec![ responses::sse(vec![ responses::ev_assistant_message("m1", "LOCAL_SUMMARY"), responses::ev_completed_with_tokens("r1", /*total_tokens*/ 120), ]), responses::sse(vec![ responses::ev_assistant_message("m2", "follow-up answer"), responses::ev_completed_with_tokens("r2", /*total_tokens*/ 80), ]), ], ) .await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()) .with_root_config( "compact_prompt = \"Summarize the conversation.\"\nmodel_auto_compact_token_limit = 200", ) .with_provider_config("supports_websockets = false") .write(codex_home.path())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; let huge_user = "u".repeat(20_000); let huge_assistant = "a".repeat(20_000); std::fs::write( &session_path, [ serde_json::json!({ "type": "user", "cwd": &project_root, "timestamp": &recent_timestamp, "message": { "content": &huge_user }, }) .to_string(), serde_json::json!({ "type": "assistant", "cwd": &project_root, "timestamp": &recent_timestamp, "message": { "content": &huge_assistant }, }) .to_string(), ] .join("\n"), )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .with_env_overrides(&[("HOME", Some(home_dir.as_str()))]) .build_initialized_with_timeout(DEFAULT_TIMEOUT) .await?; let request_id = mcp .send_raw_request( "externalAgentConfig/detect", Some(serde_json::json!({ "includeHome": true, })), ) .await?; let detected: ExternalAgentConfigDetectResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(detected.items.len(), 1); let request_id = mcp .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ "migrationItems": detected.items })), ) .await?; let response: ExternalAgentConfigImportResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let import_id = assert_import_response(response); let completed: ExternalAgentConfigImportCompletedNotification = timeout( DEFAULT_TIMEOUT, mcp.read_notification("externalAgentConfig/import/completed"), ) .await??; assert_eq!(completed.import_id, import_id); let request_id = mcp .send_thread_list_request(ThreadListParams { originators: None, cursor: None, limit: None, sort_key: None, sort_direction: None, model_providers: None, source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: false, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }) .await?; let response: ThreadListResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let thread = response .data .first() .expect("expected imported thread") .clone(); let request_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread.id.clone(), ..Default::default() }) .await?; let _: ThreadResumeResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; let request_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), client_user_message_id: None, input: vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; let _: TurnStartResponse = timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; let requests = response_log.requests(); assert_eq!(requests.len(), 2); let first = requests[0].body_json().to_string(); let second = requests[1].body_json().to_string(); assert!(first.contains("Summarize the conversation.")); assert!(!first.contains("follow up")); assert!(second.contains("follow up")); assert!(second.contains("LOCAL_SUMMARY")); Ok(()) } fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { std::fs::write( codex_home.join("config.toml"), format!("chatgpt_base_url = \"{base_url}\"\n"), ) }