From a1a0e49ee99e6d595dde49fb4d68d433ce7b4e1e Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 13:13:53 +1000 Subject: [PATCH 01/10] created recipe dtos and acp method for managing recipes --- Cargo.lock | 3 +- crates/goose-cli/src/cli.rs | 1 + crates/goose-sdk-types/src/custom_requests.rs | 3 + .../src/custom_requests/recipe.rs | 351 +++++++++++ crates/goose-server/src/commands/agent.rs | 1 + crates/goose/Cargo.toml | 1 + crates/goose/src/acp/server.rs | 5 +- .../goose/src/acp/server/custom_dispatch.rs | 87 +++ crates/goose/src/acp/server/recipe.rs | 263 -------- .../src/acp/server/recipe/conversions.rs | 596 ++++++++++++++++++ crates/goose/src/acp/server/recipe/mod.rs | 564 +++++++++++++++++ crates/goose/src/acp/server_factory.rs | 35 +- 12 files changed, 1644 insertions(+), 266 deletions(-) create mode 100644 crates/goose-sdk-types/src/custom_requests/recipe.rs delete mode 100644 crates/goose/src/acp/server/recipe.rs create mode 100644 crates/goose/src/acp/server/recipe/conversions.rs create mode 100644 crates/goose/src/acp/server/recipe/mod.rs diff --git a/Cargo.lock b/Cargo.lock index b37d6818058a..26bb6926a92b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4837,6 +4837,7 @@ dependencies = [ "schemars 1.2.1", "serde", "serde_json", + "serde_path_to_error", "serde_urlencoded", "serde_yaml", "serial_test", @@ -4995,7 +4996,7 @@ dependencies = [ "pem", "pkcs1", "pkcs8 0.11.0", - "rand 0.8.6", + "rand 0.10.1", "regex", "reqwest 0.13.4", "rmcp", diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 38a0ec262ad8..3694127a4e20 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1358,6 +1358,7 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> config_dir: Paths::config_dir(), goose_platform: GoosePlatform::GooseCli, additional_source_roots, + scheduler: None, })); let env_secret = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV) .ok() diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index f8e511f490c8..48e1bc10b21e 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -4,6 +4,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +mod recipe; +pub use recipe::*; + /// Schema descriptor for a single custom method, produced by the /// `#[custom_methods]` macro's generated `custom_method_schemas()` function. /// diff --git a/crates/goose-sdk-types/src/custom_requests/recipe.rs b/crates/goose-sdk-types/src/custom_requests/recipe.rs new file mode 100644 index 000000000000..cf9b838ae022 --- /dev/null +++ b/crates/goose-sdk-types/src/custom_requests/recipe.rs @@ -0,0 +1,351 @@ +use std::collections::HashMap; + +use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::EmptyResponse; + +fn default_recipe_version() -> String { + "1.0.0".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeDto { + #[serde(default = "default_recipe_version")] + pub version: String, + pub title: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extensions: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activities: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sub_recipes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry: Option, +} + +impl Default for RecipeDto { + fn default() -> Self { + Self { + version: default_recipe_version(), + title: String::new(), + description: String::new(), + instructions: None, + prompt: None, + extensions: None, + settings: None, + activities: None, + author: None, + parameters: None, + response: None, + sub_recipes: None, + retry: None, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeAuthorDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contact: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeSettingsDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goose_provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goose_model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_turns: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeResponseDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub json_schema: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SubRecipeDto { + pub name: String, + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub values: Option>, + #[serde(default)] + pub sequential_when_repeated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeParameterDto { + pub key: String, + pub input_type: RecipeParameterInputTypeDto, + pub requirement: RecipeParameterRequirementDto, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option>, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RecipeParameterInputTypeDto { + #[default] + String, + Number, + Boolean, + Date, + File, + Select, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RecipeParameterRequirementDto { + #[default] + Required, + Optional, + UserPrompt, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeRetryConfigDto { + pub max_retries: u32, + #[serde(default)] + pub checks: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_failure: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_failure_timeout_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RecipeSuccessCheckDto { + Shell { command: String }, +} + +impl Default for RecipeSuccessCheckDto { + fn default() -> Self { + Self::Shell { + command: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RecipeExtensionDto { + Builtin { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + }, + Platform { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + }, + Stdio { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + cmd: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + args: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_keys: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + }, + StreamableHttp { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + uri: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_keys: Vec, + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + headers: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + socket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + bundled: Option, + }, +} + +impl Default for RecipeExtensionDto { + fn default() -> Self { + Self::Builtin { + name: String::new(), + description: None, + display_name: None, + timeout: None, + bundled: None, + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RecipeListEntryDto { + pub id: String, + pub recipe: RecipeDto, + pub file_path: String, + pub last_modified: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schedule_cron: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slash_command: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/recipes/encode", + response = EncodeRecipeResponse +)] +pub struct EncodeRecipeRequest { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct EncodeRecipeResponse { + pub deeplink: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/recipes/decode", + response = DecodeRecipeResponse +)] +pub struct DecodeRecipeRequest { + pub deeplink: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct DecodeRecipeResponse { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/scan", response = ScanRecipeResponse)] +pub struct ScanRecipeRequest { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ScanRecipeResponse { + pub has_security_warnings: bool, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/save", response = SaveRecipeResponse)] +pub struct SaveRecipeRequest { + pub recipe: RecipeDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct SaveRecipeResponse { + pub id: String, + pub file_name: String, + pub file_path: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/parse", response = ParseRecipeResponse)] +pub struct ParseRecipeRequest { + pub content: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ParseRecipeResponse { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/delete", response = EmptyResponse)] +pub struct DeleteRecipeRequest { + pub id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/list", response = ListRecipesResponse)] +pub struct ListRecipesRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct ListRecipesResponse { + pub recipes: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/unstable/recipes/schedule", response = EmptyResponse)] +pub struct ScheduleRecipeRequest { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cron_schedule: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/recipes/slash-command", + response = EmptyResponse +)] +pub struct SetRecipeSlashCommandRequest { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slash_command: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/unstable/recipes/to-yaml", + response = RecipeToYamlResponse +)] +pub struct RecipeToYamlRequest { + pub recipe: RecipeDto, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct RecipeToYamlResponse { + pub yaml: String, +} diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 5311138a85f1..0a255daced2b 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -78,6 +78,7 @@ pub async fn run() -> Result<()> { config_dir: Paths::config_dir(), goose_platform: GoosePlatform::GooseDesktop, additional_source_roots: Vec::new(), + scheduler: Some(app_state.scheduler()), })); let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()).layer( diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index ead2d6381be1..294bcaee95f8 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -90,6 +90,7 @@ reqwest = { workspace = true, features = ["json", "cookies", "gzip", "brotli", " tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_path_to_error = { version = "0.1.8", default-features = false } serde_urlencoded = { version = "0.7.1", default-features = false } jsonschema = { version = "0.30", default-features = false } uuid = { workspace = true, features = ["v7"] } diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 6fb2dad6b7eb..4b9b0c6af868 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -33,6 +33,7 @@ use crate::providers::inventory::{ ProviderInventoryEntry, ProviderInventoryService, RefreshJobPlan, RefreshPlan, RefreshSkipReason, }; +use crate::scheduler_trait::SchedulerTrait; use crate::session::{ EnabledExtensionsState, ExtensionData, ExtensionState, Session, SessionManager, }; @@ -200,6 +201,7 @@ pub struct GooseAcpAgentOptions { pub disable_session_naming: bool, pub goose_platform: GoosePlatform, pub additional_source_roots: Vec, + pub scheduler: Arc, } pub struct GooseAcpAgent { @@ -891,7 +893,7 @@ impl GooseAcpAgent { let agent_config = AgentConfig::new( Arc::clone(&session_manager), Arc::clone(&permission_manager), - None, + Some(options.scheduler), Config::global().get_goose_mode().unwrap_or_default(), options.disable_session_naming, options.goose_platform.clone(), @@ -2941,6 +2943,7 @@ pub async fn run(builtins: Vec) -> Result<()> { config_dir: Paths::config_dir(), goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + scheduler: None, }, ); let agent = server.create_agent().await?; diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index a6a747b6e1c6..1652023637ce 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -8,6 +8,13 @@ impl GooseAcpAgent { method: &str, params: serde_json::Value, ) -> Result { + if ::matches_method(method) { + let req = recipe::deserialize_save_recipe_request(params)?; + let result = self.on_save_recipe(req).await?; + return serde_json::to_value(&result) + .map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string())); + } + self.handle_custom_request(method, params).await } @@ -321,6 +328,86 @@ impl GooseAcpAgent { self.on_import_session(req).await } + #[custom_method(EncodeRecipeRequest)] + async fn dispatch_encode_recipe( + &self, + req: EncodeRecipeRequest, + ) -> Result { + self.on_encode_recipe(req).await + } + + #[custom_method(DecodeRecipeRequest)] + async fn dispatch_decode_recipe( + &self, + req: DecodeRecipeRequest, + ) -> Result { + self.on_decode_recipe(req).await + } + + #[custom_method(ScanRecipeRequest)] + async fn dispatch_scan_recipe( + &self, + req: ScanRecipeRequest, + ) -> Result { + self.on_scan_recipe(req).await + } + + #[custom_method(ListRecipesRequest)] + async fn dispatch_list_recipes( + &self, + req: ListRecipesRequest, + ) -> Result { + self.on_list_recipes(req).await + } + + #[custom_method(DeleteRecipeRequest)] + async fn dispatch_delete_recipe( + &self, + req: DeleteRecipeRequest, + ) -> Result { + self.on_delete_recipe(req).await + } + + #[custom_method(ScheduleRecipeRequest)] + async fn dispatch_schedule_recipe( + &self, + req: ScheduleRecipeRequest, + ) -> Result { + self.on_schedule_recipe(req).await + } + + #[custom_method(SetRecipeSlashCommandRequest)] + async fn dispatch_set_recipe_slash_command( + &self, + req: SetRecipeSlashCommandRequest, + ) -> Result { + self.on_set_recipe_slash_command(req).await + } + + #[custom_method(SaveRecipeRequest)] + async fn dispatch_save_recipe( + &self, + req: SaveRecipeRequest, + ) -> Result { + self.on_save_recipe(req).await + } + + #[custom_method(ParseRecipeRequest)] + async fn dispatch_parse_recipe( + &self, + req: ParseRecipeRequest, + ) -> Result { + self.on_parse_recipe(req).await + } + + #[custom_method(RecipeToYamlRequest)] + async fn dispatch_recipe_to_yaml( + &self, + req: RecipeToYamlRequest, + ) -> Result { + self.on_recipe_to_yaml(req).await + } + #[custom_method(GetSessionInfoRequest)] async fn dispatch_get_session_info( &self, diff --git a/crates/goose/src/acp/server/recipe.rs b/crates/goose/src/acp/server/recipe.rs deleted file mode 100644 index 04f6a40cf0a9..000000000000 --- a/crates/goose/src/acp/server/recipe.rs +++ /dev/null @@ -1,263 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use agent_client_protocol::schema::Meta; -use agent_client_protocol::{ - Client, ConnectionTo, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, UntypedMessage, -}; -use serde::{Deserialize, Serialize}; -use tokio::sync::oneshot; - -use super::{meta_string, GooseAcpAgent, ResultExt}; -use crate::agents::Agent; -use crate::recipe::build_recipe::{build_recipe_from_template, RecipeError}; -use crate::recipe::local_recipes::get_recipe_library_dir; -use crate::recipe::manifest::{list_recipe_file_manifests, load_recipe_from_path}; -use crate::recipe::validate_recipe::validate_recipe_template_from_content; -use crate::recipe::{Recipe, RecipeParameter}; -use crate::recipe_deeplink; - -pub(super) const RECIPE_PARAMS_METHOD: &str = "_goose/unstable/session/recipe/request-params"; - -pub(super) const RECIPE_PARAMS_CANCELLED_REASON: &str = "recipe_params_cancelled"; - -impl GooseAcpAgent { - pub(super) async fn resolve_recipe_from_meta( - &self, - meta: Option<&Meta>, - ) -> Result, agent_client_protocol::Error> { - let resolved = if let Some(deeplink) = meta_string(meta, "recipeDeeplink")? { - let recipe = recipe_deeplink::decode(&deeplink).map_err(|e| { - agent_client_protocol::Error::invalid_params().data(format!("recipeDeeplink: {e}")) - })?; - Some((recipe, get_recipe_library_dir(true))) - } else if let Some(id) = meta_string(meta, "recipeId")? { - let path = self.resolve_recipe_path_by_id(&id).await?; - let recipe = load_recipe_from_path(&path).internal_err_ctx("Failed to load recipe")?; - let recipe_dir = path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| get_recipe_library_dir(true)); - Some((recipe, recipe_dir)) - } else { - None - }; - - if let Some((ref recipe, ref recipe_dir)) = resolved { - validate_recipe(recipe, recipe_dir)?; - } - Ok(resolved) - } - - async fn resolve_recipe_path_by_id( - &self, - id: &str, - ) -> Result { - if let Some(path) = self.recipe_path_cache.lock().await.get(id).cloned() { - return Ok(path); - } - let map: HashMap = list_recipe_file_manifests() - .unwrap_or_default() - .into_iter() - .map(|manifest| (manifest.id, manifest.file_path)) - .collect(); - let resolved = map.get(id).cloned(); - *self.recipe_path_cache.lock().await = map; - resolved.ok_or_else(|| { - agent_client_protocol::Error::invalid_params().data(format!("recipe not found: {id}")) - }) - } - - fn render_recipe( - &self, - recipe: &Recipe, - recipe_dir: &Path, - values: HashMap, - ) -> Result, agent_client_protocol::Error> { - let content = recipe.to_yaml().map_err(|e| { - agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")) - })?; - let params: Vec<(String, String)> = values.into_iter().collect(); - match build_recipe_from_template( - content, - recipe_dir, - params, - None:: Result>, - ) { - Ok(rendered) => Ok(Some(rendered)), - Err(RecipeError::MissingParams { .. }) => Ok(None), - Err(e) => { - Err(agent_client_protocol::Error::internal_error().data(format!("recipe: {e}"))) - } - } - } - - pub(super) async fn apply_recipe(&self, agent: &Arc, recipe: &Recipe) { - agent - .apply_recipe_components(recipe.response.clone(), true) - .await; - if let Some(instructions) = recipe.instructions.clone() { - agent - .extend_system_prompt("recipe".to_string(), instructions) - .await; - } - } - - pub(super) async fn render_recipe_for_session( - &self, - cx: &ConnectionTo, - session_id: &str, - recipe: Option<&(Recipe, PathBuf)>, - ) -> Result<(Option, Option>), agent_client_protocol::Error> - { - let Some((recipe, recipe_dir)) = recipe else { - return Ok((None, None)); - }; - let (rendered, values) = self - .render_recipe_with_params(cx, session_id, recipe, recipe_dir) - .await?; - Ok((Some(rendered), values)) - } - - async fn render_recipe_with_params( - &self, - cx: &ConnectionTo, - session_id: &str, - recipe: &Recipe, - recipe_dir: &Path, - ) -> Result<(Recipe, Option>), agent_client_protocol::Error> { - let parameters = recipe.parameters.clone().unwrap_or_default(); - - if parameters.is_empty() || !self.supports_recipe_param_requests() { - return match self.render_recipe(recipe, recipe_dir, HashMap::new())? { - Some(rendered) => Ok((rendered, None)), - None => Err(agent_client_protocol::Error::invalid_params().data( - "recipe requires parameters but the client does not support recipeParameterRequests", - )), - }; - } - - let response = self - .request_recipe_params(cx, session_id, parameters) - .await?; - if matches!(response.action, RecipeParamsAction::Cancel) { - return Err(recipe_params_cancelled_error()); - } - let values = response.values; - match self.render_recipe(recipe, recipe_dir, values.clone())? { - Some(rendered) => Ok((rendered, Some(values))), - None => Err(agent_client_protocol::Error::invalid_params() - .data("recipe still missing required parameters")), - } - } - - async fn request_recipe_params( - &self, - cx: &ConnectionTo, - session_id: &str, - parameters: Vec, - ) -> Result { - let request = RequestRecipeParams { - session_id: session_id.to_string(), - parameters, - }; - let (tx, rx) = oneshot::channel(); - cx.send_request(RequestRecipeParamsMessage(request)) - .on_receiving_result(move |result| async move { - let _ = tx.send(result.map(|response| response.0)); - Ok(()) - })?; - match rx.await { - Ok(response) => response, - Err(_) => Err(agent_client_protocol::Error::internal_error() - .data("recipe params request was dropped")), - } - } -} - -fn recipe_params_cancelled_error() -> agent_client_protocol::Error { - agent_client_protocol::Error::invalid_params().data(serde_json::json!({ - "reason": RECIPE_PARAMS_CANCELLED_REASON, - })) -} - -fn validate_recipe(recipe: &Recipe, recipe_dir: &Path) -> Result<(), agent_client_protocol::Error> { - let yaml = recipe - .to_yaml() - .map_err(|e| agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")))?; - validate_recipe_template_from_content(&yaml, Some(recipe_dir.to_string_lossy().to_string())) - .map_err(|e| agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")))?; - Ok(()) -} - -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "camelCase")] -pub(super) struct RequestRecipeParams { - session_id: String, - parameters: Vec, -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "camelCase")] -pub(super) enum RecipeParamsAction { - #[default] - Submit, - Cancel, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "camelCase")] -pub(super) struct RecipeParamsResponse { - #[serde(default)] - action: RecipeParamsAction, - #[serde(default)] - values: HashMap, -} - -#[derive(Debug, Clone)] -struct RequestRecipeParamsMessage(RequestRecipeParams); - -impl JsonRpcMessage for RequestRecipeParamsMessage { - fn matches_method(method: &str) -> bool { - method == RECIPE_PARAMS_METHOD - } - - fn method(&self) -> &str { - RECIPE_PARAMS_METHOD - } - - fn to_untyped_message(&self) -> Result { - UntypedMessage::new(RECIPE_PARAMS_METHOD, &self.0) - } - - fn parse_message( - method: &str, - params: &impl serde::Serialize, - ) -> Result { - if !Self::matches_method(method) { - return Err(agent_client_protocol::Error::method_not_found()); - } - Ok(Self(agent_client_protocol::util::json_cast_params(params)?)) - } -} - -impl JsonRpcRequest for RequestRecipeParamsMessage { - type Response = RecipeParamsResponseMessage; -} - -#[derive(Debug, Clone)] -struct RecipeParamsResponseMessage(RecipeParamsResponse); - -impl JsonRpcResponse for RecipeParamsResponseMessage { - fn into_json(self, _method: &str) -> Result { - serde_json::to_value(self.0).map_err(agent_client_protocol::Error::into_internal_error) - } - - fn from_value( - _method: &str, - value: serde_json::Value, - ) -> Result { - Ok(Self(agent_client_protocol::util::json_cast(&value)?)) - } -} diff --git a/crates/goose/src/acp/server/recipe/conversions.rs b/crates/goose/src/acp/server/recipe/conversions.rs new file mode 100644 index 000000000000..1e613a95838a --- /dev/null +++ b/crates/goose/src/acp/server/recipe/conversions.rs @@ -0,0 +1,596 @@ +use anyhow::{bail, Result}; +use goose_sdk_types::custom_requests::{ + RecipeAuthorDto, RecipeDto, RecipeExtensionDto, RecipeParameterDto, + RecipeParameterInputTypeDto, RecipeParameterRequirementDto, RecipeResponseDto, + RecipeRetryConfigDto, RecipeSettingsDto, RecipeSuccessCheckDto, SubRecipeDto, +}; + +use crate::agents::extension::{Envs, ExtensionConfig}; +use crate::agents::types::{RetryConfig, SuccessCheck}; +use crate::recipe::{ + Author, Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, + Response, Settings, SubRecipe, +}; + +impl TryFrom for Recipe { + type Error = anyhow::Error; + + fn try_from(dto: RecipeDto) -> Result { + Ok(Self { + version: dto.version, + title: dto.title, + description: dto.description, + instructions: dto.instructions, + prompt: dto.prompt, + extensions: dto + .extensions + .map(|extensions| { + extensions + .into_iter() + .map(ExtensionConfig::try_from) + .collect::>>() + }) + .transpose()?, + settings: dto.settings.map(Settings::from), + activities: dto.activities, + author: dto.author.map(Author::from), + parameters: dto + .parameters + .map(|parameters| parameters.into_iter().map(RecipeParameter::from).collect()), + response: dto.response.map(Response::from), + sub_recipes: dto + .sub_recipes + .map(|sub_recipes| sub_recipes.into_iter().map(SubRecipe::from).collect()), + retry: dto.retry.map(RetryConfig::from), + }) + } +} + +impl TryFrom for RecipeDto { + type Error = anyhow::Error; + + fn try_from(recipe: Recipe) -> Result { + Ok(Self { + version: recipe.version, + title: recipe.title, + description: recipe.description, + instructions: recipe.instructions, + prompt: recipe.prompt, + extensions: recipe + .extensions + .map(|extensions| { + extensions + .into_iter() + .map(RecipeExtensionDto::try_from) + .collect::>>() + }) + .transpose()?, + settings: recipe.settings.map(RecipeSettingsDto::from), + activities: recipe.activities, + author: recipe.author.map(RecipeAuthorDto::from), + parameters: recipe.parameters.map(|parameters| { + parameters + .into_iter() + .map(RecipeParameterDto::from) + .collect() + }), + response: recipe.response.map(RecipeResponseDto::from), + sub_recipes: recipe + .sub_recipes + .map(|sub_recipes| sub_recipes.into_iter().map(SubRecipeDto::from).collect()), + retry: recipe.retry.map(RecipeRetryConfigDto::from), + }) + } +} + +impl From for Author { + fn from(dto: RecipeAuthorDto) -> Self { + Self { + contact: dto.contact, + metadata: dto.metadata, + } + } +} + +impl From for RecipeAuthorDto { + fn from(author: Author) -> Self { + Self { + contact: author.contact, + metadata: author.metadata, + } + } +} + +impl From for Settings { + fn from(dto: RecipeSettingsDto) -> Self { + Self { + goose_provider: dto.goose_provider, + goose_model: dto.goose_model, + temperature: dto.temperature, + max_turns: dto.max_turns, + } + } +} + +impl From for RecipeSettingsDto { + fn from(settings: Settings) -> Self { + Self { + goose_provider: settings.goose_provider, + goose_model: settings.goose_model, + temperature: settings.temperature, + max_turns: settings.max_turns, + } + } +} + +impl From for Response { + fn from(dto: RecipeResponseDto) -> Self { + Self { + json_schema: dto.json_schema, + } + } +} + +impl From for RecipeResponseDto { + fn from(response: Response) -> Self { + Self { + json_schema: response.json_schema, + } + } +} + +impl From for SubRecipe { + fn from(dto: SubRecipeDto) -> Self { + Self { + name: dto.name, + path: dto.path, + values: dto.values, + sequential_when_repeated: dto.sequential_when_repeated, + description: dto.description, + } + } +} + +impl From for SubRecipeDto { + fn from(sub_recipe: SubRecipe) -> Self { + Self { + name: sub_recipe.name, + path: sub_recipe.path, + values: sub_recipe.values, + sequential_when_repeated: sub_recipe.sequential_when_repeated, + description: sub_recipe.description, + } + } +} + +impl From for RecipeParameter { + fn from(dto: RecipeParameterDto) -> Self { + Self { + key: dto.key, + input_type: RecipeParameterInputType::from(dto.input_type), + requirement: RecipeParameterRequirement::from(dto.requirement), + description: dto.description, + default: dto.default, + options: dto.options, + } + } +} + +impl From for RecipeParameterDto { + fn from(parameter: RecipeParameter) -> Self { + Self { + key: parameter.key, + input_type: RecipeParameterInputTypeDto::from(parameter.input_type), + requirement: RecipeParameterRequirementDto::from(parameter.requirement), + description: parameter.description, + default: parameter.default, + options: parameter.options, + } + } +} + +impl From for RecipeParameterInputType { + fn from(dto: RecipeParameterInputTypeDto) -> Self { + match dto { + RecipeParameterInputTypeDto::String => Self::String, + RecipeParameterInputTypeDto::Number => Self::Number, + RecipeParameterInputTypeDto::Boolean => Self::Boolean, + RecipeParameterInputTypeDto::Date => Self::Date, + RecipeParameterInputTypeDto::File => Self::File, + RecipeParameterInputTypeDto::Select => Self::Select, + } + } +} + +impl From for RecipeParameterInputTypeDto { + fn from(input_type: RecipeParameterInputType) -> Self { + match input_type { + RecipeParameterInputType::String => Self::String, + RecipeParameterInputType::Number => Self::Number, + RecipeParameterInputType::Boolean => Self::Boolean, + RecipeParameterInputType::Date => Self::Date, + RecipeParameterInputType::File => Self::File, + RecipeParameterInputType::Select => Self::Select, + } + } +} + +impl From for RecipeParameterRequirement { + fn from(dto: RecipeParameterRequirementDto) -> Self { + match dto { + RecipeParameterRequirementDto::Required => Self::Required, + RecipeParameterRequirementDto::Optional => Self::Optional, + RecipeParameterRequirementDto::UserPrompt => Self::UserPrompt, + } + } +} + +impl From for RecipeParameterRequirementDto { + fn from(requirement: RecipeParameterRequirement) -> Self { + match requirement { + RecipeParameterRequirement::Required => Self::Required, + RecipeParameterRequirement::Optional => Self::Optional, + RecipeParameterRequirement::UserPrompt => Self::UserPrompt, + } + } +} + +impl From for RetryConfig { + fn from(dto: RecipeRetryConfigDto) -> Self { + Self { + max_retries: dto.max_retries, + checks: dto.checks.into_iter().map(SuccessCheck::from).collect(), + on_failure: dto.on_failure, + timeout_seconds: dto.timeout_seconds, + on_failure_timeout_seconds: dto.on_failure_timeout_seconds, + } + } +} + +impl From for RecipeRetryConfigDto { + fn from(retry: RetryConfig) -> Self { + Self { + max_retries: retry.max_retries, + checks: retry + .checks + .into_iter() + .map(RecipeSuccessCheckDto::from) + .collect(), + on_failure: retry.on_failure, + timeout_seconds: retry.timeout_seconds, + on_failure_timeout_seconds: retry.on_failure_timeout_seconds, + } + } +} + +impl From for SuccessCheck { + fn from(dto: RecipeSuccessCheckDto) -> Self { + match dto { + RecipeSuccessCheckDto::Shell { command } => Self::Shell { command }, + } + } +} + +impl From for RecipeSuccessCheckDto { + fn from(check: SuccessCheck) -> Self { + match check { + SuccessCheck::Shell { command } => Self::Shell { command }, + } + } +} + +impl TryFrom for ExtensionConfig { + type Error = anyhow::Error; + + fn try_from(dto: RecipeExtensionDto) -> Result { + Ok(match dto { + RecipeExtensionDto::Builtin { + name, + description, + display_name, + timeout, + bundled, + } => Self::Builtin { + name, + description: description.unwrap_or_default(), + display_name, + timeout, + bundled, + available_tools: Vec::new(), + }, + RecipeExtensionDto::Platform { + name, + description, + display_name, + bundled, + } => Self::Platform { + name, + description: description.unwrap_or_default(), + display_name, + bundled, + available_tools: Vec::new(), + }, + RecipeExtensionDto::Stdio { + name, + description, + cmd, + args, + env_keys, + timeout, + cwd, + bundled, + } => Self::Stdio { + name, + description: description.unwrap_or_default(), + cmd, + args, + envs: Envs::default(), + env_keys, + timeout, + cwd, + bundled, + available_tools: Vec::new(), + }, + RecipeExtensionDto::StreamableHttp { + name, + description, + uri, + env_keys, + headers, + timeout, + socket, + bundled, + } => Self::StreamableHttp { + name, + description: description.unwrap_or_default(), + uri, + envs: Envs::default(), + env_keys, + headers, + timeout, + socket, + bundled, + available_tools: Vec::new(), + }, + }) + } +} + +impl TryFrom for RecipeExtensionDto { + type Error = anyhow::Error; + + fn try_from(extension: ExtensionConfig) -> Result { + Ok(match extension { + ExtensionConfig::Builtin { + name, + description, + display_name, + timeout, + bundled, + .. + } => Self::Builtin { + name, + description: Some(description), + display_name, + timeout, + bundled, + }, + ExtensionConfig::Platform { + name, + description, + display_name, + bundled, + .. + } => Self::Platform { + name, + description: Some(description), + display_name, + bundled, + }, + ExtensionConfig::Stdio { + name, + description, + cmd, + args, + env_keys, + timeout, + cwd, + bundled, + .. + } => Self::Stdio { + name, + description: Some(description), + cmd, + args, + env_keys, + timeout, + cwd, + bundled, + }, + ExtensionConfig::StreamableHttp { + name, + description, + uri, + env_keys, + headers, + timeout, + socket, + bundled, + .. + } => Self::StreamableHttp { + name, + description: Some(description), + uri, + env_keys, + headers, + timeout, + socket, + bundled, + }, + ExtensionConfig::Sse { .. } => bail_unsupported_extension("sse")?, + ExtensionConfig::Frontend { .. } => bail_unsupported_extension("frontend")?, + ExtensionConfig::InlinePython { .. } => bail_unsupported_extension("inline_python")?, + }) + } +} + +fn bail_unsupported_extension(extension_type: &str) -> Result { + bail!("recipe extension type `{extension_type}` is not supported by RecipeDto") +} + +pub fn recipe_manifest_to_list_entry_dto( + id: String, + recipe: Recipe, + file_path: impl ToString, + last_modified: String, + schedule_cron: Option, + slash_command: Option, +) -> Result { + Ok(goose_sdk_types::custom_requests::RecipeListEntryDto { + id, + recipe: RecipeDto::try_from(recipe)?, + file_path: file_path.to_string(), + last_modified, + schedule_cron, + slash_command, + }) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use serde_json::json; + + use super::*; + + #[test] + fn converts_recipe_dto_to_recipe_and_back() { + let dto = RecipeDto { + title: "Test Recipe".to_string(), + description: "A recipe used by conversion tests".to_string(), + instructions: Some("Follow the instructions".to_string()), + prompt: Some("Start here".to_string()), + extensions: Some(vec![ + RecipeExtensionDto::Builtin { + name: "developer".to_string(), + description: Some("Developer tools".to_string()), + display_name: Some("Developer".to_string()), + timeout: Some(300), + bundled: Some(true), + }, + RecipeExtensionDto::Stdio { + name: "local".to_string(), + description: Some("Local tool".to_string()), + cmd: "goose-mcp".to_string(), + args: vec!["run".to_string()], + env_keys: vec!["API_KEY".to_string()], + timeout: Some(60), + cwd: Some("/tmp".to_string()), + bundled: None, + }, + RecipeExtensionDto::StreamableHttp { + name: "remote".to_string(), + description: Some("Remote tool".to_string()), + uri: "http://localhost:3000/mcp".to_string(), + env_keys: vec!["TOKEN".to_string()], + headers: HashMap::from([("X-Test".to_string(), "true".to_string())]), + timeout: Some(30), + socket: None, + bundled: Some(false), + }, + ]), + settings: Some(RecipeSettingsDto { + goose_provider: Some("openai".to_string()), + goose_model: Some("gpt-5".to_string()), + temperature: Some(0.2), + max_turns: Some(4), + }), + activities: Some(vec!["plan".to_string(), "build".to_string()]), + author: Some(RecipeAuthorDto { + contact: Some("test@example.com".to_string()), + metadata: Some("metadata".to_string()), + }), + parameters: Some(vec![RecipeParameterDto { + key: "environment".to_string(), + input_type: RecipeParameterInputTypeDto::Select, + requirement: RecipeParameterRequirementDto::Required, + description: "Target environment".to_string(), + default: Some("dev".to_string()), + options: Some(vec!["dev".to_string(), "prod".to_string()]), + }]), + response: Some(RecipeResponseDto { + json_schema: Some(json!({ + "type": "object", + "properties": { + "ok": { "type": "boolean" } + } + })), + }), + sub_recipes: Some(vec![SubRecipeDto { + name: "child".to_string(), + path: "child.yaml".to_string(), + values: Some(HashMap::from([("target".to_string(), "dev".to_string())])), + sequential_when_repeated: true, + description: Some("Child recipe".to_string()), + }]), + retry: Some(RecipeRetryConfigDto { + max_retries: 2, + checks: vec![RecipeSuccessCheckDto::Shell { + command: "test -f output.json".to_string(), + }], + on_failure: Some("rm -f output.json".to_string()), + timeout_seconds: Some(10), + on_failure_timeout_seconds: Some(20), + }), + ..RecipeDto::default() + }; + + let recipe = Recipe::try_from(dto).unwrap(); + assert_eq!(recipe.version, "1.0.0"); + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.extensions.as_ref().unwrap().len(), 3); + assert_eq!( + recipe.sub_recipes.as_ref().unwrap()[0].values, + Some(HashMap::from([("target".to_string(), "dev".to_string())])) + ); + assert_eq!(recipe.retry.as_ref().unwrap().max_retries, 2); + + let round_tripped = RecipeDto::try_from(recipe).unwrap(); + let serialized = serde_json::to_value(round_tripped).unwrap(); + assert!(serialized.get("sub_recipes").is_some()); + assert!(serialized.get("subRecipes").is_none()); + assert_eq!(serialized["parameters"][0]["input_type"], json!("select")); + assert_eq!(serialized["retry"]["checks"][0]["type"], json!("shell")); + } + + #[test] + fn recipe_dto_rejects_unsupported_internal_extension_variants() { + let recipe = Recipe { + version: "1.0.0".to_string(), + title: "Unsupported Extension".to_string(), + description: "Uses an unsupported recipe extension".to_string(), + instructions: Some("Run".to_string()), + prompt: None, + extensions: Some(vec![ExtensionConfig::InlinePython { + name: "inline".to_string(), + description: "Inline Python".to_string(), + code: "print('hello')".to_string(), + timeout: Some(30), + dependencies: None, + available_tools: Vec::new(), + }]), + settings: None, + activities: None, + author: None, + parameters: None, + response: None, + sub_recipes: None, + retry: None, + }; + + let err = RecipeDto::try_from(recipe).unwrap_err().to_string(); + assert!(err.contains("inline_python")); + assert!(err.contains("not supported")); + } +} diff --git a/crates/goose/src/acp/server/recipe/mod.rs b/crates/goose/src/acp/server/recipe/mod.rs new file mode 100644 index 000000000000..53f7958f70db --- /dev/null +++ b/crates/goose/src/acp/server/recipe/mod.rs @@ -0,0 +1,564 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use agent_client_protocol::schema::Meta; +use agent_client_protocol::{ + Client, ConnectionTo, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, UntypedMessage, +}; +use fs_err as fs; +use goose_sdk_types::custom_requests::{ + DecodeRecipeRequest, DecodeRecipeResponse, DeleteRecipeRequest, EmptyResponse, + EncodeRecipeRequest, EncodeRecipeResponse, ListRecipesRequest, ListRecipesResponse, + ParseRecipeRequest, ParseRecipeResponse, RecipeDto, RecipeToYamlRequest, RecipeToYamlResponse, + SaveRecipeRequest, SaveRecipeResponse, ScanRecipeRequest, ScanRecipeResponse, + ScheduleRecipeRequest, SetRecipeSlashCommandRequest, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::oneshot; + +mod conversions; + +use super::{meta_string, GooseAcpAgent, ResultExt}; +use crate::agents::Agent; +use crate::recipe::build_recipe::{build_recipe_from_template, RecipeError}; +use crate::recipe::local_recipes::{self, get_recipe_library_dir}; +use crate::recipe::manifest::{ + list_recipe_file_manifests, load_recipe_from_path, short_id_from_path, +}; +use crate::recipe::validate_recipe::validate_recipe_template_from_content; +use crate::recipe::{strip_error_location, Recipe, RecipeParameter}; +use crate::recipe_deeplink; +use crate::slash_commands::recipe_slash_command; + +use self::conversions::recipe_manifest_to_list_entry_dto; + +pub(super) const RECIPE_PARAMS_METHOD: &str = "_goose/unstable/session/recipe/request-params"; + +pub(super) const RECIPE_PARAMS_CANCELLED_REASON: &str = "recipe_params_cancelled"; + +pub(super) fn deserialize_save_recipe_request( + params: serde_json::Value, +) -> Result { + let result: Result = serde_path_to_error::deserialize(params); + result.map_err(save_recipe_validation_error) +} + +impl GooseAcpAgent { + pub(super) async fn resolve_recipe_from_meta( + &self, + meta: Option<&Meta>, + ) -> Result, agent_client_protocol::Error> { + let resolved = if let Some(deeplink) = meta_string(meta, "recipeDeeplink")? { + let recipe = recipe_deeplink::decode(&deeplink).map_err(|e| { + agent_client_protocol::Error::invalid_params().data(format!("recipeDeeplink: {e}")) + })?; + Some((recipe, get_recipe_library_dir(true))) + } else if let Some(id) = meta_string(meta, "recipeId")? { + let path = self.resolve_recipe_path_by_id(&id).await?; + let recipe = load_recipe_from_path(&path).internal_err_ctx("Failed to load recipe")?; + let recipe_dir = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| get_recipe_library_dir(true)); + Some((recipe, recipe_dir)) + } else { + None + }; + + if let Some((ref recipe, ref recipe_dir)) = resolved { + validate_recipe(recipe, recipe_dir)?; + } + Ok(resolved) + } + + async fn resolve_recipe_path_by_id( + &self, + id: &str, + ) -> Result { + if let Some(path) = self.recipe_path_cache.lock().await.get(id).cloned() { + return Ok(path); + } + let map: HashMap = list_recipe_file_manifests() + .unwrap_or_default() + .into_iter() + .map(|manifest| (manifest.id, manifest.file_path)) + .collect(); + let resolved = map.get(id).cloned(); + *self.recipe_path_cache.lock().await = map; + resolved.ok_or_else(|| { + agent_client_protocol::Error::invalid_params().data(format!("recipe not found: {id}")) + }) + } + + pub(super) async fn on_encode_recipe( + &self, + req: EncodeRecipeRequest, + ) -> Result { + let recipe = recipe_from_dto(req.recipe)?; + let deeplink = match recipe_deeplink::encode(&recipe) { + Ok(deeplink) => deeplink, + Err(err) => { + tracing::error!("Failed to encode recipe: {}", err); + #[cfg(feature = "telemetry")] + crate::posthog::emit_error("recipe_encode_failed", &err.to_string()); + return Err( + agent_client_protocol::Error::invalid_params().data(format!("recipe: {err}")) + ); + } + }; + Ok(EncodeRecipeResponse { deeplink }) + } + + pub(super) async fn on_decode_recipe( + &self, + req: DecodeRecipeRequest, + ) -> Result { + let recipe = match recipe_deeplink::decode(&req.deeplink) { + Ok(recipe) => recipe, + Err(err) => { + tracing::error!("Failed to decode deeplink: {}", err); + #[cfg(feature = "telemetry")] + crate::posthog::emit_error("recipe_decode_failed", &err.to_string()); + return Err( + agent_client_protocol::Error::invalid_params().data(format!("deeplink: {err}")) + ); + } + }; + validate_recipe_without_dir(&recipe)?; + Ok(DecodeRecipeResponse { + recipe: recipe_to_dto(recipe)?, + }) + } + + pub(super) async fn on_scan_recipe( + &self, + req: ScanRecipeRequest, + ) -> Result { + let recipe = recipe_from_dto(req.recipe)?; + Ok(ScanRecipeResponse { + has_security_warnings: recipe.check_for_security_warnings(), + }) + } + + pub(super) async fn on_list_recipes( + &self, + _req: ListRecipesRequest, + ) -> Result { + let manifests = list_recipe_file_manifests().internal_err_ctx("Failed to list recipes")?; + let recipe_file_hash_map: HashMap<_, _> = manifests + .iter() + .map(|manifest| (manifest.id.clone(), manifest.file_path.clone())) + .collect(); + *self.recipe_path_cache.lock().await = recipe_file_hash_map; + + let scheduled_jobs = self.agent_manager.scheduler().list_scheduled_jobs().await; + let schedule_map: HashMap<_, _> = scheduled_jobs + .into_iter() + .map(|job| (PathBuf::from(job.source), job.cron)) + .collect(); + + let slash_map: HashMap<_, _> = recipe_slash_command::list_commands() + .into_iter() + .map(|command| (PathBuf::from(command.recipe_path), command.command)) + .collect(); + + let recipes = manifests + .into_iter() + .map(|manifest| { + let schedule_cron = schedule_map.get(&manifest.file_path).cloned(); + let slash_command = slash_map.get(&manifest.file_path).cloned(); + recipe_manifest_to_list_entry_dto( + manifest.id, + manifest.recipe, + manifest.file_path.display(), + manifest.last_modified, + schedule_cron, + slash_command, + ) + }) + .collect::, _>>() + .map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?; + + Ok(ListRecipesResponse { recipes }) + } + + pub(super) async fn on_delete_recipe( + &self, + req: DeleteRecipeRequest, + ) -> Result { + let file_path = self.resolve_recipe_path_by_id(&req.id).await?; + fs::remove_file(&file_path).internal_err_ctx("Failed to delete recipe")?; + self.recipe_path_cache.lock().await.remove(&req.id); + Ok(EmptyResponse {}) + } + + pub(super) async fn on_schedule_recipe( + &self, + req: ScheduleRecipeRequest, + ) -> Result { + let file_path = self.resolve_recipe_path_by_id(&req.id).await?; + if let Err(err) = self + .agent_manager + .scheduler() + .schedule_recipe(file_path, req.cron_schedule) + .await + { + tracing::error!("Failed to schedule recipe: {}", err); + #[cfg(feature = "telemetry")] + crate::posthog::emit_error("recipe_schedule_failed", &err.to_string()); + return Err(agent_client_protocol::Error::internal_error() + .data(format!("Failed to schedule recipe: {err}"))); + } + Ok(EmptyResponse {}) + } + + pub(super) async fn on_set_recipe_slash_command( + &self, + req: SetRecipeSlashCommandRequest, + ) -> Result { + let file_path = self.resolve_recipe_path_by_id(&req.id).await?; + if let Err(err) = + recipe_slash_command::set_recipe_slash_command(file_path, req.slash_command) + { + tracing::error!("Failed to set slash command: {}", err); + return Err(agent_client_protocol::Error::internal_error() + .data(format!("Failed to set recipe slash command: {err}"))); + } + Ok(EmptyResponse {}) + } + + pub(super) async fn on_save_recipe( + &self, + req: SaveRecipeRequest, + ) -> Result { + let recipe = recipe_from_dto(req.recipe)?; + if recipe.check_for_security_warnings() { + return Err(agent_client_protocol::Error::invalid_params().data( + "This recipe contains hidden characters that could be malicious. Please remove them before trying to save.", + )); + } + validate_recipe_without_dir(&recipe)?; + + let file_path = match req.id.as_ref() { + Some(id) => Some(self.resolve_recipe_path_by_id(id).await?), + None => None, + }; + + let save_file_path = local_recipes::save_recipe_to_file(recipe, file_path) + .internal_err_ctx("Failed to save recipe")?; + let file_name = save_file_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + let file_path = save_file_path.display().to_string(); + let id = short_id_from_path(&file_path); + self.recipe_path_cache + .lock() + .await + .insert(id.clone(), save_file_path); + + Ok(SaveRecipeResponse { + id, + file_name, + file_path, + }) + } + + pub(super) async fn on_parse_recipe( + &self, + req: ParseRecipeRequest, + ) -> Result { + let recipe = validate_recipe_template_from_content(&req.content, None).map_err(|e| { + agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")) + })?; + Ok(ParseRecipeResponse { + recipe: recipe_to_dto(recipe)?, + }) + } + + pub(super) async fn on_recipe_to_yaml( + &self, + req: RecipeToYamlRequest, + ) -> Result { + let recipe = recipe_from_dto(req.recipe)?; + let yaml = recipe.to_yaml().invalid_params_err_ctx("recipe")?; + Ok(RecipeToYamlResponse { yaml }) + } + + fn render_recipe( + &self, + recipe: &Recipe, + recipe_dir: &Path, + values: HashMap, + ) -> Result, agent_client_protocol::Error> { + let content = recipe.to_yaml().map_err(|e| { + agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")) + })?; + let params: Vec<(String, String)> = values.into_iter().collect(); + match build_recipe_from_template( + content, + recipe_dir, + params, + None:: Result>, + ) { + Ok(rendered) => Ok(Some(rendered)), + Err(RecipeError::MissingParams { .. }) => Ok(None), + Err(e) => { + Err(agent_client_protocol::Error::internal_error().data(format!("recipe: {e}"))) + } + } + } + + pub(super) async fn apply_recipe(&self, agent: &Arc, recipe: &Recipe) { + agent + .apply_recipe_components(recipe.response.clone(), true) + .await; + if let Some(instructions) = recipe.instructions.clone() { + agent + .extend_system_prompt("recipe".to_string(), instructions) + .await; + } + } + + pub(super) async fn render_recipe_for_session( + &self, + cx: &ConnectionTo, + session_id: &str, + recipe: Option<&(Recipe, PathBuf)>, + ) -> Result<(Option, Option>), agent_client_protocol::Error> + { + let Some((recipe, recipe_dir)) = recipe else { + return Ok((None, None)); + }; + let (rendered, values) = self + .render_recipe_with_params(cx, session_id, recipe, recipe_dir) + .await?; + Ok((Some(rendered), values)) + } + + async fn render_recipe_with_params( + &self, + cx: &ConnectionTo, + session_id: &str, + recipe: &Recipe, + recipe_dir: &Path, + ) -> Result<(Recipe, Option>), agent_client_protocol::Error> { + let parameters = recipe.parameters.clone().unwrap_or_default(); + + if parameters.is_empty() || !self.supports_recipe_param_requests() { + return match self.render_recipe(recipe, recipe_dir, HashMap::new())? { + Some(rendered) => Ok((rendered, None)), + None => Err(agent_client_protocol::Error::invalid_params().data( + "recipe requires parameters but the client does not support recipeParameterRequests", + )), + }; + } + + let response = self + .request_recipe_params(cx, session_id, parameters) + .await?; + if matches!(response.action, RecipeParamsAction::Cancel) { + return Err(recipe_params_cancelled_error()); + } + let values = response.values; + match self.render_recipe(recipe, recipe_dir, values.clone())? { + Some(rendered) => Ok((rendered, Some(values))), + None => Err(agent_client_protocol::Error::invalid_params() + .data("recipe still missing required parameters")), + } + } + + async fn request_recipe_params( + &self, + cx: &ConnectionTo, + session_id: &str, + parameters: Vec, + ) -> Result { + let request = RequestRecipeParams { + session_id: session_id.to_string(), + parameters, + }; + let (tx, rx) = oneshot::channel(); + cx.send_request(RequestRecipeParamsMessage(request)) + .on_receiving_result(move |result| async move { + let _ = tx.send(result.map(|response| response.0)); + Ok(()) + })?; + match rx.await { + Ok(response) => response, + Err(_) => Err(agent_client_protocol::Error::internal_error() + .data("recipe params request was dropped")), + } + } +} + +fn recipe_params_cancelled_error() -> agent_client_protocol::Error { + agent_client_protocol::Error::invalid_params().data(serde_json::json!({ + "reason": RECIPE_PARAMS_CANCELLED_REASON, + })) +} + +fn save_recipe_validation_error( + error: serde_path_to_error::Error, +) -> agent_client_protocol::Error { + let path = error.path().to_string(); + let inner = strip_error_location(&error.into_inner().to_string()); + let message = if path == "." { + format!("Save recipe validation failed: {inner}") + } else { + format!( + "save recipe validation failed at {}: {inner}", + path.trim_start_matches('.') + ) + }; + agent_client_protocol::Error::invalid_params().data(message) +} + +fn validate_recipe(recipe: &Recipe, recipe_dir: &Path) -> Result<(), agent_client_protocol::Error> { + let yaml = recipe + .to_yaml() + .map_err(|e| agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")))?; + validate_recipe_template_from_content(&yaml, Some(recipe_dir.to_string_lossy().to_string())) + .map_err(|e| agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")))?; + Ok(()) +} + +fn validate_recipe_without_dir(recipe: &Recipe) -> Result<(), agent_client_protocol::Error> { + let yaml = recipe + .to_yaml() + .map_err(|e| agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")))?; + validate_recipe_template_from_content(&yaml, None) + .map_err(|e| agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}")))?; + Ok(()) +} + +fn recipe_from_dto(dto: RecipeDto) -> Result { + Recipe::try_from(dto) + .map_err(|e| agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}"))) +} + +fn recipe_to_dto(recipe: Recipe) -> Result { + RecipeDto::try_from(recipe) + .map_err(|e| agent_client_protocol::Error::invalid_params().data(format!("recipe: {e}"))) +} + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(super) struct RequestRecipeParams { + session_id: String, + parameters: Vec, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(super) enum RecipeParamsAction { + #[default] + Submit, + Cancel, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(super) struct RecipeParamsResponse { + #[serde(default)] + action: RecipeParamsAction, + #[serde(default)] + values: HashMap, +} + +#[derive(Debug, Clone)] +struct RequestRecipeParamsMessage(RequestRecipeParams); + +impl JsonRpcMessage for RequestRecipeParamsMessage { + fn matches_method(method: &str) -> bool { + method == RECIPE_PARAMS_METHOD + } + + fn method(&self) -> &str { + RECIPE_PARAMS_METHOD + } + + fn to_untyped_message(&self) -> Result { + UntypedMessage::new(RECIPE_PARAMS_METHOD, &self.0) + } + + fn parse_message( + method: &str, + params: &impl serde::Serialize, + ) -> Result { + if !Self::matches_method(method) { + return Err(agent_client_protocol::Error::method_not_found()); + } + Ok(Self(agent_client_protocol::util::json_cast_params(params)?)) + } +} + +impl JsonRpcRequest for RequestRecipeParamsMessage { + type Response = RecipeParamsResponseMessage; +} + +#[derive(Debug, Clone)] +struct RecipeParamsResponseMessage(RecipeParamsResponse); + +impl JsonRpcResponse for RecipeParamsResponseMessage { + fn into_json(self, _method: &str) -> Result { + serde_json::to_value(self.0).map_err(agent_client_protocol::Error::into_internal_error) + } + + fn from_value( + _method: &str, + value: serde_json::Value, + ) -> Result { + Ok(Self(agent_client_protocol::util::json_cast(&value)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn error_data(error: agent_client_protocol::Error) -> String { + error.data.unwrap().as_str().unwrap().to_string() + } + + #[test] + fn deserialize_save_recipe_request_reports_nested_path() { + let error = deserialize_save_recipe_request(json!({ + "recipe": { + "title": "Test", + "description": "Test recipe", + "prompt": "Run the test", + "parameters": [ + { + "key": "name", + "input_type": "bogus", + "requirement": "required", + "description": "Name" + } + ] + } + })) + .unwrap_err(); + + let message = error_data(error); + assert!( + message.starts_with( + "save recipe validation failed at recipe.parameters[0].input_type: unknown variant `bogus`" + ), + "{message}" + ); + } + + #[test] + fn deserialize_save_recipe_request_omits_root_path() { + let error = deserialize_save_recipe_request(json!("not an object")).unwrap_err(); + + let message = error_data(error); + assert!( + message.starts_with("Save recipe validation failed: invalid type: string"), + "{message}" + ); + } +} diff --git a/crates/goose/src/acp/server_factory.rs b/crates/goose/src/acp/server_factory.rs index 8cd971a6bca1..7b4149baa2fe 100644 --- a/crates/goose/src/acp/server_factory.rs +++ b/crates/goose/src/acp/server_factory.rs @@ -1,8 +1,11 @@ use crate::acp::server::{AcpProviderFactory, GooseAcpAgent, GooseAcpAgentOptions}; use crate::agents::GoosePlatform; +use crate::scheduler_trait::SchedulerTrait; +use crate::session::SessionManager; use crate::source_roots::SourceRoot; use anyhow::Result; use std::sync::Arc; +use tokio::sync::OnceCell; use tracing::info; pub struct AcpServerFactoryConfig { @@ -11,20 +14,49 @@ pub struct AcpServerFactoryConfig { pub config_dir: std::path::PathBuf, pub goose_platform: GoosePlatform, pub additional_source_roots: Vec, + // TODO(acp-migration): Temporary bridge for goosed, which still creates the REST AppState scheduler. + // When the REST/goose-server path is removed, make AcpServer own the scheduler + // directly and remove this optional injection. + pub scheduler: Option>, } pub struct AcpServer { config: AcpServerFactoryConfig, + scheduler: OnceCell>, } impl AcpServer { pub fn new(config: AcpServerFactoryConfig) -> Self { - Self { config } + Self { + config, + scheduler: OnceCell::new(), + } + } + + async fn scheduler(&self) -> Result> { + if let Some(scheduler) = &self.config.scheduler { + return Ok(Arc::clone(scheduler)); + } + + let data_dir = self.config.data_dir.clone(); + self.scheduler + .get_or_try_init(|| async move { + let session_manager = Arc::new(SessionManager::new(data_dir.clone())); + let schedule_file_path = data_dir.join("schedule.json"); + let scheduler = + crate::scheduler::Scheduler::new(schedule_file_path, session_manager) + .await + .map(|scheduler| scheduler as Arc)?; + Ok(scheduler) + }) + .await + .cloned() } pub async fn create_agent(&self) -> Result> { let config = crate::config::Config::global(); let disable_session_naming = config.get_goose_disable_session_naming().unwrap_or(false); + let scheduler = self.scheduler().await?; let provider_factory: AcpProviderFactory = Arc::new( move |provider_name, model_config, extensions, working_dir| { @@ -55,6 +87,7 @@ impl AcpServer { disable_session_naming, goose_platform: self.config.goose_platform.clone(), additional_source_roots: self.config.additional_source_roots.clone(), + scheduler, }) .await?; info!("Created new ACP agent"); From b4ebff3c1727071b299cef48ebc8ee31181e3f02 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 13:40:12 +1000 Subject: [PATCH 02/10] generate schema --- crates/goose/acp-meta.json | 50 ++ crates/goose/acp-schema.json | 927 +++++++++++++++++++++++++++++ ui/sdk/src/generated/client.gen.ts | 126 ++++ ui/sdk/src/generated/index.ts | 52 +- ui/sdk/src/generated/types.gen.ts | 191 +++++- ui/sdk/src/generated/zod.gen.ts | 343 +++++++++++ 6 files changed, 1686 insertions(+), 3 deletions(-) diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index c36e199b83b3..eef554cdf90f 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -195,6 +195,56 @@ "requestType": "ImportSessionRequest_unstable", "responseType": "ImportSessionResponse_unstable" }, + { + "method": "_goose/unstable/recipes/encode", + "requestType": "EncodeRecipeRequest_unstable", + "responseType": "EncodeRecipeResponse_unstable" + }, + { + "method": "_goose/unstable/recipes/decode", + "requestType": "DecodeRecipeRequest_unstable", + "responseType": "DecodeRecipeResponse_unstable" + }, + { + "method": "_goose/unstable/recipes/scan", + "requestType": "ScanRecipeRequest_unstable", + "responseType": "ScanRecipeResponse_unstable" + }, + { + "method": "_goose/unstable/recipes/list", + "requestType": "ListRecipesRequest_unstable", + "responseType": "ListRecipesResponse_unstable" + }, + { + "method": "_goose/unstable/recipes/delete", + "requestType": "DeleteRecipeRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/recipes/schedule", + "requestType": "ScheduleRecipeRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/recipes/slash-command", + "requestType": "SetRecipeSlashCommandRequest_unstable", + "responseType": "EmptyResponse" + }, + { + "method": "_goose/unstable/recipes/save", + "requestType": "SaveRecipeRequest_unstable", + "responseType": "SaveRecipeResponse_unstable" + }, + { + "method": "_goose/unstable/recipes/parse", + "requestType": "ParseRecipeRequest_unstable", + "responseType": "ParseRecipeResponse_unstable" + }, + { + "method": "_goose/unstable/recipes/to-yaml", + "requestType": "RecipeToYamlRequest_unstable", + "responseType": "RecipeToYamlResponse_unstable" + }, { "method": "_goose/unstable/session/info", "requestType": "GetSessionInfoRequest_unstable", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 14b132d95827..417ac5f542ff 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -2692,6 +2692,787 @@ "x-side": "agent", "x-method": "_goose/unstable/session/import" }, + "EncodeRecipeRequest_unstable": { + "type": "object", + "properties": { + "recipe": { + "$ref": "#/$defs/RecipeDto" + } + }, + "required": [ + "recipe" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/encode" + }, + "RecipeDto": { + "type": "object", + "properties": { + "version": { + "type": "string", + "default": "1.0.0" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "instructions": { + "type": [ + "string", + "null" + ] + }, + "prompt": { + "type": [ + "string", + "null" + ] + }, + "extensions": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/RecipeExtensionDto" + } + }, + "settings": { + "anyOf": [ + { + "$ref": "#/$defs/RecipeSettingsDto" + }, + { + "type": "null" + } + ] + }, + "activities": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "author": { + "anyOf": [ + { + "$ref": "#/$defs/RecipeAuthorDto" + }, + { + "type": "null" + } + ] + }, + "parameters": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/RecipeParameterDto" + } + }, + "response": { + "anyOf": [ + { + "$ref": "#/$defs/RecipeResponseDto" + }, + { + "type": "null" + } + ] + }, + "sub_recipes": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/SubRecipeDto" + } + }, + "retry": { + "anyOf": [ + { + "$ref": "#/$defs/RecipeRetryConfigDto" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "title", + "description" + ] + }, + "RecipeExtensionDto": { + "oneOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "timeout": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "bundled": { + "type": [ + "boolean", + "null" + ] + }, + "type": { + "type": "string", + "const": "builtin" + } + }, + "required": [ + "type", + "name" + ] + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "bundled": { + "type": [ + "boolean", + "null" + ] + }, + "type": { + "type": "string", + "const": "platform" + } + }, + "required": [ + "type", + "name" + ] + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "cmd": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "env_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeout": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "bundled": { + "type": [ + "boolean", + "null" + ] + }, + "type": { + "type": "string", + "const": "stdio" + } + }, + "required": [ + "type", + "name", + "cmd" + ] + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + }, + "env_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "timeout": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "socket": { + "type": [ + "string", + "null" + ] + }, + "bundled": { + "type": [ + "boolean", + "null" + ] + }, + "type": { + "type": "string", + "const": "streamable_http" + } + }, + "required": [ + "type", + "name", + "uri" + ] + } + ] + }, + "RecipeSettingsDto": { + "type": "object", + "properties": { + "goose_provider": { + "type": [ + "string", + "null" + ] + }, + "goose_model": { + "type": [ + "string", + "null" + ] + }, + "temperature": { + "type": [ + "number", + "null" + ], + "format": "float" + }, + "max_turns": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + } + }, + "RecipeAuthorDto": { + "type": "object", + "properties": { + "contact": { + "type": [ + "string", + "null" + ] + }, + "metadata": { + "type": [ + "string", + "null" + ] + } + } + }, + "RecipeParameterDto": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "input_type": { + "$ref": "#/$defs/RecipeParameterInputTypeDto" + }, + "requirement": { + "$ref": "#/$defs/RecipeParameterRequirementDto" + }, + "description": { + "type": "string" + }, + "default": { + "type": [ + "string", + "null" + ] + }, + "options": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "required": [ + "key", + "input_type", + "requirement", + "description" + ] + }, + "RecipeParameterInputTypeDto": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "date", + "file", + "select" + ] + }, + "RecipeParameterRequirementDto": { + "type": "string", + "enum": [ + "required", + "optional", + "user_prompt" + ] + }, + "RecipeResponseDto": { + "type": "object", + "properties": { + "json_schema": {} + } + }, + "SubRecipeDto": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "values": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "sequential_when_repeated": { + "type": "boolean", + "default": false + }, + "description": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "path" + ] + }, + "RecipeRetryConfigDto": { + "type": "object", + "properties": { + "max_retries": { + "type": "integer", + "minimum": 0 + }, + "checks": { + "type": "array", + "items": { + "$ref": "#/$defs/RecipeSuccessCheckDto" + }, + "default": [] + }, + "on_failure": { + "type": [ + "string", + "null" + ] + }, + "timeout_seconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "on_failure_timeout_seconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "required": [ + "max_retries" + ] + }, + "RecipeSuccessCheckDto": { + "oneOf": [ + { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "type": { + "type": "string", + "const": "shell" + } + }, + "required": [ + "type", + "command" + ] + } + ] + }, + "EncodeRecipeResponse_unstable": { + "type": "object", + "properties": { + "deeplink": { + "type": "string" + } + }, + "required": [ + "deeplink" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/encode" + }, + "DecodeRecipeRequest_unstable": { + "type": "object", + "properties": { + "deeplink": { + "type": "string" + } + }, + "required": [ + "deeplink" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/decode" + }, + "DecodeRecipeResponse_unstable": { + "type": "object", + "properties": { + "recipe": { + "$ref": "#/$defs/RecipeDto" + } + }, + "required": [ + "recipe" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/decode" + }, + "ScanRecipeRequest_unstable": { + "type": "object", + "properties": { + "recipe": { + "$ref": "#/$defs/RecipeDto" + } + }, + "required": [ + "recipe" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/scan" + }, + "ScanRecipeResponse_unstable": { + "type": "object", + "properties": { + "has_security_warnings": { + "type": "boolean" + } + }, + "required": [ + "has_security_warnings" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/scan" + }, + "ListRecipesRequest_unstable": { + "type": "object", + "x-side": "agent", + "x-method": "_goose/unstable/recipes/list" + }, + "ListRecipesResponse_unstable": { + "type": "object", + "properties": { + "recipes": { + "type": "array", + "items": { + "$ref": "#/$defs/RecipeListEntryDto" + } + } + }, + "required": [ + "recipes" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/list" + }, + "RecipeListEntryDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "recipe": { + "$ref": "#/$defs/RecipeDto" + }, + "file_path": { + "type": "string" + }, + "last_modified": { + "type": "string" + }, + "schedule_cron": { + "type": [ + "string", + "null" + ] + }, + "slash_command": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "recipe", + "file_path", + "last_modified" + ] + }, + "DeleteRecipeRequest_unstable": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/delete" + }, + "ScheduleRecipeRequest_unstable": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "cron_schedule": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/schedule" + }, + "SetRecipeSlashCommandRequest_unstable": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "slash_command": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/slash-command" + }, + "SaveRecipeRequest_unstable": { + "type": "object", + "properties": { + "recipe": { + "$ref": "#/$defs/RecipeDto" + }, + "id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "recipe" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/save" + }, + "SaveRecipeResponse_unstable": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "file_name": { + "type": "string" + }, + "file_path": { + "type": "string" + } + }, + "required": [ + "id", + "file_name", + "file_path" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/save" + }, + "ParseRecipeRequest_unstable": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/parse" + }, + "ParseRecipeResponse_unstable": { + "type": "object", + "properties": { + "recipe": { + "$ref": "#/$defs/RecipeDto" + } + }, + "required": [ + "recipe" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/parse" + }, + "RecipeToYamlRequest_unstable": { + "type": "object", + "properties": { + "recipe": { + "$ref": "#/$defs/RecipeDto" + } + }, + "required": [ + "recipe" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/to-yaml" + }, + "RecipeToYamlResponse_unstable": { + "type": "object", + "properties": { + "yaml": { + "type": "string" + } + }, + "required": [ + "yaml" + ], + "x-side": "agent", + "x-method": "_goose/unstable/recipes/to-yaml" + }, "GetSessionInfoRequest_unstable": { "type": "object", "properties": { @@ -4164,6 +4945,96 @@ "description": "Params for _goose/unstable/session/import", "title": "ImportSessionRequest_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/EncodeRecipeRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/encode", + "title": "EncodeRecipeRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DecodeRecipeRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/decode", + "title": "DecodeRecipeRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ScanRecipeRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/scan", + "title": "ScanRecipeRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListRecipesRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/list", + "title": "ListRecipesRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DeleteRecipeRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/delete", + "title": "DeleteRecipeRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ScheduleRecipeRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/schedule", + "title": "ScheduleRecipeRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/SetRecipeSlashCommandRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/slash-command", + "title": "SetRecipeSlashCommandRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/SaveRecipeRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/save", + "title": "SaveRecipeRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ParseRecipeRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/parse", + "title": "ParseRecipeRequest_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RecipeToYamlRequest_unstable" + } + ], + "description": "Params for _goose/unstable/recipes/to-yaml", + "title": "RecipeToYamlRequest_unstable" + }, { "allOf": [ { @@ -4608,6 +5479,62 @@ ], "title": "ImportSessionResponse_unstable" }, + { + "allOf": [ + { + "$ref": "#/$defs/EncodeRecipeResponse_unstable" + } + ], + "title": "EncodeRecipeResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/DecodeRecipeResponse_unstable" + } + ], + "title": "DecodeRecipeResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ScanRecipeResponse_unstable" + } + ], + "title": "ScanRecipeResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ListRecipesResponse_unstable" + } + ], + "title": "ListRecipesResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/SaveRecipeResponse_unstable" + } + ], + "title": "SaveRecipeResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ParseRecipeResponse_unstable" + } + ], + "title": "ParseRecipeResponse_unstable" + }, + { + "allOf": [ + { + "$ref": "#/$defs/RecipeToYamlResponse_unstable" + } + ], + "title": "RecipeToYamlResponse_unstable" + }, { "allOf": [ { diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 6d9277e0040f..7588717d4c51 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -22,9 +22,12 @@ import type { CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, + DecodeRecipeRequest_unstable, + DecodeRecipeResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, + DeleteRecipeRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, @@ -41,6 +44,8 @@ import type { DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, + EncodeRecipeRequest_unstable, + EncodeRecipeResponse_unstable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, @@ -64,12 +69,16 @@ import type { ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, + ListRecipesRequest_unstable, + ListRecipesResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, + ParseRecipeRequest_unstable, + ParseRecipeResponse_unstable, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, @@ -93,13 +102,21 @@ import type { ReadResourceRequest_unstable, ReadResourceResponse_unstable, RecipeParamsResponse_unstable, + RecipeToYamlRequest_unstable, + RecipeToYamlResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, RequestRecipeParams_unstable, + SaveRecipeRequest_unstable, + SaveRecipeResponse_unstable, + ScanRecipeRequest_unstable, + ScanRecipeResponse_unstable, + ScheduleRecipeRequest_unstable, SetConfigExtensionEnabledRequest_unstable, + SetRecipeSlashCommandRequest_unstable, SetSessionSystemPromptRequest_unstable, SteerSessionRequest_unstable, SteerSessionResponse_unstable, @@ -116,11 +133,13 @@ import { zCustomProviderDeleteResponse_unstable, zCustomProviderReadResponse_unstable, zCustomProviderUpdateResponse_unstable, + zDecodeRecipeResponse_unstable, zDefaultsReadResponse_unstable, zDictationConfigResponse_unstable, zDictationModelDownloadProgressResponse_unstable, zDictationModelsListResponse_unstable, zDictationTranscribeResponse_unstable, + zEncodeRecipeResponse_unstable, zExportSessionResponse_unstable, zExportSourceResponse_unstable, zGetAvailableExtensionsResponse_unstable, @@ -133,9 +152,11 @@ import { zImportSessionResponse_unstable, zImportSourcesResponse_unstable, zListProvidersResponse_unstable, + zListRecipesResponse_unstable, zListSourcesResponse_unstable, zOnboardingImportApplyResponse_unstable, zOnboardingImportScanResponse_unstable, + zParseRecipeResponse_unstable, zPreferencesReadResponse_unstable, zProviderCatalogListResponse_unstable, zProviderCatalogTemplateResponse_unstable, @@ -145,8 +166,11 @@ import { zProviderSetupCatalogListResponse_unstable, zProviderSupportedModelsListResponse_unstable, zReadResourceResponse_unstable, + zRecipeToYamlResponse_unstable, zRefreshProviderInventoryResponse_unstable, zRequestRecipeParams_unstable, + zSaveRecipeResponse_unstable, + zScanRecipeResponse_unstable, zSteerSessionResponse_unstable, zUpdateSourceResponse_unstable, } from './zod.gen.js'; @@ -567,6 +591,108 @@ export class GooseExtClient { ) as ImportSessionResponse_unstable; } + async recipesEncode_unstable( + params: EncodeRecipeRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/recipes/encode", + params, + ); + return zEncodeRecipeResponse_unstable.parse( + raw, + ) as EncodeRecipeResponse_unstable; + } + + async recipesDecode_unstable( + params: DecodeRecipeRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/recipes/decode", + params, + ); + return zDecodeRecipeResponse_unstable.parse( + raw, + ) as DecodeRecipeResponse_unstable; + } + + async recipesScan_unstable( + params: ScanRecipeRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/recipes/scan", + params, + ); + return zScanRecipeResponse_unstable.parse( + raw, + ) as ScanRecipeResponse_unstable; + } + + async recipesList_unstable( + params: ListRecipesRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/recipes/list", + params, + ); + return zListRecipesResponse_unstable.parse( + raw, + ) as ListRecipesResponse_unstable; + } + + async recipesDelete_unstable( + params: DeleteRecipeRequest_unstable, + ): Promise { + await this.conn.extMethod("_goose/unstable/recipes/delete", params); + } + + async recipesSchedule_unstable( + params: ScheduleRecipeRequest_unstable, + ): Promise { + await this.conn.extMethod("_goose/unstable/recipes/schedule", params); + } + + async recipesSlashCommand_unstable( + params: SetRecipeSlashCommandRequest_unstable, + ): Promise { + await this.conn.extMethod("_goose/unstable/recipes/slash-command", params); + } + + async recipesSave_unstable( + params: SaveRecipeRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/recipes/save", + params, + ); + return zSaveRecipeResponse_unstable.parse( + raw, + ) as SaveRecipeResponse_unstable; + } + + async recipesParse_unstable( + params: ParseRecipeRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/recipes/parse", + params, + ); + return zParseRecipeResponse_unstable.parse( + raw, + ) as ParseRecipeResponse_unstable; + } + + async recipesToYaml_unstable( + params: RecipeToYamlRequest_unstable, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/unstable/recipes/to-yaml", + params, + ); + return zRecipeToYamlResponse_unstable.parse( + raw, + ) as RecipeToYamlResponse_unstable; + } + async sessionInfo_unstable( params: GetSessionInfoRequest_unstable, ): Promise { diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 50f6a0bb44ae..35f8040d2f82 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddSessionExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtAgentRequest, ExtAgentResponse, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeParamsAction, RecipeParamsResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, RequestRecipeParams_unstable, ResourceLink, Role, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddSessionExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DecodeRecipeRequest_unstable, DecodeRecipeResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteRecipeRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EncodeRecipeRequest_unstable, EncodeRecipeResponse_unstable, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtAgentRequest, ExtAgentResponse, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListRecipesRequest_unstable, ListRecipesResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, ParseRecipeRequest_unstable, ParseRecipeResponse_unstable, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RecipeAuthorDto, RecipeDto, RecipeExtensionDto, RecipeListEntryDto, RecipeParameter, RecipeParameterDto, RecipeParameterInputType, RecipeParameterInputTypeDto, RecipeParameterRequirement, RecipeParameterRequirementDto, RecipeParamsAction, RecipeParamsResponse_unstable, RecipeResponseDto, RecipeRetryConfigDto, RecipeSettingsDto, RecipeSuccessCheckDto, RecipeToYamlRequest_unstable, RecipeToYamlResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, RequestRecipeParams_unstable, ResourceLink, Role, SaveRecipeRequest_unstable, SaveRecipeResponse_unstable, ScanRecipeRequest_unstable, ScanRecipeResponse_unstable, ScheduleRecipeRequest_unstable, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetRecipeSlashCommandRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, SubRecipeDto, TextContent, TextResourceContents, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -198,6 +198,56 @@ export const GOOSE_EXT_METHODS = [ requestType: "ImportSessionRequest_unstable", responseType: "ImportSessionResponse_unstable", }, + { + method: "_goose/unstable/recipes/encode", + requestType: "EncodeRecipeRequest_unstable", + responseType: "EncodeRecipeResponse_unstable", + }, + { + method: "_goose/unstable/recipes/decode", + requestType: "DecodeRecipeRequest_unstable", + responseType: "DecodeRecipeResponse_unstable", + }, + { + method: "_goose/unstable/recipes/scan", + requestType: "ScanRecipeRequest_unstable", + responseType: "ScanRecipeResponse_unstable", + }, + { + method: "_goose/unstable/recipes/list", + requestType: "ListRecipesRequest_unstable", + responseType: "ListRecipesResponse_unstable", + }, + { + method: "_goose/unstable/recipes/delete", + requestType: "DeleteRecipeRequest_unstable", + responseType: "EmptyResponse", + }, + { + method: "_goose/unstable/recipes/schedule", + requestType: "ScheduleRecipeRequest_unstable", + responseType: "EmptyResponse", + }, + { + method: "_goose/unstable/recipes/slash-command", + requestType: "SetRecipeSlashCommandRequest_unstable", + responseType: "EmptyResponse", + }, + { + method: "_goose/unstable/recipes/save", + requestType: "SaveRecipeRequest_unstable", + responseType: "SaveRecipeResponse_unstable", + }, + { + method: "_goose/unstable/recipes/parse", + requestType: "ParseRecipeRequest_unstable", + responseType: "ParseRecipeResponse_unstable", + }, + { + method: "_goose/unstable/recipes/to-yaml", + requestType: "RecipeToYamlRequest_unstable", + responseType: "RecipeToYamlResponse_unstable", + }, { method: "_goose/unstable/session/info", requestType: "GetSessionInfoRequest_unstable", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index b2cd9179e137..b944a8b8f552 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -1131,6 +1131,193 @@ export type ImportSessionResponse_unstable = { messageCount: number; }; +export type EncodeRecipeRequest_unstable = { + recipe: RecipeDto; +}; + +export type RecipeDto = { + version?: string; + title: string; + description: string; + instructions?: string | null; + prompt?: string | null; + extensions?: Array | null; + settings?: RecipeSettingsDto | null; + activities?: Array | null; + author?: RecipeAuthorDto | null; + parameters?: Array | null; + response?: RecipeResponseDto | null; + sub_recipes?: Array | null; + retry?: RecipeRetryConfigDto | null; +}; + +export type RecipeExtensionDto = { + name: string; + description?: string | null; + display_name?: string | null; + timeout?: number | null; + bundled?: boolean | null; + type: 'builtin'; +} | { + name: string; + description?: string | null; + display_name?: string | null; + bundled?: boolean | null; + type: 'platform'; +} | { + name: string; + description?: string | null; + cmd: string; + args?: Array; + env_keys?: Array; + timeout?: number | null; + cwd?: string | null; + bundled?: boolean | null; + type: 'stdio'; +} | { + name: string; + description?: string | null; + uri: string; + env_keys?: Array; + headers?: { + [key: string]: string; + }; + timeout?: number | null; + socket?: string | null; + bundled?: boolean | null; + type: 'streamable_http'; +}; + +export type RecipeSettingsDto = { + goose_provider?: string | null; + goose_model?: string | null; + temperature?: number | null; + max_turns?: number | null; +}; + +export type RecipeAuthorDto = { + contact?: string | null; + metadata?: string | null; +}; + +export type RecipeParameterDto = { + key: string; + input_type: RecipeParameterInputTypeDto; + requirement: RecipeParameterRequirementDto; + description: string; + default?: string | null; + options?: Array | null; +}; + +export type RecipeParameterInputTypeDto = 'string' | 'number' | 'boolean' | 'date' | 'file' | 'select'; + +export type RecipeParameterRequirementDto = 'required' | 'optional' | 'user_prompt'; + +export type RecipeResponseDto = { + json_schema?: unknown; +}; + +export type SubRecipeDto = { + name: string; + path: string; + values?: { + [key: string]: string; + } | null; + sequential_when_repeated?: boolean; + description?: string | null; +}; + +export type RecipeRetryConfigDto = { + max_retries: number; + checks?: Array; + on_failure?: string | null; + timeout_seconds?: number | null; + on_failure_timeout_seconds?: number | null; +}; + +export type RecipeSuccessCheckDto = { + command: string; + type: 'shell'; +}; + +export type EncodeRecipeResponse_unstable = { + deeplink: string; +}; + +export type DecodeRecipeRequest_unstable = { + deeplink: string; +}; + +export type DecodeRecipeResponse_unstable = { + recipe: RecipeDto; +}; + +export type ScanRecipeRequest_unstable = { + recipe: RecipeDto; +}; + +export type ScanRecipeResponse_unstable = { + has_security_warnings: boolean; +}; + +export type ListRecipesRequest_unstable = { + [key: string]: unknown; +}; + +export type ListRecipesResponse_unstable = { + recipes: Array; +}; + +export type RecipeListEntryDto = { + id: string; + recipe: RecipeDto; + file_path: string; + last_modified: string; + schedule_cron?: string | null; + slash_command?: string | null; +}; + +export type DeleteRecipeRequest_unstable = { + id: string; +}; + +export type ScheduleRecipeRequest_unstable = { + id: string; + cron_schedule?: string | null; +}; + +export type SetRecipeSlashCommandRequest_unstable = { + id: string; + slash_command?: string | null; +}; + +export type SaveRecipeRequest_unstable = { + recipe: RecipeDto; + id?: string | null; +}; + +export type SaveRecipeResponse_unstable = { + id: string; + file_name: string; + file_path: string; +}; + +export type ParseRecipeRequest_unstable = { + content: string; +}; + +export type ParseRecipeResponse_unstable = { + recipe: RecipeDto; +}; + +export type RecipeToYamlRequest_unstable = { + recipe: RecipeDto; +}; + +export type RecipeToYamlResponse_unstable = { + yaml: string; +}; + /** * Return list-style metadata for a single session without loading the conversation. */ @@ -1626,14 +1813,14 @@ export type RecipeParamsAction = 'submit' | 'cancel'; export type ExtRequest = { id: string; method: string; - params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { + params?: AddSessionExtensionRequest_unstable | RemoveSessionExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | EncodeRecipeRequest_unstable | DecodeRecipeRequest_unstable | ScanRecipeRequest_unstable | ListRecipesRequest_unstable | DeleteRecipeRequest_unstable | ScheduleRecipeRequest_unstable | SetRecipeSlashCommandRequest_unstable | SaveRecipeRequest_unstable | ParseRecipeRequest_unstable | RecipeToYamlRequest_unstable | GetSessionInfoRequest_unstable | TruncateSessionConversationRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | SteerSessionResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | GetSessionInfoResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown; + result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | SteerSessionResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | EncodeRecipeResponse_unstable | DecodeRecipeResponse_unstable | ScanRecipeResponse_unstable | ListRecipesResponse_unstable | SaveRecipeResponse_unstable | ParseRecipeResponse_unstable | RecipeToYamlResponse_unstable | GetSessionInfoResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 1f6f3604dd7a..1d4c577fd1af 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -1131,6 +1131,332 @@ export const zImportSessionResponse_unstable = z.object({ messageCount: z.number().int().gte(0) }); +export const zRecipeExtensionDto = z.union([ + z.object({ + name: z.string(), + description: z.union([ + z.string(), + z.null() + ]).optional(), + display_name: z.union([ + z.string(), + z.null() + ]).optional(), + timeout: z.union([ + z.number().int().gte(0), + z.null() + ]).optional(), + bundled: z.union([ + z.boolean(), + z.null() + ]).optional(), + type: z.literal('builtin') + }), + z.object({ + name: z.string(), + description: z.union([ + z.string(), + z.null() + ]).optional(), + display_name: z.union([ + z.string(), + z.null() + ]).optional(), + bundled: z.union([ + z.boolean(), + z.null() + ]).optional(), + type: z.literal('platform') + }), + z.object({ + name: z.string(), + description: z.union([ + z.string(), + z.null() + ]).optional(), + cmd: z.string(), + args: z.array(z.string()).optional(), + env_keys: z.array(z.string()).optional(), + timeout: z.union([ + z.number().int().gte(0), + z.null() + ]).optional(), + cwd: z.union([ + z.string(), + z.null() + ]).optional(), + bundled: z.union([ + z.boolean(), + z.null() + ]).optional(), + type: z.literal('stdio') + }), + z.object({ + name: z.string(), + description: z.union([ + z.string(), + z.null() + ]).optional(), + uri: z.string(), + env_keys: z.array(z.string()).optional(), + headers: z.record(z.string()).optional(), + timeout: z.union([ + z.number().int().gte(0), + z.null() + ]).optional(), + socket: z.union([ + z.string(), + z.null() + ]).optional(), + bundled: z.union([ + z.boolean(), + z.null() + ]).optional(), + type: z.literal('streamable_http') + }) +]); + +export const zRecipeSettingsDto = z.object({ + goose_provider: z.union([ + z.string(), + z.null() + ]).optional(), + goose_model: z.union([ + z.string(), + z.null() + ]).optional(), + temperature: z.union([ + z.number(), + z.null() + ]).optional(), + max_turns: z.union([ + z.number().int().gte(0), + z.null() + ]).optional() +}); + +export const zRecipeAuthorDto = z.object({ + contact: z.union([ + z.string(), + z.null() + ]).optional(), + metadata: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zRecipeParameterInputTypeDto = z.enum([ + 'string', + 'number', + 'boolean', + 'date', + 'file', + 'select' +]); + +export const zRecipeParameterRequirementDto = z.enum([ + 'required', + 'optional', + 'user_prompt' +]); + +export const zRecipeParameterDto = z.object({ + key: z.string(), + input_type: zRecipeParameterInputTypeDto, + requirement: zRecipeParameterRequirementDto, + description: z.string(), + default: z.union([ + z.string(), + z.null() + ]).optional(), + options: z.union([ + z.array(z.string()), + z.null() + ]).optional() +}); + +export const zRecipeResponseDto = z.object({ + json_schema: z.unknown().optional() +}); + +export const zSubRecipeDto = z.object({ + name: z.string(), + path: z.string(), + values: z.union([ + z.record(z.string()), + z.null() + ]).optional(), + sequential_when_repeated: z.boolean().optional().default(false), + description: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zRecipeSuccessCheckDto = z.object({ + command: z.string(), + type: z.literal('shell') +}); + +export const zRecipeRetryConfigDto = z.object({ + max_retries: z.number().int().gte(0), + checks: z.array(zRecipeSuccessCheckDto).optional().default([]), + on_failure: z.union([ + z.string(), + z.null() + ]).optional(), + timeout_seconds: z.union([ + z.number().int().gte(0), + z.null() + ]).optional(), + on_failure_timeout_seconds: z.union([ + z.number().int().gte(0), + z.null() + ]).optional() +}); + +export const zRecipeDto = z.object({ + version: z.string().optional().default('1.0.0'), + title: z.string(), + description: z.string(), + instructions: z.union([ + z.string(), + z.null() + ]).optional(), + prompt: z.union([ + z.string(), + z.null() + ]).optional(), + extensions: z.union([ + z.array(zRecipeExtensionDto), + z.null() + ]).optional(), + settings: z.union([ + zRecipeSettingsDto, + z.null() + ]).optional(), + activities: z.union([ + z.array(z.string()), + z.null() + ]).optional(), + author: z.union([ + zRecipeAuthorDto, + z.null() + ]).optional(), + parameters: z.union([ + z.array(zRecipeParameterDto), + z.null() + ]).optional(), + response: z.union([ + zRecipeResponseDto, + z.null() + ]).optional(), + sub_recipes: z.union([ + z.array(zSubRecipeDto), + z.null() + ]).optional(), + retry: z.union([ + zRecipeRetryConfigDto, + z.null() + ]).optional() +}); + +export const zEncodeRecipeRequest_unstable = z.object({ + recipe: zRecipeDto +}); + +export const zEncodeRecipeResponse_unstable = z.object({ + deeplink: z.string() +}); + +export const zDecodeRecipeRequest_unstable = z.object({ + deeplink: z.string() +}); + +export const zDecodeRecipeResponse_unstable = z.object({ + recipe: zRecipeDto +}); + +export const zScanRecipeRequest_unstable = z.object({ + recipe: zRecipeDto +}); + +export const zScanRecipeResponse_unstable = z.object({ + has_security_warnings: z.boolean() +}); + +export const zListRecipesRequest_unstable = z.record(z.unknown()); + +export const zRecipeListEntryDto = z.object({ + id: z.string(), + recipe: zRecipeDto, + file_path: z.string(), + last_modified: z.string(), + schedule_cron: z.union([ + z.string(), + z.null() + ]).optional(), + slash_command: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zListRecipesResponse_unstable = z.object({ + recipes: z.array(zRecipeListEntryDto) +}); + +export const zDeleteRecipeRequest_unstable = z.object({ + id: z.string() +}); + +export const zScheduleRecipeRequest_unstable = z.object({ + id: z.string(), + cron_schedule: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zSetRecipeSlashCommandRequest_unstable = z.object({ + id: z.string(), + slash_command: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zSaveRecipeRequest_unstable = z.object({ + recipe: zRecipeDto, + id: z.union([ + z.string(), + z.null() + ]).optional() +}); + +export const zSaveRecipeResponse_unstable = z.object({ + id: z.string(), + file_name: z.string(), + file_path: z.string() +}); + +export const zParseRecipeRequest_unstable = z.object({ + content: z.string() +}); + +export const zParseRecipeResponse_unstable = z.object({ + recipe: zRecipeDto +}); + +export const zRecipeToYamlRequest_unstable = z.object({ + recipe: zRecipeDto +}); + +export const zRecipeToYamlResponse_unstable = z.object({ + yaml: z.string() +}); + /** * Return list-style metadata for a single session without loading the conversation. */ @@ -1654,6 +1980,16 @@ export const zExtRequest = z.object({ zOnboardingImportApplyRequest_unstable, zExportSessionRequest_unstable, zImportSessionRequest_unstable, + zEncodeRecipeRequest_unstable, + zDecodeRecipeRequest_unstable, + zScanRecipeRequest_unstable, + zListRecipesRequest_unstable, + zDeleteRecipeRequest_unstable, + zScheduleRecipeRequest_unstable, + zSetRecipeSlashCommandRequest_unstable, + zSaveRecipeRequest_unstable, + zParseRecipeRequest_unstable, + zRecipeToYamlRequest_unstable, zGetSessionInfoRequest_unstable, zTruncateSessionConversationRequest_unstable, zUpdateSessionProjectRequest_unstable, @@ -1716,6 +2052,13 @@ export const zExtResponse = z.union([ zOnboardingImportApplyResponse_unstable, zExportSessionResponse_unstable, zImportSessionResponse_unstable, + zEncodeRecipeResponse_unstable, + zDecodeRecipeResponse_unstable, + zScanRecipeResponse_unstable, + zListRecipesResponse_unstable, + zSaveRecipeResponse_unstable, + zParseRecipeResponse_unstable, + zRecipeToYamlResponse_unstable, zGetSessionInfoResponse_unstable, zCreateSourceResponse_unstable, zListSourcesResponse_unstable, From de4a838f692df89c735708a680623800b278dbdc Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 14:02:03 +1000 Subject: [PATCH 03/10] use acp methods for recipe management --- ui/desktop/src/acp/__tests__/recipe.test.ts | 162 ++++++++++++++++++ ui/desktop/src/acp/recipe.ts | 147 ++++++++++++++++ .../src/components/recipes/RecipesView.tsx | 97 +++++------ ui/desktop/src/recipe/index.ts | 59 ++----- ui/desktop/src/recipe/recipe_management.ts | 46 +++-- 5 files changed, 397 insertions(+), 114 deletions(-) create mode 100644 ui/desktop/src/acp/__tests__/recipe.test.ts create mode 100644 ui/desktop/src/acp/recipe.ts diff --git a/ui/desktop/src/acp/__tests__/recipe.test.ts b/ui/desktop/src/acp/__tests__/recipe.test.ts new file mode 100644 index 000000000000..040937689165 --- /dev/null +++ b/ui/desktop/src/acp/__tests__/recipe.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Recipe } from '../../api'; +import { getAcpClient } from '../acpConnection'; +import { + decodeRecipe, + deleteRecipe, + encodeRecipe, + listRecipes, + parseRecipe, + recipeToYaml, + saveRecipe, + scanRecipe, + scheduleRecipe, + setRecipeSlashCommand, +} from '../recipe'; + +vi.mock('../acpConnection', () => ({ + getAcpClient: vi.fn(), +})); + +const recipe = { + title: 'Test Recipe', + description: 'A recipe used by ACP tests', + instructions: 'Follow these test instructions', +} as Recipe; + +function createClient() { + return { + goose: { + recipesEncode_unstable: vi.fn(), + recipesDecode_unstable: vi.fn(), + recipesScan_unstable: vi.fn(), + recipesParse_unstable: vi.fn(), + recipesSave_unstable: vi.fn(), + recipesList_unstable: vi.fn(), + recipesDelete_unstable: vi.fn(), + recipesSchedule_unstable: vi.fn(), + recipesSlashCommand_unstable: vi.fn(), + recipesToYaml_unstable: vi.fn(), + }, + }; +} + +describe('ACP recipe helpers', () => { + let client: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + client = createClient(); + vi.mocked(getAcpClient).mockResolvedValue( + client as unknown as Awaited> + ); + }); + + it('encodes a recipe using ACP', async () => { + client.goose.recipesEncode_unstable.mockResolvedValue({ deeplink: 'encoded' }); + + await expect(encodeRecipe(recipe)).resolves.toBe('encoded'); + + expect(client.goose.recipesEncode_unstable).toHaveBeenCalledWith({ recipe }); + }); + + it('decodes a recipe using ACP', async () => { + client.goose.recipesDecode_unstable.mockResolvedValue({ recipe }); + + await expect(decodeRecipe('encoded')).resolves.toEqual(recipe); + + expect(client.goose.recipesDecode_unstable).toHaveBeenCalledWith({ deeplink: 'encoded' }); + }); + + it('scans a recipe using ACP', async () => { + client.goose.recipesScan_unstable.mockResolvedValue({ has_security_warnings: true }); + + await expect(scanRecipe(recipe)).resolves.toEqual({ has_security_warnings: true }); + + expect(client.goose.recipesScan_unstable).toHaveBeenCalledWith({ recipe }); + }); + + it('parses a recipe using ACP', async () => { + client.goose.recipesParse_unstable.mockResolvedValue({ recipe }); + + await expect(parseRecipe('title: Test')).resolves.toEqual(recipe); + + expect(client.goose.recipesParse_unstable).toHaveBeenCalledWith({ content: 'title: Test' }); + }); + + it('saves a recipe using ACP', async () => { + const response = { + id: 'recipe-id', + file_name: 'test.yaml', + file_path: '/tmp/test.yaml', + }; + client.goose.recipesSave_unstable.mockResolvedValue(response); + + await expect(saveRecipe(recipe, 'recipe-id')).resolves.toEqual(response); + + expect(client.goose.recipesSave_unstable).toHaveBeenCalledWith({ + recipe, + id: 'recipe-id', + }); + }); + + it('lists recipes using ACP and returns desktop recipe manifests', async () => { + client.goose.recipesList_unstable.mockResolvedValue({ + recipes: [ + { + id: 'recipe-id', + recipe, + file_path: '/tmp/test.yaml', + last_modified: '2026-06-23T00:00:00Z', + schedule_cron: '0 0 * * * *', + slash_command: 'test', + }, + ], + }); + + await expect(listRecipes()).resolves.toEqual([ + { + id: 'recipe-id', + recipe, + file_path: '/tmp/test.yaml', + last_modified: '2026-06-23T00:00:00Z', + schedule_cron: '0 0 * * * *', + slash_command: 'test', + }, + ]); + + expect(client.goose.recipesList_unstable).toHaveBeenCalledWith({}); + }); + + it('runs recipe mutations using ACP', async () => { + await deleteRecipe('recipe-id'); + await scheduleRecipe('recipe-id', '0 0 * * * *'); + await setRecipeSlashCommand('recipe-id', 'test'); + + expect(client.goose.recipesDelete_unstable).toHaveBeenCalledWith({ id: 'recipe-id' }); + expect(client.goose.recipesSchedule_unstable).toHaveBeenCalledWith({ + id: 'recipe-id', + cron_schedule: '0 0 * * * *', + }); + expect(client.goose.recipesSlashCommand_unstable).toHaveBeenCalledWith({ + id: 'recipe-id', + slash_command: 'test', + }); + }); + + it('converts a recipe to YAML using ACP', async () => { + client.goose.recipesToYaml_unstable.mockResolvedValue({ yaml: 'title: Test Recipe' }); + + await expect(recipeToYaml(recipe)).resolves.toBe('title: Test Recipe'); + + expect(client.goose.recipesToYaml_unstable).toHaveBeenCalledWith({ recipe }); + }); + + it('surfaces ACP JSON-RPC error messages', async () => { + client.goose.recipesEncode_unstable.mockRejectedValue({ + error: { message: 'recipe is invalid' }, + }); + + await expect(encodeRecipe(recipe)).rejects.toThrow('recipe is invalid'); + }); +}); diff --git a/ui/desktop/src/acp/recipe.ts b/ui/desktop/src/acp/recipe.ts new file mode 100644 index 000000000000..61773c5dbdd3 --- /dev/null +++ b/ui/desktop/src/acp/recipe.ts @@ -0,0 +1,147 @@ +import type { + RecipeDto, + RecipeListEntryDto, + SaveRecipeResponse_unstable, + ScanRecipeResponse_unstable, +} from '@aaif/goose-sdk'; +import type { Recipe, RecipeManifest } from '../api'; +import { getAcpClient } from './acpConnection'; + +function asAcpRecipe(recipe: Recipe): RecipeDto { + return recipe as unknown as RecipeDto; +} + +function asDesktopRecipe(recipe: RecipeDto): Recipe { + return recipe as unknown as Recipe; +} + +function asDesktopRecipeManifest(entry: RecipeListEntryDto): RecipeManifest { + return { + ...entry, + recipe: asDesktopRecipe(entry.recipe), + }; +} + +function acpErrorMessage(error: unknown): string | null { + if (typeof error !== 'object' || error === null) { + return null; + } + + const candidate = 'error' in error && isRecord(error.error) ? error.error : error; + return isRecord(candidate) && typeof candidate.message === 'string' ? candidate.message : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function normalizeAcpError(error: unknown, fallback: string): Error { + if (error instanceof Error) { + return error; + } + return new Error(acpErrorMessage(error) ?? fallback); +} + +export async function encodeRecipe(recipe: Recipe): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.recipesEncode_unstable({ recipe: asAcpRecipe(recipe) }); + return response.deeplink; + } catch (error) { + throw normalizeAcpError(error, 'Failed to encode recipe'); + } +} + +export async function decodeRecipe(deeplink: string): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.recipesDecode_unstable({ deeplink }); + return asDesktopRecipe(response.recipe); + } catch (error) { + throw normalizeAcpError(error, 'Failed to decode recipe'); + } +} + +export async function scanRecipe(recipe: Recipe): Promise { + try { + const client = await getAcpClient(); + return await client.goose.recipesScan_unstable({ recipe: asAcpRecipe(recipe) }); + } catch (error) { + throw normalizeAcpError(error, 'Failed to scan recipe'); + } +} + +export async function parseRecipe(content: string): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.recipesParse_unstable({ content }); + return asDesktopRecipe(response.recipe); + } catch (error) { + throw normalizeAcpError(error, 'Failed to parse recipe'); + } +} + +export async function saveRecipe( + recipe: Recipe, + id?: string | null +): Promise { + try { + const client = await getAcpClient(); + return await client.goose.recipesSave_unstable({ + recipe: asAcpRecipe(recipe), + id, + }); + } catch (error) { + throw normalizeAcpError(error, 'Failed to save recipe'); + } +} + +export async function listRecipes(): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.recipesList_unstable({}); + return response.recipes.map(asDesktopRecipeManifest); + } catch (error) { + throw normalizeAcpError(error, 'Failed to list recipes'); + } +} + +export async function deleteRecipe(id: string): Promise { + try { + const client = await getAcpClient(); + await client.goose.recipesDelete_unstable({ id }); + } catch (error) { + throw normalizeAcpError(error, 'Failed to delete recipe'); + } +} + +export async function scheduleRecipe(id: string, cronSchedule?: string | null): Promise { + try { + const client = await getAcpClient(); + await client.goose.recipesSchedule_unstable({ id, cron_schedule: cronSchedule }); + } catch (error) { + throw normalizeAcpError(error, 'Failed to schedule recipe'); + } +} + +export async function setRecipeSlashCommand( + id: string, + slashCommand?: string | null +): Promise { + try { + const client = await getAcpClient(); + await client.goose.recipesSlashCommand_unstable({ id, slash_command: slashCommand }); + } catch (error) { + throw normalizeAcpError(error, 'Failed to set recipe slash command'); + } +} + +export async function recipeToYaml(recipe: Recipe): Promise { + try { + const client = await getAcpClient(); + const response = await client.goose.recipesToYaml_unstable({ recipe: asAcpRecipe(recipe) }); + return response.yaml; + } catch (error) { + throw normalizeAcpError(error, 'Failed to convert recipe to YAML'); + } +} diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index d2786eaa0bdf..96a141e11b72 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -1,5 +1,12 @@ import { useState, useEffect, useMemo } from 'react'; -import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipe_management'; +import { + convertToLocaleDateString, + deleteRecipe, + listSavedRecipes, + recipeToYaml, + scheduleRecipe, + setRecipeSlashCommand, +} from '../../recipe/recipe_management'; import { FileText, Edit, @@ -22,13 +29,7 @@ import { Skeleton } from '../ui/skeleton'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; -import { - deleteRecipe, - RecipeManifest, - scheduleRecipe, - setRecipeSlashCommand, - recipeToYaml, -} from '../../api'; +import type { RecipeManifest } from '../../api'; import { createSession } from '../../sessions'; import { isRecipeParamsCancelled } from '../../acp/errors'; import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm'; @@ -253,7 +254,8 @@ const i18n = defineMessages({ }, recipesDescription: { id: 'recipesView.recipesDescription', - defaultMessage: 'View and manage your saved recipes to quickly start new sessions with predefined configurations. {shortcut} to search.', + defaultMessage: + 'View and manage your saved recipes to quickly start new sessions with predefined configurations. {shortcut} to search.', }, searchRecipesPlaceholder: { id: 'recipesView.searchRecipesPlaceholder', @@ -432,7 +434,7 @@ export default function RecipesView() { } try { - await deleteRecipe({ body: { id: recipeManifest.id } }); + await deleteRecipe(recipeManifest.id); trackRecipeDeleted(true); await loadSavedRecipes(); toastSuccess({ @@ -481,16 +483,13 @@ export default function RecipesView() { const handleCopyYaml = async (recipeManifest: RecipeManifest) => { try { - const response = await recipeToYaml({ - body: { recipe: recipeManifest.recipe }, - throwOnError: true, - }); + const yaml = await recipeToYaml(recipeManifest.recipe); - if (!response.data?.yaml) { + if (!yaml) { throw new Error('No YAML data returned from API'); } - await navigator.clipboard.writeText(response.data.yaml); + await navigator.clipboard.writeText(yaml); trackRecipeYamlCopied(true); toastSuccess({ title: intl.formatMessage(i18n.yamlCopiedTitle), @@ -508,12 +507,9 @@ export default function RecipesView() { const handleExportFile = async (recipeManifest: RecipeManifest) => { try { - const response = await recipeToYaml({ - body: { recipe: recipeManifest.recipe }, - throwOnError: true, - }); + const yaml = await recipeToYaml(recipeManifest.recipe); - if (!response.data?.yaml) { + if (!yaml) { throw new Error('No YAML data returned from API'); } @@ -534,7 +530,7 @@ export default function RecipesView() { }); if (!result.canceled && result.filePath) { - await window.electron.writeFile(result.filePath, response.data.yaml); + await window.electron.writeFile(result.filePath, yaml); trackRecipeExportedToFile(true); toastSuccess({ title: intl.formatMessage(i18n.recipeExportedTitle), @@ -563,12 +559,7 @@ export default function RecipesView() { const action = scheduleRecipeManifest.schedule_cron ? 'edit' : 'add'; try { - await scheduleRecipe({ - body: { - id: scheduleRecipeManifest.id, - cron_schedule: scheduleCron, - }, - }); + await scheduleRecipe(scheduleRecipeManifest.id, scheduleCron); trackRecipeScheduled(true, action); toastSuccess({ @@ -591,12 +582,7 @@ export default function RecipesView() { if (!scheduleRecipeManifest) return; try { - await scheduleRecipe({ - body: { - id: scheduleRecipeManifest.id, - cron_schedule: null, - }, - }); + await scheduleRecipe(scheduleRecipeManifest.id, null); trackRecipeScheduled(true, 'remove'); toastSuccess({ @@ -631,17 +617,14 @@ export default function RecipesView() { : 'remove'; try { - await setRecipeSlashCommand({ - body: { - id: slashCommandRecipeManifest.id, - slash_command: slashCommand || null, - }, - }); + await setRecipeSlashCommand(slashCommandRecipeManifest.id, slashCommand || null); trackRecipeSlashCommandSet(true, action); toastSuccess({ title: intl.formatMessage(i18n.slashCommandSavedTitle), - msg: slashCommand ? intl.formatMessage(i18n.slashCommandSavedMsg, { command: slashCommand }) : intl.formatMessage(i18n.slashCommandRemovedMsg), + msg: slashCommand + ? intl.formatMessage(i18n.slashCommandSavedMsg, { command: slashCommand }) + : intl.formatMessage(i18n.slashCommandRemovedMsg), }); setShowSlashCommandDialog(false); @@ -659,12 +642,7 @@ export default function RecipesView() { if (!slashCommandRecipeManifest) return; try { - await setRecipeSlashCommand({ - body: { - id: slashCommandRecipeManifest.id, - slash_command: null, - }, - }); + await setRecipeSlashCommand(slashCommandRecipeManifest.id, null); trackRecipeSlashCommandSet(true, 'remove'); toastSuccess({ @@ -736,7 +714,11 @@ export default function RecipesView() { variant={slash_command ? 'default' : 'outline'} size="sm" className="h-8 w-8 p-0" - title={slash_command ? intl.formatMessage(i18n.editSlashCommand) : intl.formatMessage(i18n.addSlashCommand)} + title={ + slash_command + ? intl.formatMessage(i18n.editSlashCommand) + : intl.formatMessage(i18n.addSlashCommand) + } > @@ -813,7 +795,11 @@ export default function RecipesView() { variant={schedule_cron ? 'default' : 'outline'} size="sm" className="h-8 w-8 p-0" - title={schedule_cron ? intl.formatMessage(i18n.editSchedule) : intl.formatMessage(i18n.addSchedule)} + title={ + schedule_cron + ? intl.formatMessage(i18n.editSchedule) + : intl.formatMessage(i18n.addSchedule) + } > @@ -886,7 +872,9 @@ export default function RecipesView() { return (

