Files
codex/codex-rs/app-server/tests/suite/v2/external_agent_config.rs
charlesgong-openai 67ed4e717a Stop migrating Cursor sandbox settings (#39325)
## What changed

- Ignore `.cursor/sandbox.json` during external agent config migration.
- Continue importing supported settings from `.cursor/cli-config.json`.

## Testing

- Update app-server migration coverage to verify that environment variables from `cli-config.json` are imported.

GitOrigin-RevId: 6094f1cb6f698392d58056f826da21c1f1389a1b
2026-08-19 01:06:12 +00:00

2640 lines
93 KiB
Rust

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::<ExternalAgentConfigDetectResponse>(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": "<cursor_commands>\n/verify\n</cursor_commands>\n<timestamp>2026-07-26T18:00:00Z</timestamp>\n<user_query>first request</user_query>"
}]
}
})
.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 {
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::<Vec<_>>();
let expected_failures = completed
.item_type_results
.iter()
.flat_map(|type_result| type_result.failures.iter())
.collect::<Vec<_>>();
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<_>>(),
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<_>>(),
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<_>>(),
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::<Vec<serde_json::Value>, 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 = "<ide_selection>src/auth.rs:1-5</ide_selection>";
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 {
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: "<EXTERNAL SESSION IMPORTED>".into(),
phase: None,
memory_citation: None,
delivery: 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 {
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 {
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 {
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 {
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 {
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"),
)
}