use super::*; use crate::plugins::PluginInstallRequest; use crate::plugins::test_support::load_plugins_config; use crate::plugins::test_support::write_curated_plugin_sha; use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use crate::plugins::test_support::write_plugins_feature_config; use codex_tools::DiscoverablePluginInfo; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::tempdir; use tracing::Level; use tracing_subscriber::fmt::format::FmtSpan; use tracing_test::internal::MockWriter; #[tokio::test] async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["sample", "slack"]); write_plugins_feature_config(codex_home.path()); let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) .await .unwrap(); assert_eq!( discoverable_plugins, vec![DiscoverablePluginInfo { id: "slack@openai-curated".to_string(), name: "slack".to_string(), description: Some( "Plugin that includes skills, MCP servers, and app connectors".to_string(), ), has_skills: true, mcp_server_names: vec!["sample-docs".to_string()], app_connector_ids: vec!["connector_calendar".to_string()], }] ); } #[tokio::test] async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); write_file( &codex_home.path().join(crate::config::CONFIG_TOML_FILE), r#"[features] plugins = false "#, ); let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) .await .unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } #[tokio::test] async fn list_tool_suggest_discoverable_plugins_normalizes_description() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); write_plugins_feature_config(codex_home.path()); write_file( &curated_root.join("plugins/slack/.codex-plugin/plugin.json"), r#"{ "name": "slack", "description": " Plugin\n with extra spacing " }"#, ); let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) .await .unwrap(); assert_eq!( discoverable_plugins, vec![DiscoverablePluginInfo { id: "slack@openai-curated".to_string(), name: "slack".to_string(), description: Some("Plugin with extra spacing".to_string()), has_skills: true, mcp_server_names: vec!["sample-docs".to_string()], app_connector_ids: vec!["connector_calendar".to_string()], }] ); } #[tokio::test] async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); write_curated_plugin_sha(codex_home.path()); write_plugins_feature_config(codex_home.path()); PluginsManager::new(codex_home.path().to_path_buf()) .install_plugin(PluginInstallRequest { plugin_name: "slack".to_string(), marketplace_path: AbsolutePathBuf::try_from( curated_root.join(".agents/plugins/marketplace.json"), ) .expect("marketplace path"), }) .await .expect("plugin should install"); let refreshed_config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config) .await .unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } #[tokio::test] async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["sample"]); write_file( &codex_home.path().join(crate::config::CONFIG_TOML_FILE), r#"[features] plugins = true [tool_suggest] discoverables = [{ type = "plugin", id = "sample@openai-curated" }] "#, ); let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) .await .unwrap(); assert_eq!( discoverable_plugins, vec![DiscoverablePluginInfo { id: "sample@openai-curated".to_string(), name: "sample".to_string(), description: Some( "Plugin that includes skills, MCP servers, and app connectors".to_string(), ), has_skills: true, mcp_server_names: vec!["sample-docs".to_string()], app_connector_ids: vec!["connector_calendar".to_string()], }] ); } #[tokio::test] async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace( &curated_root, &["slack", "build-ios-apps", "life-science-research"], ); write_plugins_feature_config(codex_home.path()); let too_long_prompt = "x".repeat(129); for plugin_name in ["build-ios-apps", "life-science-research"] { write_file( &curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")), &format!( r#"{{ "name": "{plugin_name}", "description": "Plugin that includes skills, MCP servers, and app connectors", "interface": {{ "defaultPrompt": "{too_long_prompt}" }} }}"# ), ); } let config = load_plugins_config(codex_home.path()).await; let buffer: &'static std::sync::Mutex> = Box::leak(Box::new(std::sync::Mutex::new(Vec::new()))); let subscriber = tracing_subscriber::fmt() .with_level(true) .with_ansi(false) .with_max_level(Level::WARN) .with_span_events(FmtSpan::NONE) .with_writer(MockWriter::new(buffer)) .finish(); let _guard = tracing::subscriber::set_default(subscriber); let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) .await .unwrap(); assert_eq!(discoverable_plugins.len(), 1); assert_eq!(discoverable_plugins[0].id, "slack@openai-curated"); let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone()).expect("utf8 logs"); assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 2); assert_eq!( logs.matches("build-ios-apps/.codex-plugin/plugin.json") .count(), 1 ); assert_eq!( logs.matches("life-science-research/.codex-plugin/plugin.json") .count(), 1 ); }