{intl.formatMessage(i18n.noSavedRecipes)}

-

{intl.formatMessage(i18n.noSavedRecipesDescription)}

+

+ {intl.formatMessage(i18n.noSavedRecipesDescription)} +

); } @@ -942,7 +930,10 @@ export default function RecipesView() {
- setSearchTerm(term)} placeholder={intl.formatMessage(i18n.searchRecipesPlaceholder)}> + setSearchTerm(term)} + placeholder={intl.formatMessage(i18n.searchRecipesPlaceholder)} + >
- {intl.formatMessage(i18n.scheduleDialogTitle, { action: scheduleRecipeManifest.schedule_cron ? 'Edit' : 'Add' })} + {intl.formatMessage(i18n.scheduleDialogTitle, { + action: scheduleRecipeManifest.schedule_cron ? 'Edit' : 'Add', + })}
diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index 031eb2db96c2..fa44b634a1b2 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -1,10 +1,10 @@ -import { - encodeRecipe as apiEncodeRecipe, - decodeRecipe as apiDecodeRecipe, - scanRecipe as apiScanRecipe, - parseRecipe as apiParseRecipe, -} from '../api'; import type { RecipeParameter } from '../api'; +import { + decodeRecipe as acpDecodeRecipe, + encodeRecipe as acpEncodeRecipe, + parseRecipe as acpParseRecipe, + scanRecipe as acpScanRecipe, +} from '../acp/recipe'; // Re-export OpenAPI types with frontend-specific additions export type Parameter = RecipeParameter; @@ -17,15 +17,7 @@ export type Recipe = import('../api').Recipe & { export async function encodeRecipe(recipe: Recipe): Promise { try { - const response = await apiEncodeRecipe({ - body: { recipe }, - }); - - if (!response.data) { - throw new Error('No data returned from API'); - } - - return response.data.deeplink; + return await acpEncodeRecipe(recipe); } catch (error) { console.error('Failed to encode recipe:', error); throw error; @@ -33,22 +25,8 @@ export async function encodeRecipe(recipe: Recipe): Promise { } export async function decodeRecipe(deeplink: string): Promise { - try { - const response = await apiDecodeRecipe({ - body: { deeplink }, - }); - - if (!response.data) { - throw new Error('No data returned from API'); - } - - if (!response.data.recipe) { - console.error('Decoded recipe is null:', response.data); - throw new Error('Decoded recipe is null'); - } - - return stripEmptyExtensions(response.data.recipe as Recipe); + return stripEmptyExtensions(await acpDecodeRecipe(deeplink)); } catch (error) { console.error('Failed to decode deeplink:', error); throw error; @@ -57,15 +35,7 @@ export async function decodeRecipe(deeplink: string): Promise { export async function scanRecipe(recipe: Recipe): Promise<{ has_security_warnings: boolean }> { try { - const response = await apiScanRecipe({ - body: { recipe }, - }); - - if (!response.data) { - throw new Error('No data returned from API'); - } - - return response.data; + return await acpScanRecipe(recipe); } catch (error) { console.error('Failed to scan recipe:', error); throw error; @@ -100,16 +70,7 @@ export function stripEmptyExtensions(recipe: Recipe): Recipe { export async function parseRecipeFromFile(fileContent: string): Promise { try { - const response = await apiParseRecipe({ - body: { content: fileContent }, - throwOnError: true, - }); - - if (!response.data?.recipe) { - throw new Error('No recipe returned from API'); - } - - return response.data.recipe as Recipe; + return await acpParseRecipe(fileContent); } catch (error) { let errorMessage = 'unknown error'; if (typeof error === 'object' && error !== null && 'message' in error) { diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index e37791abcf82..0e705352d828 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -1,4 +1,12 @@ -import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifest } from '../api'; +import type { Recipe, RecipeManifest } from '../api'; +import { + deleteRecipe as acpDeleteRecipe, + listRecipes as acpListRecipes, + recipeToYaml as acpRecipeToYaml, + saveRecipe as acpSaveRecipe, + scheduleRecipe as acpScheduleRecipe, + setRecipeSlashCommand as acpSetRecipeSlashCommand, +} from '../acp/recipe'; import { stripEmptyExtensions } from '.'; export const saveRecipe = async ( @@ -6,17 +14,11 @@ export const saveRecipe = async ( recipeId?: string | null ): Promise<{ id: string; fileName: string; filePath: string }> => { try { - const response = await saveRecipeApi({ - body: { - recipe: stripEmptyExtensions(recipe), - id: recipeId, - }, - throwOnError: true, - }); + const response = await acpSaveRecipe(stripEmptyExtensions(recipe), recipeId); return { - id: response.data.id, - fileName: response.data.file_name, - filePath: response.data.file_path, + id: response.id, + fileName: response.file_name, + filePath: response.file_path, }; } catch (error) { let error_message = 'unknown error'; @@ -29,14 +31,32 @@ export const saveRecipe = async ( export const listSavedRecipes = async (): Promise => { try { - const listRecipeResponse = await listRecipes(); - return listRecipeResponse?.data?.manifests ?? []; + return await acpListRecipes(); } catch (error) { console.warn('Failed to list saved recipes:', error); return []; } }; +export const deleteRecipe = async (id: string): Promise => { + await acpDeleteRecipe(id); +}; + +export const scheduleRecipe = async (id: string, cronSchedule?: string | null): Promise => { + await acpScheduleRecipe(id, cronSchedule); +}; + +export const setRecipeSlashCommand = async ( + id: string, + slashCommand?: string | null +): Promise => { + await acpSetRecipeSlashCommand(id, slashCommand); +}; + +export const recipeToYaml = async (recipe: Recipe): Promise => { + return await acpRecipeToYaml(recipe); +}; + const parseLastModified = (val: string | Date): Date => { return val instanceof Date ? val : new Date(val); }; From 77fdb786ec59ead243965558a6dae57c62f9441c Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 14:32:24 +1000 Subject: [PATCH 04/10] ui to use acp methods and types --- ui/desktop/package.json | 3 +- ui/desktop/src/acp/__tests__/recipe.test.ts | 4 +- ui/desktop/src/acp/recipe.ts | 46 ++--- ui/desktop/src/acp/sessions.ts | 5 +- ui/desktop/src/components/BaseChat.tsx | 7 +- .../recipes/CreateEditRecipeModal.tsx | 46 +++-- .../src/components/recipes/RecipesView.tsx | 2 +- .../shared/RecipeExtensionSelector.tsx | 82 +++++++-- .../recipes/shared/RecipeFormFields.tsx | 19 ++- .../recipes/shared/recipeFormSchema.ts | 8 +- ui/desktop/src/recipe/index.ts | 18 +- ui/desktop/src/recipe/recipe_management.ts | 2 +- ui/desktop/src/recipe/validation.test.ts | 13 ++ ui/desktop/src/recipe/validation.ts | 160 ++---------------- ui/desktop/src/schedule.ts | 5 +- ui/desktop/src/utils/navigationUtils.ts | 2 +- ui/pnpm-lock.yaml | 3 + 17 files changed, 189 insertions(+), 236 deletions(-) diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 405ccfc57054..612bc8ca227e 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -105,7 +105,8 @@ "tw-animate-css": "^1.4.0", "unist-util-visit": "^5.1.0", "uuid": "^13.0.0", - "zod": "^3.25.76" + "zod": "^3.25.76", + "zod-to-json-schema": "3.25.1" }, "devDependencies": { "@electron-forge/cli": "^7.11.1", diff --git a/ui/desktop/src/acp/__tests__/recipe.test.ts b/ui/desktop/src/acp/__tests__/recipe.test.ts index 040937689165..2719fdbcba36 100644 --- a/ui/desktop/src/acp/__tests__/recipe.test.ts +++ b/ui/desktop/src/acp/__tests__/recipe.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Recipe } from '../../api'; +import type { RecipeDto } from '@aaif/goose-sdk'; import { getAcpClient } from '../acpConnection'; import { decodeRecipe, @@ -22,7 +22,7 @@ const recipe = { title: 'Test Recipe', description: 'A recipe used by ACP tests', instructions: 'Follow these test instructions', -} as Recipe; +} as RecipeDto; function createClient() { return { diff --git a/ui/desktop/src/acp/recipe.ts b/ui/desktop/src/acp/recipe.ts index 61773c5dbdd3..9df60bb28b09 100644 --- a/ui/desktop/src/acp/recipe.ts +++ b/ui/desktop/src/acp/recipe.ts @@ -1,27 +1,11 @@ import type { RecipeDto, - RecipeListEntryDto, SaveRecipeResponse_unstable, ScanRecipeResponse_unstable, + RecipeListEntryDto, } from '@aaif/goose-sdk'; -import type { Recipe, RecipeManifest } from '../api'; import { getAcpClient } from './acpConnection'; -function asAcpRecipe(recipe: Recipe): RecipeDto { - return recipe as unknown as RecipeDto; -} - -function asDesktopRecipe(recipe: RecipeDto): Recipe { - return recipe as unknown as Recipe; -} - -function asDesktopRecipeManifest(entry: RecipeListEntryDto): RecipeManifest { - return { - ...entry, - recipe: asDesktopRecipe(entry.recipe), - }; -} - function acpErrorMessage(error: unknown): string | null { if (typeof error !== 'object' || error === null) { return null; @@ -42,53 +26,53 @@ function normalizeAcpError(error: unknown, fallback: string): Error { return new Error(acpErrorMessage(error) ?? fallback); } -export async function encodeRecipe(recipe: Recipe): Promise { +export async function encodeRecipe(recipe: RecipeDto): Promise { try { const client = await getAcpClient(); - const response = await client.goose.recipesEncode_unstable({ recipe: asAcpRecipe(recipe) }); + const response = await client.goose.recipesEncode_unstable({ recipe }); return response.deeplink; } catch (error) { throw normalizeAcpError(error, 'Failed to encode recipe'); } } -export async function decodeRecipe(deeplink: string): Promise { +export async function decodeRecipe(deeplink: string): Promise { try { const client = await getAcpClient(); const response = await client.goose.recipesDecode_unstable({ deeplink }); - return asDesktopRecipe(response.recipe); + return response.recipe; } catch (error) { throw normalizeAcpError(error, 'Failed to decode recipe'); } } -export async function scanRecipe(recipe: Recipe): Promise { +export async function scanRecipe(recipe: RecipeDto): Promise { try { const client = await getAcpClient(); - return await client.goose.recipesScan_unstable({ recipe: asAcpRecipe(recipe) }); + return await client.goose.recipesScan_unstable({ recipe }); } catch (error) { throw normalizeAcpError(error, 'Failed to scan recipe'); } } -export async function parseRecipe(content: string): Promise { +export async function parseRecipe(content: string): Promise { try { const client = await getAcpClient(); const response = await client.goose.recipesParse_unstable({ content }); - return asDesktopRecipe(response.recipe); + return response.recipe; } catch (error) { throw normalizeAcpError(error, 'Failed to parse recipe'); } } export async function saveRecipe( - recipe: Recipe, + recipe: RecipeDto, id?: string | null ): Promise { try { const client = await getAcpClient(); return await client.goose.recipesSave_unstable({ - recipe: asAcpRecipe(recipe), + recipe, id, }); } catch (error) { @@ -96,11 +80,11 @@ export async function saveRecipe( } } -export async function listRecipes(): Promise { +export async function listRecipes(): Promise { try { const client = await getAcpClient(); const response = await client.goose.recipesList_unstable({}); - return response.recipes.map(asDesktopRecipeManifest); + return response.recipes; } catch (error) { throw normalizeAcpError(error, 'Failed to list recipes'); } @@ -136,10 +120,10 @@ export async function setRecipeSlashCommand( } } -export async function recipeToYaml(recipe: Recipe): Promise { +export async function recipeToYaml(recipe: RecipeDto): Promise { try { const client = await getAcpClient(); - const response = await client.goose.recipesToYaml_unstable({ recipe: asAcpRecipe(recipe) }); + const response = await client.goose.recipesToYaml_unstable({ recipe }); return response.yaml; } catch (error) { throw normalizeAcpError(error, 'Failed to convert recipe to YAML'); diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index 4dee3fed161b..001c99a9fccb 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -8,7 +8,8 @@ import type { import type { GooseExtension } from '@aaif/goose-sdk'; import { getAcpClient } from './acpConnection'; import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext'; -import type { ExtensionLoadResult, Recipe, Session } from '../api'; +import type { ExtensionLoadResult, Session } from '../api'; +import type { Recipe } from '../recipe'; interface GooseSessionInfoMeta { messageCount?: number; @@ -100,7 +101,7 @@ export function sessionInfoToSession(s: SessionInfo, loadMeta: LoadSessionMeta = provider_name: meta.providerId, model_config: modelConfig, session_type: meta.sessionType, - recipe: loadMeta.recipe, + recipe: loadMeta.recipe as Session['recipe'], user_recipe_values: loadMeta.userRecipeValues, user_set_name: meta.userSetName, last_message_snippet: meta.lastMessageSnippet, diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 0b27450b1afe..9519945bb746 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -23,6 +23,7 @@ import { useNavigation } from '../hooks/useNavigation'; import { RecipeHeader } from './RecipeHeader'; import { RecipeWarningModal } from './ui/RecipeWarningModal'; import { scanRecipe } from '../recipe'; +import type { Recipe } from '../recipe'; import { UserInput } from '../types/message'; import RecipeActivities from './recipes/RecipeActivities'; import { useToolCount } from './alerts/useToolCount'; @@ -127,7 +128,7 @@ export default function BaseChat({ [session, sessionId, updateSession] ); - const recipe = session?.recipe; + const recipe = session?.recipe as Recipe | null | undefined; const resolvedInitialMessage = useMemo((): UserInput | undefined => { if (!initialMessage) return undefined; @@ -246,9 +247,7 @@ export default function BaseChat({ if (sessionId) { try { await acpDeleteSession(sessionId); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId } }) - ); + window.dispatchEvent(new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId } })); } catch (error) { console.error('Failed to delete declined recipe session:', error); } diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx index 1e0d8fe838c7..dece98fc74f6 100644 --- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx +++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx @@ -1,12 +1,11 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useForm } from '@tanstack/react-form'; -import { Recipe, generateDeepLink, Parameter } from '../../recipe'; +import { generateDeepLink } from '../../recipe'; +import type { Recipe, Parameter, RecipeExtension, RecipeSettings } from '../../recipe'; import { Check, ExternalLink, Play, Save, X } from 'lucide-react'; import { Geese } from '../icons/Geese'; import Copy from '../icons/Copy'; -import { ExtensionConfig } from '../ConfigContext'; import { Button } from '../ui/button'; -import type { Settings } from '../../api'; import { RecipeFormFields } from './shared/RecipeFormFields'; import { RecipeFormData } from './shared/recipeFormSchema'; @@ -26,11 +25,13 @@ const i18n = defineMessages({ }, createSubtitle: { id: 'createEditRecipe.createSubtitle', - defaultMessage: 'Create a new recipe to define agent behavior and capabilities for reusable chat sessions.', + defaultMessage: + 'Create a new recipe to define agent behavior and capabilities for reusable chat sessions.', }, editSubtitle: { id: 'createEditRecipe.editSubtitle', - defaultMessage: "You can edit the recipe below to change the agent's behavior in a new session.", + defaultMessage: + "You can edit the recipe below to change the agent's behavior in a new session.", }, learnMore: { id: 'createEditRecipe.learnMore', @@ -258,13 +259,24 @@ export default function CreateEditRecipeModal({ : undefined; const cleanedExtensions = extensions?.map( - (extension: ExtensionConfig & { envs?: unknown; enabled?: boolean }) => { - const { envs: _envs, enabled: _enabled, ...rest } = extension; + ( + extension: RecipeExtension & { + envs?: unknown; + enabled?: boolean; + available_tools?: unknown; + } + ) => { + const { + envs: _envs, + enabled: _enabled, + available_tools: _availableTools, + ...rest + } = extension; return rest; } - ) as ExtensionConfig[] | undefined; + ) as RecipeExtension[] | undefined; - const mergedSettings: Settings = { + const mergedSettings: RecipeSettings = { ...(recipe?.settings || {}), }; if (model !== undefined) { @@ -433,7 +445,9 @@ export default function CreateEditRecipeModal({ toastError({ title: intl.formatMessage(i18n.saveFailed), - msg: intl.formatMessage(i18n.saveFailedMsg, { error: errorMessage(error, 'Unknown error') }), + msg: intl.formatMessage(i18n.saveFailedMsg, { + error: errorMessage(error, 'Unknown error'), + }), traceback: errorMessage(error), }); } finally { @@ -469,7 +483,9 @@ export default function CreateEditRecipeModal({ toastError({ title: intl.formatMessage(i18n.saveAndRunFailed), - msg: intl.formatMessage(i18n.saveAndRunFailedMsg, { error: errorMessage(error, 'Unknown error') }), + msg: intl.formatMessage(i18n.saveAndRunFailedMsg, { + error: errorMessage(error, 'Unknown error'), + }), traceback: errorMessage(error), }); } finally { @@ -490,7 +506,9 @@ export default function CreateEditRecipeModal({

