From e3890910427940c9106ea61d75f82dffbf20c7a6 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Fri, 13 Mar 2026 23:13:51 -0700 Subject: [PATCH] make defaultPrompt an array, keep backcompat (#14649) make plugins' `defaultPrompt` an array, but keep backcompat for strings. the array is limited by app-server to 3 entries of up to 128 chars (drops extra entries, `None`s-out ones that are too long) without erroring if those invariants are violating. added tests, tested locally. --- .../codex_app_server_protocol.schemas.json | 6 +- .../codex_app_server_protocol.v2.schemas.json | 6 +- .../schema/json/v2/PluginListResponse.json | 6 +- .../schema/json/v2/PluginReadResponse.json | 6 +- .../schema/typescript/v2/PluginInterface.ts | 7 +- .../app-server-protocol/src/protocol/v2.rs | 4 +- .../app-server/tests/suite/v2/plugin_list.rs | 78 +++++- .../app-server/tests/suite/v2/plugin_read.rs | 81 ++++++- codex-rs/core/src/plugins/manifest.rs | 226 +++++++++++++++++- 9 files changed, 409 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 75054b0d6..57363c925 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9292,8 +9292,12 @@ ] }, "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, "type": [ - "string", + "array", "null" ] }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b4868f09a..29eb9cad5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6037,8 +6037,12 @@ ] }, "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, "type": [ - "string", + "array", "null" ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index cbd7e04c2..e0140a039 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -51,8 +51,12 @@ ] }, "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, "type": [ - "string", + "array", "null" ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index f14607283..9a23c145a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -125,8 +125,12 @@ ] }, "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, "type": [ - "string", + "array", "null" ] }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts index f9f016d09..cea42d29e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts @@ -3,4 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null, defaultPrompt: string | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array, }; +export type PluginInterface = { displayName: string | null, shortDescription: string | null, longDescription: string | null, developerName: string | null, category: string | null, capabilities: Array, websiteUrl: string | null, privacyPolicyUrl: string | null, termsOfServiceUrl: string | null, +/** + * Starter prompts for the plugin. Capped at 3 entries with a maximum of + * 128 characters per entry. + */ +defaultPrompt: Array | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e86900cbc..172f427f4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3355,7 +3355,9 @@ pub struct PluginInterface { pub website_url: Option, pub privacy_policy_url: Option, pub terms_of_service_url: Option, - pub default_prompt: Option, + /// Starter prompts for the plugin. Capped at 3 entries with a maximum of + /// 128 characters per entry. + pub default_prompt: Option>, pub brand_color: Option, pub composer_icon: Option, pub logo: Option, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 0a5cc8936..c0fd6e79c 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -407,7 +407,10 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res "websiteURL": "https://openai.com/", "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", - "defaultPrompt": "Starter prompt for trying a plugin", + "defaultPrompt": [ + "Starter prompt for trying a plugin", + "Find my next action" + ], "brandColor": "#3B82F6", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png", @@ -466,6 +469,13 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res interface.terms_of_service_url.as_deref(), Some("https://openai.com/policies/row-terms-of-use/") ); + assert_eq!( + interface.default_prompt, + Some(vec![ + "Starter prompt for trying a plugin".to_string(), + "Find my next action".to_string() + ]) + ); assert_eq!( interface.composer_icon, Some(AbsolutePathBuf::try_from( @@ -488,6 +498,72 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res Ok(()) } +#[tokio::test] +async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "demo-plugin", + "interface": { + "defaultPrompt": "Starter prompt for trying a plugin" + } +}"##, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + force_remote_sync: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "demo-plugin") + .expect("expected demo-plugin entry"); + assert_eq!( + plugin + .interface + .as_ref() + .and_then(|interface| interface.default_prompt.clone()), + Some(vec!["Starter prompt for trying a plugin".to_string()]) + ); + Ok(()) +} + #[tokio::test] async fn plugin_list_force_remote_sync_returns_remote_sync_error_on_fail_open() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 8e454ee7b..8917ab4e8 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -58,7 +58,10 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> "websiteURL": "https://openai.com/", "privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/", "termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/", - "defaultPrompt": "Starter prompt for trying a plugin", + "defaultPrompt": [ + "Draft the reply", + "Find my next action" + ], "brandColor": "#3B82F6", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png", @@ -162,6 +165,18 @@ enabled = true .and_then(|interface| interface.category.as_deref()), Some("Design") ); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.default_prompt.clone()), + Some(vec![ + "Draft the reply".to_string(), + "Find my next action".to_string() + ]) + ); assert_eq!(response.plugin.skills.len(), 1); assert_eq!( response.plugin.skills[0].name, @@ -183,6 +198,70 @@ enabled = true Ok(()) } +#[tokio::test] +async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "demo-plugin", + "interface": { + "defaultPrompt": "Starter prompt for trying a plugin" + } +}"##, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.default_prompt.clone()), + Some(vec!["Starter prompt for trying a plugin".to_string()]) + ); + Ok(()) +} + #[tokio::test] async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index b7325b400..b6ab34f0c 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -1,10 +1,13 @@ use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde_json::Value as JsonValue; use std::fs; use std::path::Component; use std::path::Path; pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; +const MAX_DEFAULT_PROMPT_COUNT: usize = 3; +const MAX_DEFAULT_PROMPT_LEN: usize = 128; #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] @@ -43,7 +46,7 @@ pub struct PluginManifestInterfaceSummary { pub website_url: Option, pub privacy_policy_url: Option, pub terms_of_service_url: Option, - pub default_prompt: Option, + pub default_prompt: Option>, pub brand_color: Option, pub composer_icon: Option, pub logo: Option, @@ -75,7 +78,7 @@ struct PluginManifestInterface { #[serde(alias = "termsOfServiceURL")] terms_of_service_url: Option, #[serde(default)] - default_prompt: Option, + default_prompt: Option, #[serde(default)] brand_color: Option, #[serde(default)] @@ -86,6 +89,21 @@ struct PluginManifestInterface { screenshots: Vec, } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PluginManifestDefaultPrompt { + String(String), + List(Vec), + Invalid(JsonValue), +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PluginManifestDefaultPromptEntry { + String(String), + Invalid(JsonValue), +} + pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option { let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH); if !manifest_path.is_file() { @@ -128,7 +146,7 @@ pub(crate) fn plugin_manifest_interface( website_url: interface.website_url.clone(), privacy_policy_url: interface.privacy_policy_url.clone(), terms_of_service_url: interface.terms_of_service_url.clone(), - default_prompt: interface.default_prompt.clone(), + default_prompt: resolve_default_prompts(plugin_root, interface.default_prompt.as_ref()), brand_color: interface.brand_color.clone(), composer_icon: resolve_interface_asset_path( plugin_root, @@ -190,6 +208,99 @@ fn resolve_interface_asset_path( resolve_manifest_path(plugin_root, field, path) } +fn resolve_default_prompts( + plugin_root: &Path, + value: Option<&PluginManifestDefaultPrompt>, +) -> Option> { + match value? { + PluginManifestDefaultPrompt::String(prompt) => { + resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt) + .map(|prompt| vec![prompt]) + } + PluginManifestDefaultPrompt::List(values) => { + let mut prompts = Vec::new(); + for (index, item) in values.iter().enumerate() { + if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT { + warn_invalid_default_prompt( + plugin_root, + "interface.defaultPrompt", + &format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"), + ); + break; + } + + match item { + PluginManifestDefaultPromptEntry::String(prompt) => { + let field = format!("interface.defaultPrompt[{index}]"); + if let Some(prompt) = + resolve_default_prompt_str(plugin_root, &field, prompt) + { + prompts.push(prompt); + } + } + PluginManifestDefaultPromptEntry::Invalid(value) => { + let field = format!("interface.defaultPrompt[{index}]"); + warn_invalid_default_prompt( + plugin_root, + &field, + &format!("expected a string, found {}", json_value_type(value)), + ); + } + } + } + + (!prompts.is_empty()).then_some(prompts) + } + PluginManifestDefaultPrompt::Invalid(value) => { + warn_invalid_default_prompt( + plugin_root, + "interface.defaultPrompt", + &format!( + "expected a string or array of strings, found {}", + json_value_type(value) + ), + ); + None + } + } +} + +fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option { + let prompt = prompt.split_whitespace().collect::>().join(" "); + if prompt.is_empty() { + warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty"); + return None; + } + if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN { + warn_invalid_default_prompt( + plugin_root, + field, + &format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"), + ); + return None; + } + Some(prompt) +} + +fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) { + let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH); + tracing::warn!( + path = %manifest_path.display(), + "ignoring {field}: {message}" + ); +} + +fn json_value_type(value: &JsonValue) -> &'static str { + match value { + JsonValue::Null => "null", + JsonValue::Bool(_) => "boolean", + JsonValue::Number(_) => "number", + JsonValue::String(_) => "string", + JsonValue::Array(_) => "array", + JsonValue::Object(_) => "object", + } +} + fn resolve_manifest_path( plugin_root: &Path, field: &'static str, @@ -232,3 +343,112 @@ fn resolve_manifest_path( }) .ok() } + +#[cfg(test)] +mod tests { + use super::MAX_DEFAULT_PROMPT_LEN; + use super::PluginManifest; + use super::plugin_manifest_interface; + use pretty_assertions::assert_eq; + use std::fs; + use std::path::Path; + use tempfile::tempdir; + + fn write_manifest(plugin_root: &Path, interface: &str) { + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!( + r#"{{ + "name": "demo-plugin", + "interface": {interface} +}}"# + ), + ) + .expect("write manifest"); + } + + fn load_manifest(plugin_root: &Path) -> PluginManifest { + let manifest_path = plugin_root.join(".codex-plugin/plugin.json"); + let contents = fs::read_to_string(manifest_path).expect("read manifest"); + serde_json::from_str(&contents).expect("parse manifest") + } + + #[test] + fn plugin_manifest_interface_accepts_legacy_default_prompt_string() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_manifest( + &plugin_root, + r#"{ + "displayName": "Demo Plugin", + "defaultPrompt": " Summarize my inbox " + }"#, + ); + + let manifest = load_manifest(&plugin_root); + let interface = + plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface"); + + assert_eq!( + interface.default_prompt, + Some(vec!["Summarize my inbox".to_string()]) + ); + } + + #[test] + fn plugin_manifest_interface_normalizes_default_prompt_array() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1); + write_manifest( + &plugin_root, + &format!( + r#"{{ + "displayName": "Demo Plugin", + "defaultPrompt": [ + " Summarize my inbox ", + 123, + "{too_long}", + " ", + "Draft the reply ", + "Find my next action", + "Archive old mail" + ] + }}"# + ), + ); + + let manifest = load_manifest(&plugin_root); + let interface = + plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface"); + + assert_eq!( + interface.default_prompt, + Some(vec![ + "Summarize my inbox".to_string(), + "Draft the reply".to_string(), + "Find my next action".to_string(), + ]) + ); + } + + #[test] + fn plugin_manifest_interface_ignores_invalid_default_prompt_shape() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_manifest( + &plugin_root, + r#"{ + "displayName": "Demo Plugin", + "defaultPrompt": { "text": "Summarize my inbox" } + }"#, + ); + + let manifest = load_manifest(&plugin_root); + let interface = + plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface"); + + assert_eq!(interface.default_prompt, None); + } +}