use std::time::Duration;use anyhow::Result;use app_test_support::ChatGptAuthFixture;use app_test_support::TestAppServer;use app_test_support::create_mock_responses_server_repeating_assistant;use app_test_support::to_response;use app_test_support::write_chatgpt_auth;use codex_app_server_protocol::ConfigReadParams;use codex_app_server_protocol::ConfigReadResponse;use codex_app_server_protocol::ExperimentalFeature;use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;use codex_app_server_protocol::ExperimentalFeatureListParams;use codex_app_server_protocol::ExperimentalFeatureListResponse;use codex_app_server_protocol::ExperimentalFeatureStage;use codex_app_server_protocol::JSONRPCError;use codex_app_server_protocol::JSONRPCResponse;use codex_app_server_protocol::RequestId;use codex_app_server_protocol::ThreadStartParams;use codex_app_server_protocol::ThreadStartResponse;use codex_config::LoaderOverrides;use codex_config::types::AuthCredentialsStoreMode;use codex_core::config::ConfigBuilder;use codex_features::FEATURES;use codex_features::Stage;use pretty_assertions::assert_eq;use serde::de::DeserializeOwned;use serde_json::json;use std::collections::BTreeMap;use tempfile::TempDir;use tokio::time::timeout;use wiremock::Mock;use wiremock::MockServer;use wiremock::ResponseTemplate;use wiremock::matchers::header;use wiremock::matchers::method;use wiremock::matchers::path;const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);#[tokio::test]async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Result<()> { let codex_home = TempDir::new()?; let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .loader_overrides(LoaderOverrides::with_managed_config_path_for_tests( codex_home.path().join("managed_config.toml"), )) .build() .await?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) .await?; let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?; let expected_data = FEATURES .iter() .map(|spec| { let (stage, display_name, description, announcement) = match spec.stage { Stage::Experimental { name, menu_description, announcement, } => ( ExperimentalFeatureStage::Beta, Some(name.to_string()), Some(menu_description.to_string()), Some(announcement.to_string()), ), Stage::UnderDevelopment => { (ExperimentalFeatureStage::UnderDevelopment, None, None, None) } Stage::Stable => (ExperimentalFeatureStage::Stable, None, None, None), Stage::Deprecated => (ExperimentalFeatureStage::Deprecated, None, None, None), Stage::Removed => (ExperimentalFeatureStage::Removed, None, None, None), }; ExperimentalFeature { name: spec.key.to_string(), stage, display_name, description, announcement, enabled: config.features.enabled(spec.id), default_enabled: spec.default_enabled, } }) .collect::<Vec<_>>(); let expected = ExperimentalFeatureListResponse { data: expected_data, next_cursor: None, }; assert_eq!(actual, expected); Ok(())}#[tokio::test]async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_policy()-> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; std::fs::write( codex_home.path().join("config.toml"), format!( r#"chatgpt_base_url = "{}/backend-api/""#, 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") .plan_type("team"), AuthCredentialsStoreMode::File, )?; Mock::given(method("GET")) .and(path("/backend-api/accounts/account-123/settings")) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with( ResponseTemplate::new(200) .set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#), ) .mount(&server) .await; let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) .await?; let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?; let apps = actual .data .iter() .find(|feature| feature.name == "apps") .expect("apps feature should be present"); let plugins = actual .data .iter() .find(|feature| feature.name == "plugins") .expect("plugins feature should be present"); assert!(!apps.enabled); assert!(!plugins.enabled); assert!(apps.default_enabled); assert!(plugins.default_enabled); Ok(())}#[tokio::test]async fn experimental_feature_list_resolves_thread_project_config() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; let workspace = TempDir::new()?; let server_uri = server.uri(); let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); std::fs::write( codex_home.path().join("config.toml"), format!( r#"model = "mock-model"approval_policy = "never"sandbox_mode = "read-only"model_provider = "mock_provider"[projects."{workspace_key}"]trust_level = "trusted"[model_providers.mock_provider]name = "Mock provider for test"base_url = "{server_uri}/v1"wire_api = "responses"request_max_retries = 0stream_max_retries = 0"# ), )?; let project_config_dir = workspace.path().join(".codex"); std::fs::create_dir_all(&project_config_dir)?; std::fs::write( project_config_dir.join("config.toml"), r#"[features]memories = true"#, )?; let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp .send_thread_start_request(ThreadStartParams { cwd: Some(workspace.path().display().to_string()), ..Default::default() }) .await?; let ThreadStartResponse { thread, .. } = read_response::<ThreadStartResponse>(&mut mcp, thread_start_id).await?; let request_id = mcp .send_experimental_feature_list_request(ExperimentalFeatureListParams { cursor: None, limit: None, thread_id: Some(thread.id), }) .await?; let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?; let memories = actual .data .iter() .find(|feature| feature.name == "memories") .expect("memories feature should be present"); assert!(memories.enabled); Ok(())}#[tokio::test]async fn experimental_feature_list_rejects_unknown_thread_id() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_experimental_feature_list_request(ExperimentalFeatureListParams { cursor: None, limit: None, thread_id: Some("00000000-0000-4000-8000-000000000001".to_string()), }) .await?; let JSONRPCError { error, .. } = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; assert_eq!(error.code, -32600); assert!( error .message .contains("thread not found: 00000000-0000-4000-8000-000000000001"), "{}", error.message ); Ok(())}#[tokio::test]async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads()-> Result<()> { let codex_home = TempDir::new()?; let project_cwd = codex_home.path().join("project"); std::fs::create_dir_all(&project_cwd)?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = set_experimental_feature_enablement( &mut mcp, BTreeMap::from([("auth_elicitation".to_string(), true)]), ) .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]), } ); for cwd in [None, Some(project_cwd.display().to_string())] { let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?; assert_eq!( config .additional .get("features") .and_then(|features| features.get("auth_elicitation")), Some(&json!(true)) ); } Ok(())}#[tokio::test]async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join("config.toml"), "[features]\nmemories = false\n", )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = set_experimental_feature_enablement( &mut mcp, BTreeMap::from([("memories".to_string(), true)]), ) .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::from([("memories".to_string(), true)]), } ); let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; assert_eq!( config .additional .get("features") .and_then(|features| features.get("memories")), Some(&json!(false)) ); Ok(())}#[tokio::test]async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; set_experimental_feature_enablement( &mut mcp, BTreeMap::from([("mentions_v2".to_string(), true)]), ) .await?; let actual = set_experimental_feature_enablement( &mut mcp, BTreeMap::from([ ("auth_elicitation".to_string(), true), ("memories".to_string(), true), ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), false), ]), ) .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::from([ ("auth_elicitation".to_string(), true), ("memories".to_string(), true), ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), false), ]), } ); let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; assert_eq!( config .additional .get("features") .and_then(|features| features.get("mentions_v2")), Some(&json!(true)) ); assert_eq!( config .additional .get("features") .and_then(|features| features.get("auth_elicitation")), Some(&json!(true)) ); assert_eq!( config .additional .get("features") .and_then(|features| features.get("memories")), Some(&json!(true)) ); assert_eq!( config .additional .get("features") .and_then(|features| features.get("remote_plugin")), Some(&json!(true)) ); assert_eq!( config .additional .get("features") .and_then(|features| features.get("tool_suggest")), Some(&json!(false)) ); Ok(())}#[tokio::test]async fn experimental_feature_enablement_set_allows_remote_control() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let remote_control_enabled = false; let enablement = BTreeMap::from([("remote_control".to_string(), remote_control_enabled)]); let actual = set_experimental_feature_enablement(&mut mcp, enablement.clone()).await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { enablement } ); Ok(())}#[tokio::test]async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; set_experimental_feature_enablement( &mut mcp, BTreeMap::from([("mentions_v2".to_string(), true)]), ) .await?; let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::new(), } ); let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; assert_eq!( config .additional .get("features") .and_then(|features| features.get("mentions_v2")), Some(&json!(true)) ); Ok(())}#[tokio::test]async fn experimental_feature_enablement_set_ignores_invalid_features() -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = set_experimental_feature_enablement( &mut mcp, BTreeMap::from([ ("apps".to_string(), false), ("auth_elicitation".to_string(), true), ("connectors".to_string(), false), ("personality".to_string(), false), ("plugins".to_string(), false), ("tool_call_mcp_elicitation".to_string(), false), ("unknown_feature".to_string(), true), ]), ) .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { enablement: BTreeMap::from([("auth_elicitation".to_string(), true)]), } ); Ok(())}async fn set_experimental_feature_enablement( mcp: &mut TestAppServer, enablement: BTreeMap<String, bool>,) -> Result<ExperimentalFeatureEnablementSetResponse> { let request_id = mcp .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { enablement, }) .await?; read_response(mcp, request_id).await}async fn read_config(mcp: &mut TestAppServer, cwd: Option<String>) -> Result<ConfigReadResponse> { let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, cwd, }) .await?; read_response(mcp, request_id).await}async fn read_response<T: DeserializeOwned>(mcp: &mut TestAppServer, request_id: i64) -> Result<T> { let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; to_response(response)}