- {isCreateMode ? intl.formatMessage(i18n.createRecipeTitle) : intl.formatMessage(i18n.viewEditRecipeTitle)} + {isCreateMode + ? intl.formatMessage(i18n.createRecipeTitle) + : intl.formatMessage(i18n.viewEditRecipeTitle)}

{isCreateMode @@ -589,7 +607,9 @@ export default function CreateEditRecipeModal({ className="inline-flex items-center justify-center gap-2 px-4 py-2" > - {isSaving ? intl.formatMessage(i18n.saving) : intl.formatMessage(i18n.saveAndRunRecipe)} + {isSaving + ? intl.formatMessage(i18n.saving) + : intl.formatMessage(i18n.saveAndRunRecipe)}

diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index 96a141e11b72..9b4689b785f5 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -7,6 +7,7 @@ import { scheduleRecipe, setRecipeSlashCommand, } from '../../recipe/recipe_management'; +import type { RecipeManifest } from '../../recipe'; import { FileText, Edit, @@ -29,7 +30,6 @@ import { Skeleton } from '../ui/skeleton'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; -import type { RecipeManifest } from '../../api'; import { createSession } from '../../sessions'; import { isRecipeParamsCancelled } from '../../acp/errors'; import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm'; diff --git a/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx b/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx index 381ad4206b2e..09f94987468d 100644 --- a/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx +++ b/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { ExtensionConfig } from '../../../api'; -import { useConfig } from '../../ConfigContext'; +import type { RecipeExtension } from '../../../recipe'; +import { useConfig, type FixedExtensionEntry } from '../../ConfigContext'; import { Input } from '../../ui/input'; import { Switch } from '../../ui/switch'; import { formatExtensionName } from '../../settings/extensions/subcomponents/ExtensionList'; @@ -13,7 +13,8 @@ const i18n = defineMessages({ }, description: { id: 'recipeExtensionSelector.description', - defaultMessage: 'Select which extensions should be available when running this recipe. Leave empty to use default extensions.', + defaultMessage: + 'Select which extensions should be available when running this recipe. Leave empty to use default extensions.', }, searchPlaceholder: { id: 'recipeExtensionSelector.searchPlaceholder', @@ -33,9 +34,57 @@ const i18n = defineMessages({ }, }); +type DisplayRecipeExtension = RecipeExtension & { + enabled?: boolean; +}; + +function toRecipeExtension( + extension: FixedExtensionEntry | DisplayRecipeExtension +): DisplayRecipeExtension | null { + const enabled = 'enabled' in extension ? extension.enabled : undefined; + + switch (extension.type) { + case 'builtin': { + const { name, description, display_name, timeout, bundled, type } = extension; + return { name, description, display_name, timeout, bundled, type, enabled }; + } + case 'platform': { + const { name, description, display_name, bundled, type } = extension; + return { name, description, display_name, bundled, type, enabled }; + } + case 'stdio': { + const { name, description, cmd, args, env_keys, timeout, cwd, bundled, type } = extension; + return { name, description, cmd, args, env_keys, timeout, cwd, bundled, type, enabled }; + } + case 'streamable_http': { + const { name, description, uri, env_keys, headers, timeout, socket, bundled, type } = + extension; + return { + name, + description, + uri, + env_keys, + headers, + timeout, + socket, + bundled, + type, + enabled, + }; + } + default: + return null; + } +} + +function removeDisplayFields(extension: DisplayRecipeExtension): RecipeExtension { + const { enabled: _enabled, ...recipeExtension } = extension; + return recipeExtension; +} + interface RecipeExtensionSelectorProps { - selectedExtensions: ExtensionConfig[]; - onExtensionsChange: (extensions: ExtensionConfig[]) => void; + selectedExtensions: RecipeExtension[]; + onExtensionsChange: (extensions: RecipeExtension[]) => void; } export const RecipeExtensionSelector = ({ @@ -48,7 +97,13 @@ export const RecipeExtensionSelector = ({ const selectedExtensionNames = new Set(selectedExtensions.map((ext) => ext.name)); - const extensionMap = new Map(allExtensions.map((ext) => [ext.name, ext])); + const extensionMap = new Map(); + allExtensions.forEach((extension) => { + const recipeExtension = toRecipeExtension(extension); + if (recipeExtension) { + extensionMap.set(recipeExtension.name, recipeExtension); + } + }); selectedExtensions.forEach((ext) => { if (!extensionMap.has(ext.name)) { @@ -58,16 +113,13 @@ export const RecipeExtensionSelector = ({ const displayExtensions = Array.from(extensionMap.values()); - const handleToggle = (extensionConfig: ExtensionConfig) => { + const handleToggle = (extensionConfig: DisplayRecipeExtension) => { const isSelected = selectedExtensionNames.has(extensionConfig.name); if (isSelected) { onExtensionsChange(selectedExtensions.filter((ext) => ext.name !== extensionConfig.name)); } else { - const { enabled: _enabled, ...cleanExtension } = extensionConfig as ExtensionConfig & { - enabled?: boolean; - }; - onExtensionsChange([...selectedExtensions, cleanExtension]); + onExtensionsChange([...selectedExtensions, removeDisplayFields(extensionConfig)]); } }; @@ -96,9 +148,7 @@ export const RecipeExtensionSelector = ({ -

- {intl.formatMessage(i18n.description)} -

+

{intl.formatMessage(i18n.description)}

{sortedExtensions.length === 0 ? (
- {searchQuery ? intl.formatMessage(i18n.noExtensionsFound) : intl.formatMessage(i18n.noExtensionsAvailable)} + {searchQuery + ? intl.formatMessage(i18n.noExtensionsFound) + : intl.formatMessage(i18n.noExtensionsAvailable)}
) : ( sortedExtensions.map((ext) => { diff --git a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx index af2a37040c56..757e1ffd6a23 100644 --- a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx +++ b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx @@ -1,7 +1,6 @@ import React, { useState } from 'react'; -import { Parameter } from '../../../recipe'; +import type { Parameter, RecipeExtension } from '../../../recipe'; import { ChevronDown } from 'lucide-react'; -import { ExtensionConfig } from '../../../api'; import { defineMessages, useIntl } from '../../../i18n'; const i18n = defineMessages({ @@ -35,7 +34,8 @@ const i18n = defineMessages({ }, templateVarHint: { id: 'recipeFormFields.templateVarHint', - defaultMessage: "Use '{{parameter_name}}' to define parameters that can be filled in when running the recipe.", + defaultMessage: + "Use '{{parameter_name}}' to define parameters that can be filled in when running the recipe.", }, initialPrompt: { id: 'recipeFormFields.initialPrompt', @@ -63,7 +63,8 @@ const i18n = defineMessages({ }, parametersDescription: { id: 'recipeFormFields.parametersDescription', - defaultMessage: "Parameters will be automatically detected from '{{parameter_name}}' syntax in instructions/prompt/activities or you can manually add them below.", + defaultMessage: + "Parameters will be automatically detected from '{{parameter_name}}' syntax in instructions/prompt/activities or you can manually add them below.", }, parameterNamePlaceholder: { id: 'recipeFormFields.parameterNamePlaceholder', @@ -417,7 +418,9 @@ export function RecipeFormFields({ advancedOpen ? 'rotate-0' : '-rotate-90' }`} /> - {intl.formatMessage(i18n.advancedOptions)} + + {intl.formatMessage(i18n.advancedOptions)} + {intl.formatMessage(i18n.advancedOptionsHint)} @@ -444,7 +447,9 @@ export function RecipeFormFields({ if (newParameterName.trim()) { const newParam: Parameter = { key: newParameterName.trim(), - description: intl.formatMessage(i18n.enterValueFor, { key: newParameterName.trim() }), + description: intl.formatMessage(i18n.enterValueFor, { + key: newParameterName.trim(), + }), input_type: 'string', requirement: 'required', }; @@ -572,7 +577,7 @@ export function RecipeFormFields({ {/* Extensions Field */} - {(field: FormFieldApi) => ( + {(field: FormFieldApi) => ( diff --git a/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts b/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts index 4a27562632f0..bd29294f8cf1 100644 --- a/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts +++ b/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { ExtensionConfig } from '../../../api'; +import type { RecipeExtension } from '../../../recipe'; -// Zod schema for Parameter - matching API RecipeParameter type +// Zod schema for Parameter - matching ACP RecipeParameterDto type const parameterSchema = z.object({ key: z.string().min(1, 'Parameter key is required'), input_type: z.enum(['string', 'number', 'boolean', 'date', 'file', 'select']), @@ -14,7 +14,7 @@ const parameterSchema = z.object({ // Export the parameter type for use in components export type RecipeParameter = z.infer; -// Zod schema for SubRecipe - matching API SubRecipe type +// Zod schema for SubRecipe - matching ACP SubRecipeDto type const subRecipeSchema = z.object({ name: z.string().min(1, 'Subrecipe name is required'), path: z.string().min(1, 'Subrecipe path is required'), @@ -56,7 +56,7 @@ export const recipeFormSchema = z.object({ provider: z.string().optional(), - extensions: z.array(z.custom()).optional(), + extensions: z.array(z.custom()).optional(), subRecipes: z.array(subRecipeSchema).default([]), }); diff --git a/ui/desktop/src/recipe/index.ts b/ui/desktop/src/recipe/index.ts index fa44b634a1b2..b00a047efbda 100644 --- a/ui/desktop/src/recipe/index.ts +++ b/ui/desktop/src/recipe/index.ts @@ -1,4 +1,10 @@ -import type { RecipeParameter } from '../api'; +import type { + RecipeDto, + RecipeExtensionDto, + RecipeListEntryDto, + RecipeParameterDto, + RecipeSettingsDto, +} from '@aaif/goose-sdk'; import { decodeRecipe as acpDecodeRecipe, encodeRecipe as acpEncodeRecipe, @@ -6,14 +12,18 @@ import { scanRecipe as acpScanRecipe, } from '../acp/recipe'; -// Re-export OpenAPI types with frontend-specific additions -export type Parameter = RecipeParameter; -export type Recipe = import('../api').Recipe & { +export type Parameter = RecipeParameterDto; +export type RecipeExtension = RecipeExtensionDto; +export type RecipeSettings = RecipeSettingsDto; +export type Recipe = RecipeDto & { // TODO: Separate these from the raw recipe type // Properties added for scheduled execution scheduledJobId?: string; isScheduledExecution?: boolean; }; +export type RecipeManifest = Omit & { + recipe: Recipe; +}; export async function encodeRecipe(recipe: Recipe): Promise { try { diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index 0e705352d828..63097a472c64 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -1,4 +1,3 @@ -import type { Recipe, RecipeManifest } from '../api'; import { deleteRecipe as acpDeleteRecipe, listRecipes as acpListRecipes, @@ -8,6 +7,7 @@ import { setRecipeSlashCommand as acpSetRecipeSlashCommand, } from '../acp/recipe'; import { stripEmptyExtensions } from '.'; +import type { Recipe, RecipeManifest } from '.'; export const saveRecipe = async ( recipe: Recipe, diff --git a/ui/desktop/src/recipe/validation.test.ts b/ui/desktop/src/recipe/validation.test.ts index b56c322b2294..35f51fd73d1f 100644 --- a/ui/desktop/src/recipe/validation.test.ts +++ b/ui/desktop/src/recipe/validation.test.ts @@ -28,5 +28,18 @@ describe('Recipe Validation', () => { expect(schema1).toEqual(schema2); }); + + it('documents only ACP-supported recipe extension variants', () => { + const schemaJson = JSON.stringify(getRecipeJsonSchema()); + + expect(schemaJson).toContain('builtin'); + expect(schemaJson).toContain('platform'); + expect(schemaJson).toContain('stdio'); + expect(schemaJson).toContain('streamable_http'); + expect(schemaJson).not.toContain('sse'); + expect(schemaJson).not.toContain('frontend'); + expect(schemaJson).not.toContain('inline_python'); + expect(schemaJson).not.toContain('available_tools'); + }); }); }); diff --git a/ui/desktop/src/recipe/validation.ts b/ui/desktop/src/recipe/validation.ts index 431cb4ce5b03..299fe63e95cc 100644 --- a/ui/desktop/src/recipe/validation.ts +++ b/ui/desktop/src/recipe/validation.ts @@ -1,157 +1,21 @@ -/** - * OpenAPI-based validation utilities for Recipe objects. - * - * This module uses the generated OpenAPI specification directly for validation, - * ensuring automatic synchronization with backend schema changes. - * Zod schemas are generated dynamically from the OpenAPI spec. - */ +import { zRecipeDto } from '@aaif/goose-sdk'; +import { zodToJsonSchema } from 'zod-to-json-schema'; -// Import the OpenAPI spec directly for schema extraction -import openApiSpec from '../../openapi.json'; +type JsonSchema = Record; -// Extract the Recipe schema from OpenAPI components -function getRecipeSchema() { - return openApiSpec.components?.schemas?.Recipe; -} - -/** - * Resolves $ref references in OpenAPI schemas by expanding them with the actual schema definitions - */ -function resolveRefs( - schema: Record, - openApiSpec: Record -): Record { - if (!schema || typeof schema !== 'object') { - return schema; - } - - // Handle $ref - if (typeof schema.$ref === 'string') { - const refPath = schema.$ref.replace('#/', '').split('/'); - let resolved: unknown = openApiSpec; +const recipeDescription = + 'A Recipe represents a reusable agent configuration with instructions, optional prompt, parameters, supported extensions, settings, and subrecipes.'; - for (const segment of refPath) { - if (resolved && typeof resolved === 'object' && segment in resolved) { - resolved = (resolved as Record)[segment]; - } else { - console.warn(`Could not resolve $ref: ${schema.$ref}`); - return schema; // Return original if can't resolve - } - } - - if (resolved && typeof resolved === 'object') { - // Recursively resolve refs in the resolved schema - return resolveRefs(resolved as Record, openApiSpec); - } - - return schema; - } +let recipeJsonSchema: JsonSchema | null = null; - // Handle allOf (merge schemas) - if (Array.isArray(schema.allOf)) { - const merged: Record = {}; - for (const subSchema of schema.allOf) { - if (typeof subSchema === 'object' && subSchema !== null) { - const resolved = resolveRefs(subSchema as Record, openApiSpec); - Object.assign(merged, resolved); - } - } - // Keep other properties from the original schema - const { allOf: _allOf, ...rest } = schema; - return { ...merged, ...rest }; - } - - // Handle oneOf/anyOf (keep as union) - if (Array.isArray(schema.oneOf)) { - return { - ...schema, - oneOf: schema.oneOf.map((subSchema) => - typeof subSchema === 'object' && subSchema !== null - ? resolveRefs(subSchema as Record, openApiSpec) - : subSchema - ), - }; - } - - if (Array.isArray(schema.anyOf)) { - return { - ...schema, - anyOf: schema.anyOf.map((subSchema) => - typeof subSchema === 'object' && subSchema !== null - ? resolveRefs(subSchema as Record, openApiSpec) - : subSchema - ), - }; - } - - // Handle object properties - if (schema.type === 'object' && schema.properties && typeof schema.properties === 'object') { - const resolvedProperties: Record = {}; - for (const [key, value] of Object.entries(schema.properties)) { - if (typeof value === 'object' && value !== null) { - resolvedProperties[key] = resolveRefs(value as Record, openApiSpec); - } else { - resolvedProperties[key] = value; - } - } - return { - ...schema, - properties: resolvedProperties, - }; - } - - // Handle array items - if (schema.type === 'array' && schema.items && typeof schema.items === 'object') { - return { - ...schema, - items: resolveRefs(schema.items as Record, openApiSpec), - }; - } - - // Return schema as-is if no refs to resolve - return schema; -} - -/** - * Returns a JSON schema representation derived directly from the OpenAPI specification. - * This schema is used for documentation in form help text. - * - * This function extracts the Recipe schema from the OpenAPI spec and converts it - * to a standard JSON Schema format, ensuring it stays in sync with backend changes. - * - * All $ref references are automatically resolved and expanded. - */ -export function getRecipeJsonSchema() { - const recipeSchema = getRecipeSchema(); - - if (!recipeSchema) { - // Fallback minimal schema if OpenAPI schema is not available - return { - $schema: 'http://json-schema.org/draft-07/schema#', - type: 'object', +export function getRecipeJsonSchema(): JsonSchema { + if (!recipeJsonSchema) { + recipeJsonSchema = { + ...(zodToJsonSchema(zRecipeDto, { $refStrategy: 'none' }) as JsonSchema), title: 'Recipe', - description: 'Recipe schema not found in OpenAPI specification', - required: ['title', 'description'], - properties: { - title: { type: 'string' }, - description: { type: 'string' }, - }, + description: recipeDescription, }; } - // Resolve all $refs in the schema - const resolvedSchema = resolveRefs( - recipeSchema as Record, - openApiSpec as Record - ); - - // Convert OpenAPI schema to JSON Schema format - return { - $schema: 'http://json-schema.org/draft-07/schema#', - ...resolvedSchema, - title: resolvedSchema.title || 'Recipe', - description: - resolvedSchema.description || - 'A Recipe represents a personalized, user-generated agent configuration that defines specific behaviors and capabilities within the Goose system.', - }; + return recipeJsonSchema; } diff --git a/ui/desktop/src/schedule.ts b/ui/desktop/src/schedule.ts index b5a72e0e979a..c6d7f2e9274e 100644 --- a/ui/desktop/src/schedule.ts +++ b/ui/desktop/src/schedule.ts @@ -11,7 +11,7 @@ import { inspectRunningJob as apiInspectRunningJob, SessionDisplayInfo, } from './api'; -import type { Recipe } from './api'; +import type { Recipe } from './recipe'; export interface ScheduledJob { id: string; @@ -58,7 +58,8 @@ export async function createSchedule(request: { recipe: Recipe; cron: string; }): Promise { - const response = await apiCreateSchedule({ body: request }); + type ApiCreateScheduleBody = Parameters[0]['body']; + const response = await apiCreateSchedule({ body: request as unknown as ApiCreateScheduleBody }); if (response.data) { return response.data as ScheduledJob; } diff --git a/ui/desktop/src/utils/navigationUtils.ts b/ui/desktop/src/utils/navigationUtils.ts index e98733bb1e5f..a38bdfe91833 100644 --- a/ui/desktop/src/utils/navigationUtils.ts +++ b/ui/desktop/src/utils/navigationUtils.ts @@ -1,5 +1,5 @@ import { NavigateFunction } from 'react-router-dom'; -import { Recipe } from '../api'; +import type { Recipe } from '../recipe'; import { UserInput } from '../types/message'; export type View = diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 7edfc2ce7d7c..a68cccd85bbb 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 + zod-to-json-schema: + specifier: 3.25.1 + version: 3.25.1(zod@3.25.76) devDependencies: '@electron-forge/cli': specifier: ^7.11.1 From b58d7fbbde534f736eb981204314ad4ea92b0ad9 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 14:44:28 +1000 Subject: [PATCH 05/10] clean up schema --- .../src/custom_requests/recipe.rs | 26 +++++++ crates/goose/acp-schema.json | 67 +------------------ crates/goose/src/acp/server/agent_requests.rs | 7 +- crates/goose/src/acp/server/recipe/mod.rs | 37 +++------- crates/goose/src/recipe/mod.rs | 6 +- ui/desktop/src/acp/recipeParamRequests.ts | 4 +- ui/sdk/src/generated/index.ts | 2 +- ui/sdk/src/generated/types.gen.ts | 15 +---- ui/sdk/src/generated/zod.gen.ts | 32 +-------- 9 files changed, 47 insertions(+), 149 deletions(-) diff --git a/crates/goose-sdk-types/src/custom_requests/recipe.rs b/crates/goose-sdk-types/src/custom_requests/recipe.rs index cf9b838ae022..73c2df8f708a 100644 --- a/crates/goose-sdk-types/src/custom_requests/recipe.rs +++ b/crates/goose-sdk-types/src/custom_requests/recipe.rs @@ -10,6 +10,8 @@ fn default_recipe_version() -> String { "1.0.0".to_string() } +pub const REQUEST_RECIPE_PARAMS_METHOD: &str = "_goose/unstable/session/recipe/request-params"; + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct RecipeDto { #[serde(default = "default_recipe_version")] @@ -237,6 +239,30 @@ pub struct RecipeListEntryDto { pub slash_command: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RequestRecipeParams { + pub session_id: String, + pub parameters: Vec, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum RecipeParamsAction { + #[default] + Submit, + Cancel, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct RecipeParamsResponse { + #[serde(default)] + pub action: RecipeParamsAction, + #[serde(default)] + pub values: HashMap, +} + #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request( method = "_goose/unstable/recipes/encode", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 417ac5f542ff..68b0c455b69c 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -4481,7 +4481,7 @@ "parameters": { "type": "array", "items": { - "$ref": "#/$defs/RecipeParameter" + "$ref": "#/$defs/RecipeParameterDto" } } }, @@ -4492,71 +4492,6 @@ "x-side": "client", "x-method": "_goose/unstable/session/recipe/request-params" }, - "RecipeParameter": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "input_type": { - "$ref": "#/$defs/RecipeParameterInputType" - }, - "requirement": { - "$ref": "#/$defs/RecipeParameterRequirement" - }, - "description": { - "type": "string" - }, - "default": { - "type": [ - "string", - "null" - ] - }, - "options": { - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - } - }, - "required": [ - "key", - "input_type", - "requirement", - "description" - ] - }, - "RecipeParameterInputType": { - "oneOf": [ - { - "type": "string", - "enum": [ - "string", - "number", - "boolean", - "date", - "select" - ] - }, - { - "type": "string", - "const": "file", - "description": "File parameter that imports content from a file path.\nCannot have default values to prevent importing sensitive user files." - } - ] - }, - "RecipeParameterRequirement": { - "type": "string", - "enum": [ - "required", - "optional", - "user_prompt" - ] - }, "RecipeParamsResponse_unstable": { "type": "object", "properties": { diff --git a/crates/goose/src/acp/server/agent_requests.rs b/crates/goose/src/acp/server/agent_requests.rs index 4611ed405486..98bb9adaf8a0 100644 --- a/crates/goose/src/acp/server/agent_requests.rs +++ b/crates/goose/src/acp/server/agent_requests.rs @@ -7,12 +7,13 @@ //! params/response types (deriving `JsonSchema`) next to the feature that sends //! it, then add one line to [`agent_request_schemas`]. +use goose_sdk_types::custom_requests::{ + RecipeParamsResponse, RequestRecipeParams, REQUEST_RECIPE_PARAMS_METHOD, +}; use schemars::{JsonSchema, SchemaGenerator}; use crate::acp::custom_requests::CustomMethodSchema; -use super::recipe::{RecipeParamsResponse, RequestRecipeParams, RECIPE_PARAMS_METHOD}; - fn short_type_name() -> String { let full = std::any::type_name::(); full.rsplit("::").next().unwrap_or(full).to_string() @@ -43,5 +44,5 @@ pub fn agent_request_schemas(generator: &mut SchemaGenerator) -> Vec(generator, RECIPE_PARAMS_METHOD)] + >(generator, REQUEST_RECIPE_PARAMS_METHOD)] } diff --git a/crates/goose/src/acp/server/recipe/mod.rs b/crates/goose/src/acp/server/recipe/mod.rs index 53f7958f70db..9b493004f814 100644 --- a/crates/goose/src/acp/server/recipe/mod.rs +++ b/crates/goose/src/acp/server/recipe/mod.rs @@ -10,11 +10,11 @@ use fs_err as fs; use goose_sdk_types::custom_requests::{ DecodeRecipeRequest, DecodeRecipeResponse, DeleteRecipeRequest, EmptyResponse, EncodeRecipeRequest, EncodeRecipeResponse, ListRecipesRequest, ListRecipesResponse, - ParseRecipeRequest, ParseRecipeResponse, RecipeDto, RecipeToYamlRequest, RecipeToYamlResponse, + ParseRecipeRequest, ParseRecipeResponse, RecipeDto, RecipeParameterDto, RecipeParamsAction, + RecipeParamsResponse, RecipeToYamlRequest, RecipeToYamlResponse, RequestRecipeParams, SaveRecipeRequest, SaveRecipeResponse, ScanRecipeRequest, ScanRecipeResponse, - ScheduleRecipeRequest, SetRecipeSlashCommandRequest, + ScheduleRecipeRequest, SetRecipeSlashCommandRequest, REQUEST_RECIPE_PARAMS_METHOD, }; -use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; mod conversions; @@ -33,7 +33,7 @@ use crate::slash_commands::recipe_slash_command; use self::conversions::recipe_manifest_to_list_entry_dto; -pub(super) const RECIPE_PARAMS_METHOD: &str = "_goose/unstable/session/recipe/request-params"; +pub(super) const RECIPE_PARAMS_METHOD: &str = REQUEST_RECIPE_PARAMS_METHOD; pub(super) const RECIPE_PARAMS_CANCELLED_REASON: &str = "recipe_params_cancelled"; @@ -377,7 +377,10 @@ impl GooseAcpAgent { ) -> Result { let request = RequestRecipeParams { session_id: session_id.to_string(), - parameters, + parameters: parameters + .into_iter() + .map(RecipeParameterDto::from) + .collect(), }; let (tx, rx) = oneshot::channel(); cx.send_request(RequestRecipeParamsMessage(request)) @@ -443,30 +446,6 @@ fn recipe_to_dto(recipe: Recipe) -> Result, -} - -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "camelCase")] -pub(super) enum RecipeParamsAction { - #[default] - Submit, - Cancel, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "camelCase")] -pub(super) struct RecipeParamsResponse { - #[serde(default)] - action: RecipeParamsAction, - #[serde(default)] - values: HashMap, -} - #[derive(Debug, Clone)] struct RequestRecipeParamsMessage(RequestRecipeParams); diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index e051d4e4e36d..3de1b8dda65e 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -153,7 +153,7 @@ where } } -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema, schemars::JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] #[serde(rename_all = "snake_case")] pub enum RecipeParameterRequirement { Required, @@ -171,7 +171,7 @@ impl fmt::Display for RecipeParameterRequirement { } } -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema, schemars::JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] #[serde(rename_all = "snake_case")] pub enum RecipeParameterInputType { String, @@ -194,7 +194,7 @@ impl fmt::Display for RecipeParameterInputType { } } -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema, schemars::JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct RecipeParameter { pub key: String, pub input_type: RecipeParameterInputType, diff --git a/ui/desktop/src/acp/recipeParamRequests.ts b/ui/desktop/src/acp/recipeParamRequests.ts index e00328df3391..21f73cdb0aba 100644 --- a/ui/desktop/src/acp/recipeParamRequests.ts +++ b/ui/desktop/src/acp/recipeParamRequests.ts @@ -1,5 +1,5 @@ import type { - RecipeParameter, + RecipeParameterDto, RecipeParamsResponse_unstable, RequestRecipeParams_unstable, } from '@aaif/goose-sdk'; @@ -9,7 +9,7 @@ import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; export interface AcpRecipeParamRequest { id: string; sessionId: string; - parameters: RecipeParameter[]; + parameters: RecipeParameterDto[]; initialValues?: Record; } diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 35f8040d2f82..543ba4e95d54 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest_unstable, AddSessionExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DecodeRecipeRequest_unstable, DecodeRecipeResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteRecipeRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EncodeRecipeRequest_unstable, EncodeRecipeResponse_unstable, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtAgentRequest, ExtAgentResponse, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListRecipesRequest_unstable, ListRecipesResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, ParseRecipeRequest_unstable, ParseRecipeResponse_unstable, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RecipeAuthorDto, RecipeDto, RecipeExtensionDto, RecipeListEntryDto, RecipeParameter, RecipeParameterDto, RecipeParameterInputType, RecipeParameterInputTypeDto, RecipeParameterRequirement, RecipeParameterRequirementDto, RecipeParamsAction, RecipeParamsResponse_unstable, RecipeResponseDto, RecipeRetryConfigDto, RecipeSettingsDto, RecipeSuccessCheckDto, RecipeToYamlRequest_unstable, RecipeToYamlResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, RequestRecipeParams_unstable, ResourceLink, Role, SaveRecipeRequest_unstable, SaveRecipeResponse_unstable, ScanRecipeRequest_unstable, ScanRecipeResponse_unstable, ScheduleRecipeRequest_unstable, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetRecipeSlashCommandRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, SubRecipeDto, TextContent, TextResourceContents, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; +export type { AddConfigExtensionRequest_unstable, AddSessionExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DecodeRecipeRequest_unstable, DecodeRecipeResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteRecipeRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EncodeRecipeRequest_unstable, EncodeRecipeResponse_unstable, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtAgentRequest, ExtAgentResponse, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetSessionInfoRequest_unstable, GetSessionInfoResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListRecipesRequest_unstable, ListRecipesResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, ParseRecipeRequest_unstable, ParseRecipeResponse_unstable, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RecipeAuthorDto, RecipeDto, RecipeExtensionDto, RecipeListEntryDto, RecipeParameterDto, RecipeParameterInputTypeDto, RecipeParameterRequirementDto, RecipeParamsAction, RecipeParamsResponse_unstable, RecipeResponseDto, RecipeRetryConfigDto, RecipeSettingsDto, RecipeSuccessCheckDto, RecipeToYamlRequest_unstable, RecipeToYamlResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, RequestRecipeParams_unstable, ResourceLink, Role, SaveRecipeRequest_unstable, SaveRecipeResponse_unstable, ScanRecipeRequest_unstable, ScanRecipeResponse_unstable, ScheduleRecipeRequest_unstable, SessionId, SessionInfo, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetRecipeSlashCommandRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, SubRecipeDto, TextContent, TextResourceContents, TruncateSessionConversationRequest_unstable, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index b944a8b8f552..dbbe824d5cea 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -1785,22 +1785,9 @@ export type StatusMessageUpdate = { export type RequestRecipeParams_unstable = { sessionId: string; - parameters: Array; + parameters: Array; }; -export type RecipeParameter = { - key: string; - input_type: RecipeParameterInputType; - requirement: RecipeParameterRequirement; - description: string; - default?: string | null; - options?: Array | null; -}; - -export type RecipeParameterInputType = 'string' | 'number' | 'boolean' | 'date' | 'select' | 'file'; - -export type RecipeParameterRequirement = 'required' | 'optional' | 'user_prompt'; - export type RecipeParamsResponse_unstable = { action?: RecipeParamsAction; values?: { diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 1d4c577fd1af..07124a388915 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -1894,39 +1894,9 @@ export const zGooseSessionNotification_unstable = z.object({ update: zGooseSessionUpdate }); -export const zRecipeParameterInputType = z.union([ - z.literal('string'), - z.literal('number'), - z.literal('boolean'), - z.literal('date'), - z.literal('select'), - z.literal('file') -]); - -export const zRecipeParameterRequirement = z.enum([ - 'required', - 'optional', - 'user_prompt' -]); - -export const zRecipeParameter = z.object({ - key: z.string(), - input_type: zRecipeParameterInputType, - requirement: zRecipeParameterRequirement, - description: z.string(), - default: z.union([ - z.string(), - z.null() - ]).optional(), - options: z.union([ - z.array(z.string()), - z.null() - ]).optional() -}); - export const zRequestRecipeParams_unstable = z.object({ sessionId: z.string(), - parameters: z.array(zRecipeParameter) + parameters: z.array(zRecipeParameterDto) }); export const zRecipeParamsAction = z.enum(['submit', 'cancel']); From ef8a83a18f8c33fd2046a0b3ce673892cc0c9867 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 15:59:33 +1000 Subject: [PATCH 06/10] fixed stale goosed and stale vite files in local --- Justfile | 4 ++++ ui/desktop/vite.renderer.config.mts | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/Justfile b/Justfile index 017f0c509862..56f0fe330279 100644 --- a/Justfile +++ b/Justfile @@ -45,6 +45,7 @@ release-intel: copy-binary BUILD_MODE="release": @if [ -f ./target/{{BUILD_MODE}}/goosed ]; then \ echo "Copying goosed binary from target/{{BUILD_MODE}}..."; \ + rm -f ./ui/desktop/src/bin/goosed; \ cp -p ./target/{{BUILD_MODE}}/goosed ./ui/desktop/src/bin/; \ else \ echo "Binary not found in target/{{BUILD_MODE}}"; \ @@ -52,6 +53,7 @@ copy-binary BUILD_MODE="release": fi @if [ -f ./target/{{BUILD_MODE}}/goose ]; then \ echo "Copying goose CLI binary from target/{{BUILD_MODE}}..."; \ + rm -f ./ui/desktop/src/bin/goose; \ cp -p ./target/{{BUILD_MODE}}/goose ./ui/desktop/src/bin/; \ else \ echo "goose CLI binary not found in target/{{BUILD_MODE}}"; \ @@ -62,6 +64,7 @@ copy-binary BUILD_MODE="release": copy-binary-intel: @if [ -f ./target/x86_64-apple-darwin/release/goosed ]; then \ echo "Copying Intel goosed binary to ui/desktop/src/bin with permissions preserved..."; \ + rm -f ./ui/desktop/src/bin/goosed; \ cp -p ./target/x86_64-apple-darwin/release/goosed ./ui/desktop/src/bin/; \ else \ echo "Intel release binary not found."; \ @@ -69,6 +72,7 @@ copy-binary-intel: fi @if [ -f ./target/x86_64-apple-darwin/release/goose ]; then \ echo "Copying Intel goose CLI binary to ui/desktop/src/bin..."; \ + rm -f ./ui/desktop/src/bin/goose; \ cp -p ./target/x86_64-apple-darwin/release/goose ./ui/desktop/src/bin/; \ else \ echo "Intel goose CLI binary not found."; \ diff --git a/ui/desktop/vite.renderer.config.mts b/ui/desktop/vite.renderer.config.mts index 0228982d862d..e89a6cc75f95 100644 --- a/ui/desktop/vite.renderer.config.mts +++ b/ui/desktop/vite.renderer.config.mts @@ -9,6 +9,14 @@ export default defineConfig({ plugins: [tailwindcss()], + // Vite caches a copy of @aaif/goose-sdk and doesn't notice when we rebuild it + // locally, so it serves stale code until you clear node_modules/.vite by hand. + // Excluding it makes Vite always read the latest ui/sdk/dist build. + // Dev-server only — release builds ignore optimizeDeps. + optimizeDeps: { + exclude: ['@aaif/goose-sdk'], + }, + build: { target: 'esnext' }, From f57ff944819cbf636600fb28f114966b8503df6b Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 16:20:25 +1000 Subject: [PATCH 07/10] fixed the test and address comments --- .../src/custom_requests/recipe.rs | 4 + crates/goose/acp-schema.json | 12 ++ .../src/acp/server/recipe/conversions.rs | 26 ++- crates/goose/tests/acp_fixtures/mod.rs | 156 ++++++++++++++++++ ui/desktop/src/acp/__tests__/recipe.test.ts | 13 ++ ui/desktop/src/acp/recipe.ts | 8 +- .../recipes/CreateEditRecipeModal.tsx | 8 +- .../shared/RecipeExtensionSelector.tsx | 13 +- ui/sdk/src/generated/types.gen.ts | 6 + ui/sdk/src/generated/zod.gen.ts | 2 + 10 files changed, 233 insertions(+), 15 deletions(-) diff --git a/crates/goose-sdk-types/src/custom_requests/recipe.rs b/crates/goose-sdk-types/src/custom_requests/recipe.rs index 73c2df8f708a..1dda40290858 100644 --- a/crates/goose-sdk-types/src/custom_requests/recipe.rs +++ b/crates/goose-sdk-types/src/custom_requests/recipe.rs @@ -188,6 +188,8 @@ pub enum RecipeExtensionDto { cmd: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] args: Vec, + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + envs: HashMap, #[serde(default, skip_serializing_if = "Vec::is_empty")] env_keys: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -202,6 +204,8 @@ pub enum RecipeExtensionDto { #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, uri: String, + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + envs: HashMap, #[serde(default, skip_serializing_if = "Vec::is_empty")] env_keys: Vec, #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 68b0c455b69c..1cf4fb89f017 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -2910,6 +2910,12 @@ "type": "string" } }, + "envs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "env_keys": { "type": "array", "items": { @@ -2961,6 +2967,12 @@ "uri": { "type": "string" }, + "envs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "env_keys": { "type": "array", "items": { diff --git a/crates/goose/src/acp/server/recipe/conversions.rs b/crates/goose/src/acp/server/recipe/conversions.rs index 1e613a95838a..9ec6e1a7bfc6 100644 --- a/crates/goose/src/acp/server/recipe/conversions.rs +++ b/crates/goose/src/acp/server/recipe/conversions.rs @@ -315,6 +315,7 @@ impl TryFrom for ExtensionConfig { description, cmd, args, + envs, env_keys, timeout, cwd, @@ -324,7 +325,7 @@ impl TryFrom for ExtensionConfig { description: description.unwrap_or_default(), cmd, args, - envs: Envs::default(), + envs: Envs::new(envs), env_keys, timeout, cwd, @@ -335,6 +336,7 @@ impl TryFrom for ExtensionConfig { name, description, uri, + envs, env_keys, headers, timeout, @@ -344,7 +346,7 @@ impl TryFrom for ExtensionConfig { name, description: description.unwrap_or_default(), uri, - envs: Envs::default(), + envs: Envs::new(envs), env_keys, headers, timeout, @@ -392,6 +394,7 @@ impl TryFrom for RecipeExtensionDto { description, cmd, args, + envs, env_keys, timeout, cwd, @@ -402,6 +405,7 @@ impl TryFrom for RecipeExtensionDto { description: Some(description), cmd, args, + envs: envs.get_env(), env_keys, timeout, cwd, @@ -411,6 +415,7 @@ impl TryFrom for RecipeExtensionDto { name, description, uri, + envs, env_keys, headers, timeout, @@ -421,6 +426,7 @@ impl TryFrom for RecipeExtensionDto { name, description: Some(description), uri, + envs: envs.get_env(), env_keys, headers, timeout, @@ -484,6 +490,7 @@ mod tests { description: Some("Local tool".to_string()), cmd: "goose-mcp".to_string(), args: vec!["run".to_string()], + envs: HashMap::from([("LOCAL_MODE".to_string(), "true".to_string())]), env_keys: vec!["API_KEY".to_string()], timeout: Some(60), cwd: Some("/tmp".to_string()), @@ -493,6 +500,7 @@ mod tests { name: "remote".to_string(), description: Some("Remote tool".to_string()), uri: "http://localhost:3000/mcp".to_string(), + envs: HashMap::from([("REMOTE_MODE".to_string(), "true".to_string())]), env_keys: vec!["TOKEN".to_string()], headers: HashMap::from([("X-Test".to_string(), "true".to_string())]), timeout: Some(30), @@ -550,6 +558,18 @@ mod tests { assert_eq!(recipe.version, "1.0.0"); assert_eq!(recipe.title, "Test Recipe"); assert_eq!(recipe.extensions.as_ref().unwrap().len(), 3); + match &recipe.extensions.as_ref().unwrap()[1] { + ExtensionConfig::Stdio { envs, .. } => { + assert_eq!(envs.get_env()["LOCAL_MODE"], "true"); + } + extension => panic!("expected stdio extension, got {extension:?}"), + } + match &recipe.extensions.as_ref().unwrap()[2] { + ExtensionConfig::StreamableHttp { envs, .. } => { + assert_eq!(envs.get_env()["REMOTE_MODE"], "true"); + } + extension => panic!("expected streamable_http extension, got {extension:?}"), + } assert_eq!( recipe.sub_recipes.as_ref().unwrap()[0].values, Some(HashMap::from([("target".to_string(), "dev".to_string())])) @@ -562,6 +582,8 @@ mod tests { assert!(serialized.get("subRecipes").is_none()); assert_eq!(serialized["parameters"][0]["input_type"], json!("select")); assert_eq!(serialized["retry"]["checks"][0]["type"], json!("shell")); + assert_eq!(serialized["extensions"][1]["envs"]["LOCAL_MODE"], "true"); + assert_eq!(serialized["extensions"][2]["envs"]["REMOTE_MODE"], "true"); } #[test] diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index d300cfc17db8..c4d6aad12331 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -9,6 +9,7 @@ use agent_client_protocol::schema::{ WriteTextFileResponse, }; use async_trait::async_trait; +use chrono::{DateTime, Utc}; use fs_err as fs; use goose::acp::server::{serve, AcpProviderFactory, GooseAcpAgent, GooseAcpAgentOptions}; pub use goose::acp::{map_permission_response, PermissionDecision}; @@ -19,6 +20,9 @@ use goose::config::{GooseMode, PermissionManager}; use goose::providers::api_client::{ApiClient, AuthMethod as ApiAuthMethod}; use goose::providers::base::Provider; use goose::providers::openai::OpenAiProvider; +use goose::scheduler::{ScheduledJob, SchedulerError}; +use goose::scheduler_trait::SchedulerTrait; +use goose::session::Session as GooseSession; use goose::session_context::SESSION_ID_HEADER; use goose_test_support::{ExpectedSessionId, TEST_MODEL}; use std::collections::VecDeque; @@ -34,6 +38,157 @@ static ACP_TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); static ACP_CONFIG_ROOT: LazyLock = LazyLock::new(|| tempfile::tempdir().unwrap()); +struct FixtureScheduler { + jobs: tokio::sync::Mutex>, +} + +impl FixtureScheduler { + fn new() -> Self { + Self { + jobs: tokio::sync::Mutex::new(Vec::new()), + } + } + + async fn job_mut(&self, id: &str, update: F) -> Result<(), SchedulerError> + where + F: FnOnce(&mut ScheduledJob), + { + let mut jobs = self.jobs.lock().await; + let job = jobs + .iter_mut() + .find(|job| job.id == id) + .ok_or_else(|| SchedulerError::JobNotFound(id.to_string()))?; + update(job); + Ok(()) + } +} + +#[async_trait] +impl SchedulerTrait for FixtureScheduler { + async fn add_scheduled_job( + &self, + job: ScheduledJob, + _copy_recipe: bool, + ) -> Result<(), SchedulerError> { + let mut jobs = self.jobs.lock().await; + if jobs.iter().any(|existing| existing.id == job.id) { + return Err(SchedulerError::JobIdExists(job.id)); + } + jobs.push(job); + Ok(()) + } + + async fn schedule_recipe( + &self, + recipe_path: PathBuf, + cron_schedule: Option, + ) -> anyhow::Result<(), SchedulerError> { + let id = recipe_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("test_recipe") + .to_string(); + self.add_scheduled_job( + ScheduledJob { + id, + source: recipe_path.to_string_lossy().to_string(), + cron: cron_schedule.unwrap_or_else(|| "0 0 * * * *".to_string()), + last_run: None, + currently_running: false, + paused: false, + current_session_id: None, + process_start_time: None, + parameters: Vec::new(), + recipe_base_dir: recipe_path + .parent() + .map(|parent| parent.to_string_lossy().to_string()), + }, + false, + ) + .await + } + + async fn list_scheduled_jobs(&self) -> Vec { + self.jobs.lock().await.clone() + } + + async fn remove_scheduled_job( + &self, + id: &str, + _remove_recipe: bool, + ) -> Result<(), SchedulerError> { + let mut jobs = self.jobs.lock().await; + if let Some(index) = jobs.iter().position(|job| job.id == id) { + jobs.remove(index); + Ok(()) + } else { + Err(SchedulerError::JobNotFound(id.to_string())) + } + } + + async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError> { + self.job_mut(id, |job| job.paused = true).await + } + + async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError> { + self.job_mut(id, |job| job.paused = false).await + } + + async fn run_now(&self, id: &str) -> Result { + self.job_mut(id, |job| { + job.last_run = Some(Utc::now()); + job.current_session_id = Some("test_session_123".to_string()); + }) + .await?; + Ok("test_session_123".to_string()) + } + + async fn sessions( + &self, + sched_id: &str, + _limit: usize, + ) -> Result, SchedulerError> { + let jobs = self.jobs.lock().await; + if jobs.iter().any(|job| job.id == sched_id) { + Ok(Vec::new()) + } else { + Err(SchedulerError::JobNotFound(sched_id.to_string())) + } + } + + async fn update_schedule( + &self, + sched_id: &str, + new_cron: String, + ) -> Result<(), SchedulerError> { + self.job_mut(sched_id, |job| job.cron = new_cron).await + } + + async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> { + self.job_mut(sched_id, |job| { + job.currently_running = false; + job.current_session_id = None; + job.process_start_time = None; + }) + .await + } + + async fn get_running_job_info( + &self, + sched_id: &str, + ) -> Result)>, SchedulerError> { + let jobs = self.jobs.lock().await; + let job = jobs + .iter() + .find(|job| job.id == sched_id) + .ok_or_else(|| SchedulerError::JobNotFound(sched_id.to_string()))?; + Ok(job + .current_session_id + .clone() + .zip(job.process_start_time.clone())) + } +} + fn write_global_test_config(config_path: &Path, openai_base_url: &str) { let contents = fs::read_to_string(config_path).unwrap(); let mut config: serde_yaml::Mapping = serde_yaml::from_str(&contents).unwrap(); @@ -221,6 +376,7 @@ pub async fn spawn_acp_server_in_process( disable_session_naming, goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + scheduler: Arc::new(FixtureScheduler::new()), }) .await .unwrap(); diff --git a/ui/desktop/src/acp/__tests__/recipe.test.ts b/ui/desktop/src/acp/__tests__/recipe.test.ts index 2719fdbcba36..268e48913dbf 100644 --- a/ui/desktop/src/acp/__tests__/recipe.test.ts +++ b/ui/desktop/src/acp/__tests__/recipe.test.ts @@ -159,4 +159,17 @@ describe('ACP recipe helpers', () => { await expect(encodeRecipe(recipe)).rejects.toThrow('recipe is invalid'); }); + + it('prefers ACP JSON-RPC error data over generic messages', async () => { + client.goose.recipesSave_unstable.mockRejectedValue({ + error: { + message: 'Invalid params', + data: 'save recipe validation failed at recipe.extensions[0]: missing field `cmd`', + }, + }); + + await expect(saveRecipe(recipe)).rejects.toThrow( + 'save recipe validation failed at recipe.extensions[0]: missing field `cmd`' + ); + }); }); diff --git a/ui/desktop/src/acp/recipe.ts b/ui/desktop/src/acp/recipe.ts index 9df60bb28b09..0b6b5cd33cbc 100644 --- a/ui/desktop/src/acp/recipe.ts +++ b/ui/desktop/src/acp/recipe.ts @@ -12,7 +12,13 @@ function acpErrorMessage(error: unknown): string | null { } const candidate = 'error' in error && isRecord(error.error) ? error.error : error; - return isRecord(candidate) && typeof candidate.message === 'string' ? candidate.message : null; + if (!isRecord(candidate)) { + return null; + } + if (typeof candidate.data === 'string') { + return candidate.data; + } + return typeof candidate.message === 'string' ? candidate.message : null; } function isRecord(value: unknown): value is Record { diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx index dece98fc74f6..d5d90e1f5fa4 100644 --- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx +++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx @@ -261,17 +261,11 @@ export default function CreateEditRecipeModal({ const cleanedExtensions = extensions?.map( ( extension: RecipeExtension & { - envs?: unknown; enabled?: boolean; available_tools?: unknown; } ) => { - const { - envs: _envs, - enabled: _enabled, - available_tools: _availableTools, - ...rest - } = extension; + const { enabled: _enabled, available_tools: _availableTools, ...rest } = extension; return rest; } ) as RecipeExtension[] | undefined; diff --git a/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx b/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx index 09f94987468d..5f8ee6cd891b 100644 --- a/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx +++ b/ui/desktop/src/components/recipes/shared/RecipeExtensionSelector.tsx @@ -53,16 +53,18 @@ function toRecipeExtension( return { name, description, display_name, bundled, type, enabled }; } case 'stdio': { - const { name, description, cmd, args, env_keys, timeout, cwd, bundled, type } = extension; - return { name, description, cmd, args, env_keys, timeout, cwd, bundled, type, enabled }; + const { name, description, cmd, args, envs, env_keys, timeout, cwd, bundled, type } = + extension; + return { name, description, cmd, args, envs, env_keys, timeout, cwd, bundled, type, enabled }; } case 'streamable_http': { - const { name, description, uri, env_keys, headers, timeout, socket, bundled, type } = + const { name, description, uri, envs, env_keys, headers, timeout, socket, bundled, type } = extension; return { name, description, uri, + envs, env_keys, headers, timeout, @@ -106,8 +108,9 @@ export const RecipeExtensionSelector = ({ }); selectedExtensions.forEach((ext) => { - if (!extensionMap.has(ext.name)) { - extensionMap.set(ext.name, { ...ext, enabled: true }); + const recipeExtension = toRecipeExtension({ ...ext, enabled: true }); + if (recipeExtension) { + extensionMap.set(recipeExtension.name, recipeExtension); } }); diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index dbbe824d5cea..555289163eb5 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -1169,6 +1169,9 @@ export type RecipeExtensionDto = { description?: string | null; cmd: string; args?: Array; + envs?: { + [key: string]: string; + }; env_keys?: Array; timeout?: number | null; cwd?: string | null; @@ -1178,6 +1181,9 @@ export type RecipeExtensionDto = { name: string; description?: string | null; uri: string; + envs?: { + [key: string]: string; + }; env_keys?: Array; headers?: { [key: string]: string; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 07124a388915..3d5ef2c34dc6 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -1176,6 +1176,7 @@ export const zRecipeExtensionDto = z.union([ ]).optional(), cmd: z.string(), args: z.array(z.string()).optional(), + envs: z.record(z.string()).optional(), env_keys: z.array(z.string()).optional(), timeout: z.union([ z.number().int().gte(0), @@ -1198,6 +1199,7 @@ export const zRecipeExtensionDto = z.union([ z.null() ]).optional(), uri: z.string(), + envs: z.record(z.string()).optional(), env_keys: z.array(z.string()).optional(), headers: z.record(z.string()).optional(), timeout: z.union([ From c7cc05668728a0bbcd3332618cb80ea1038a12d5 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 16:28:18 +1000 Subject: [PATCH 08/10] fixed more tests --- crates/goose/tests/acp_transport_auth_test.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/goose/tests/acp_transport_auth_test.rs b/crates/goose/tests/acp_transport_auth_test.rs index dd2af706eadc..a3107f540218 100644 --- a/crates/goose/tests/acp_transport_auth_test.rs +++ b/crates/goose/tests/acp_transport_auth_test.rs @@ -17,6 +17,7 @@ fn test_router(require_token: bool, dir: &tempfile::TempDir) -> Router { config_dir: dir.path().join("config"), goose_platform: GoosePlatform::GooseCli, additional_source_roots: Vec::new(), + scheduler: None, })); create_router(server, SECRET.to_string(), require_token) } From 2c6b6ffc93e1d7cbc16fec39c58a3374abb6385b Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 16:41:13 +1000 Subject: [PATCH 09/10] removed unnecessary tests --- ui/desktop/src/acp/__tests__/recipe.test.ts | 121 +------------------- 1 file changed, 1 insertion(+), 120 deletions(-) diff --git a/ui/desktop/src/acp/__tests__/recipe.test.ts b/ui/desktop/src/acp/__tests__/recipe.test.ts index 268e48913dbf..1ebc5e226cf2 100644 --- a/ui/desktop/src/acp/__tests__/recipe.test.ts +++ b/ui/desktop/src/acp/__tests__/recipe.test.ts @@ -1,18 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { RecipeDto } from '@aaif/goose-sdk'; import { getAcpClient } from '../acpConnection'; -import { - decodeRecipe, - deleteRecipe, - encodeRecipe, - listRecipes, - parseRecipe, - recipeToYaml, - saveRecipe, - scanRecipe, - scheduleRecipe, - setRecipeSlashCommand, -} from '../recipe'; +import { encodeRecipe, saveRecipe } from '../recipe'; vi.mock('../acpConnection', () => ({ getAcpClient: vi.fn(), @@ -28,15 +17,7 @@ function createClient() { return { goose: { recipesEncode_unstable: vi.fn(), - recipesDecode_unstable: vi.fn(), - recipesScan_unstable: vi.fn(), - recipesParse_unstable: vi.fn(), recipesSave_unstable: vi.fn(), - recipesList_unstable: vi.fn(), - recipesDelete_unstable: vi.fn(), - recipesSchedule_unstable: vi.fn(), - recipesSlashCommand_unstable: vi.fn(), - recipesToYaml_unstable: vi.fn(), }, }; } @@ -52,106 +33,6 @@ describe('ACP recipe helpers', () => { ); }); - it('encodes a recipe using ACP', async () => { - client.goose.recipesEncode_unstable.mockResolvedValue({ deeplink: 'encoded' }); - - await expect(encodeRecipe(recipe)).resolves.toBe('encoded'); - - expect(client.goose.recipesEncode_unstable).toHaveBeenCalledWith({ recipe }); - }); - - it('decodes a recipe using ACP', async () => { - client.goose.recipesDecode_unstable.mockResolvedValue({ recipe }); - - await expect(decodeRecipe('encoded')).resolves.toEqual(recipe); - - expect(client.goose.recipesDecode_unstable).toHaveBeenCalledWith({ deeplink: 'encoded' }); - }); - - it('scans a recipe using ACP', async () => { - client.goose.recipesScan_unstable.mockResolvedValue({ has_security_warnings: true }); - - await expect(scanRecipe(recipe)).resolves.toEqual({ has_security_warnings: true }); - - expect(client.goose.recipesScan_unstable).toHaveBeenCalledWith({ recipe }); - }); - - it('parses a recipe using ACP', async () => { - client.goose.recipesParse_unstable.mockResolvedValue({ recipe }); - - await expect(parseRecipe('title: Test')).resolves.toEqual(recipe); - - expect(client.goose.recipesParse_unstable).toHaveBeenCalledWith({ content: 'title: Test' }); - }); - - it('saves a recipe using ACP', async () => { - const response = { - id: 'recipe-id', - file_name: 'test.yaml', - file_path: '/tmp/test.yaml', - }; - client.goose.recipesSave_unstable.mockResolvedValue(response); - - await expect(saveRecipe(recipe, 'recipe-id')).resolves.toEqual(response); - - expect(client.goose.recipesSave_unstable).toHaveBeenCalledWith({ - recipe, - id: 'recipe-id', - }); - }); - - it('lists recipes using ACP and returns desktop recipe manifests', async () => { - client.goose.recipesList_unstable.mockResolvedValue({ - recipes: [ - { - id: 'recipe-id', - recipe, - file_path: '/tmp/test.yaml', - last_modified: '2026-06-23T00:00:00Z', - schedule_cron: '0 0 * * * *', - slash_command: 'test', - }, - ], - }); - - await expect(listRecipes()).resolves.toEqual([ - { - id: 'recipe-id', - recipe, - file_path: '/tmp/test.yaml', - last_modified: '2026-06-23T00:00:00Z', - schedule_cron: '0 0 * * * *', - slash_command: 'test', - }, - ]); - - expect(client.goose.recipesList_unstable).toHaveBeenCalledWith({}); - }); - - it('runs recipe mutations using ACP', async () => { - await deleteRecipe('recipe-id'); - await scheduleRecipe('recipe-id', '0 0 * * * *'); - await setRecipeSlashCommand('recipe-id', 'test'); - - expect(client.goose.recipesDelete_unstable).toHaveBeenCalledWith({ id: 'recipe-id' }); - expect(client.goose.recipesSchedule_unstable).toHaveBeenCalledWith({ - id: 'recipe-id', - cron_schedule: '0 0 * * * *', - }); - expect(client.goose.recipesSlashCommand_unstable).toHaveBeenCalledWith({ - id: 'recipe-id', - slash_command: 'test', - }); - }); - - it('converts a recipe to YAML using ACP', async () => { - client.goose.recipesToYaml_unstable.mockResolvedValue({ yaml: 'title: Test Recipe' }); - - await expect(recipeToYaml(recipe)).resolves.toBe('title: Test Recipe'); - - expect(client.goose.recipesToYaml_unstable).toHaveBeenCalledWith({ recipe }); - }); - it('surfaces ACP JSON-RPC error messages', async () => { client.goose.recipesEncode_unstable.mockRejectedValue({ error: { message: 'recipe is invalid' }, From c842a5a18978cd3bf91024077041dd39ec5b05e1 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 23 Jun 2026 17:58:59 +1000 Subject: [PATCH 10/10] final test and test fix --- crates/goose/tests/acp_fixtures/mod.rs | 5 +-- ui/desktop/src/acp/__tests__/recipe.test.ts | 44 ++++++++++++++++++++- ui/desktop/src/acp/recipe.ts | 27 +++++++++++-- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/goose/tests/acp_fixtures/mod.rs b/crates/goose/tests/acp_fixtures/mod.rs index c4d6aad12331..cde43e1a8b0e 100644 --- a/crates/goose/tests/acp_fixtures/mod.rs +++ b/crates/goose/tests/acp_fixtures/mod.rs @@ -182,10 +182,7 @@ impl SchedulerTrait for FixtureScheduler { .iter() .find(|job| job.id == sched_id) .ok_or_else(|| SchedulerError::JobNotFound(sched_id.to_string()))?; - Ok(job - .current_session_id - .clone() - .zip(job.process_start_time.clone())) + Ok(job.current_session_id.clone().zip(job.process_start_time)) } } diff --git a/ui/desktop/src/acp/__tests__/recipe.test.ts b/ui/desktop/src/acp/__tests__/recipe.test.ts index 1ebc5e226cf2..2ecf750c7605 100644 --- a/ui/desktop/src/acp/__tests__/recipe.test.ts +++ b/ui/desktop/src/acp/__tests__/recipe.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { RecipeDto } from '@aaif/goose-sdk'; import { getAcpClient } from '../acpConnection'; -import { encodeRecipe, saveRecipe } from '../recipe'; +import { encodeRecipe, listRecipes, parseRecipe, saveRecipe } from '../recipe'; vi.mock('../acpConnection', () => ({ getAcpClient: vi.fn(), @@ -17,6 +17,8 @@ function createClient() { return { goose: { recipesEncode_unstable: vi.fn(), + recipesList_unstable: vi.fn(), + recipesParse_unstable: vi.fn(), recipesSave_unstable: vi.fn(), }, }; @@ -53,4 +55,44 @@ describe('ACP recipe helpers', () => { 'save recipe validation failed at recipe.extensions[0]: missing field `cmd`' ); }); + + it('prefers ACP JSON-RPC error data from Error instances', async () => { + client.goose.recipesParse_unstable.mockRejectedValue( + Object.assign(new Error('Invalid params'), { + error: { + message: 'Invalid params', + data: 'recipe: missing field `title`', + }, + }) + ); + + await expect(parseRecipe('description: Missing title')).rejects.toThrow( + 'recipe: missing field `title`' + ); + }); + + it('shares concurrent recipe list requests', async () => { + const recipes = [ + { + id: 'recipe-1', + recipe, + }, + ]; + client.goose.recipesList_unstable.mockResolvedValue({ recipes }); + + const [first, second] = await Promise.all([listRecipes(), listRecipes()]); + + expect(client.goose.recipesList_unstable).toHaveBeenCalledTimes(1); + expect(first).toBe(recipes); + expect(second).toBe(recipes); + }); + + it('fetches recipes again after a list request settles', async () => { + client.goose.recipesList_unstable.mockResolvedValue({ recipes: [] }); + + await listRecipes(); + await listRecipes(); + + expect(client.goose.recipesList_unstable).toHaveBeenCalledTimes(2); + }); }); diff --git a/ui/desktop/src/acp/recipe.ts b/ui/desktop/src/acp/recipe.ts index 0b6b5cd33cbc..4b43dffd727f 100644 --- a/ui/desktop/src/acp/recipe.ts +++ b/ui/desktop/src/acp/recipe.ts @@ -6,6 +6,8 @@ import type { } from '@aaif/goose-sdk'; import { getAcpClient } from './acpConnection'; +let inFlightListRecipes: Promise | null = null; + function acpErrorMessage(error: unknown): string | null { if (typeof error !== 'object' || error === null) { return null; @@ -26,10 +28,14 @@ function isRecord(value: unknown): value is Record { } function normalizeAcpError(error: unknown, fallback: string): Error { + const message = acpErrorMessage(error); + if (message) { + return new Error(message); + } if (error instanceof Error) { return error; } - return new Error(acpErrorMessage(error) ?? fallback); + return new Error(fallback); } export async function encodeRecipe(recipe: RecipeDto): Promise { @@ -87,12 +93,27 @@ export async function saveRecipe( } export async function listRecipes(): Promise { - try { + const pending = inFlightListRecipes; + if (pending) { + return pending; + } + + const listPromise = (async () => { const client = await getAcpClient(); const response = await client.goose.recipesList_unstable({}); return response.recipes; - } catch (error) { + })().catch((error) => { throw normalizeAcpError(error, 'Failed to list recipes'); + }); + + inFlightListRecipes = listPromise; + + try { + return await listPromise; + } finally { + if (inFlightListRecipes === listPromise) { + inFlightListRecipes = null; + } } }