diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 7df1167c6d30..c36e199b83b3 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -311,5 +311,12 @@ "method": "_goose/unstable/session/update", "paramsType": "GooseSessionNotification_unstable" } + ], + "agentRequests": [ + { + "method": "_goose/unstable/session/recipe/request-params", + "requestType": "RequestRecipeParams_unstable", + "responseType": "RecipeParamsResponse_unstable" + } ] } diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 766331fdc4e1..14b132d95827 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -3691,6 +3691,116 @@ ], "description": "Live UI/session status. This is not conversation transcript content, and\nshould not be persisted or replayed as history." }, + "RequestRecipeParams_unstable": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/RecipeParameter" + } + } + }, + "required": [ + "sessionId", + "parameters" + ], + "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": { + "action": { + "$ref": "#/$defs/RecipeParamsAction", + "default": "submit" + }, + "values": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + } + }, + "x-side": "client", + "x-method": "_goose/unstable/session/recipe/request-params" + }, + "RecipeParamsAction": { + "type": "string", + "enum": [ + "submit", + "cancel" + ] + }, "ExtRequest": { "properties": { "id": { @@ -4659,6 +4769,111 @@ ], "type": "object", "x-docs-ignore": true + }, + "ExtAgentRequest": { + "properties": { + "id": { + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/RequestRecipeParams_unstable" + } + ], + "description": "Params for _goose/unstable/session/recipe/request-params", + "title": "RequestRecipeParams_unstable" + } + ] + }, + { + "description": "Untyped params", + "type": [ + "object", + "null" + ] + } + ] + } + }, + "required": [ + "id", + "method" + ], + "type": "object", + "x-docs-ignore": true + }, + "ExtAgentResponse": { + "anyOf": [ + { + "properties": { + "id": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/$defs/RecipeParamsResponse_unstable" + } + ], + "title": "RecipeParamsResponse_unstable" + } + ] + }, + { + "description": "Untyped result" + } + ] + } + }, + "required": [ + "id" + ], + "title": "Success", + "type": "object" + }, + { + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": {} + }, + "required": [ + "code", + "message" + ] + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "error" + ], + "title": "Error", + "type": "object" + } + ], + "x-docs-ignore": true } }, "anyOf": [ @@ -4688,6 +4903,24 @@ ], "description": "Extension notification (agent → client, fire-and-forget)", "title": "Notification" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtAgentRequest" + } + ], + "description": "Extension agent request (agent → client)", + "title": "AgentRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/ExtAgentResponse" + } + ], + "description": "Extension agent response (client → agent)", + "title": "AgentResponse" } ] } diff --git a/crates/goose/src/acp/response_builder.rs b/crates/goose/src/acp/response_builder.rs index ea2596881017..cb1115f4ac28 100644 --- a/crates/goose/src/acp/response_builder.rs +++ b/crates/goose/src/acp/response_builder.rs @@ -1,3 +1,4 @@ +use crate::agents::ExtensionLoadResult; use crate::config::{Config, GooseMode}; use crate::providers::inventory::{ProviderInventoryEntry, ProviderInventoryService}; use crate::session::Session; @@ -77,6 +78,31 @@ pub(super) fn session_meta(session: &Session) -> serde_json::Map serde_json::Map { + let mut meta = serde_json::Map::new(); + if let Some(recipe) = &session.recipe { + if let Ok(v) = serde_json::to_value(recipe) { + meta.insert("recipe".to_string(), v); + } + } + if let Some(values) = &session.user_recipe_values { + if let Ok(v) = serde_json::to_value(values) { + meta.insert("userRecipeValues".to_string(), v); + } + } + if let Ok(v) = serde_json::to_value(extension_results) { + meta.insert("extensionResults".to_string(), v); + } + meta.insert( + "workingDir".to_string(), + serde_json::Value::String(session.working_dir.to_string_lossy().to_string()), + ); + meta +} + pub(super) fn build_session_info(session: Session) -> SessionInfo { let meta = session_meta(&session); let mut info = SessionInfo::new(SessionId::new(session.id), session.working_dir) diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 2459b8132b1e..6fb2dad6b7eb 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -4,7 +4,7 @@ use crate::acp::fs::AcpTools; pub(super) use crate::acp::response_builder::{ build_config_options, build_mode_state, build_model_state, build_provider_options, build_session_info, build_session_setup_config, send_session_setup_notifications, session_meta, - session_provider_selection, should_refresh_inventory_for_session_init, + session_provider_selection, session_response_meta, should_refresh_inventory_for_session_init, }; use crate::acp::tools::AcpAwareToolMeta; use crate::acp::{PermissionDecision, ACP_CURRENT_MODEL}; @@ -79,6 +79,8 @@ use tracing::{debug, error, info, warn}; use url::Url; use uuid::Uuid; +mod agent_requests; +pub use agent_requests::agent_request_schemas; mod config; mod custom_dispatch; mod dictation; @@ -92,6 +94,7 @@ mod manage_sessions; mod new_session; mod onboarding; mod providers; +mod recipe; mod resources; mod sources; mod tool_notifications; @@ -211,6 +214,7 @@ pub struct GooseAcpAgent { client_mcp_host_info: OnceCell, client_supports_acp_elicitation: OnceCell, client_supports_goose_custom_notifications: OnceCell, + client_supports_recipe_param_requests: OnceCell, use_login_shell_path: OnceCell, client_cx: OnceCell>, config_dir: std::path::PathBuf, @@ -219,6 +223,7 @@ pub struct GooseAcpAgent { disable_session_naming: bool, provider_inventory: ProviderInventoryService, additional_source_roots: Vec, + recipe_path_cache: Arc>>, } /// Shorten a session/thread id for perf log correlation. @@ -300,6 +305,8 @@ struct GooseClientCapabilities { mcp_host_capabilities: Option, #[serde(rename = "customNotifications", default)] custom_notifications: Option, + #[serde(rename = "recipeParameterRequests", default)] + recipe_parameter_requests: Option, } #[derive(Debug, Default, Deserialize)] @@ -855,6 +862,13 @@ impl GooseAcpAgent { .unwrap_or(false) } + pub(super) fn supports_recipe_param_requests(&self) -> bool { + self.client_supports_recipe_param_requests + .get() + .copied() + .unwrap_or(false) + } + fn supports_acp_elicitation(&self) -> bool { self.client_supports_acp_elicitation .get() @@ -896,6 +910,7 @@ impl GooseAcpAgent { client_mcp_host_info: OnceCell::new(), client_supports_acp_elicitation: OnceCell::new(), client_supports_goose_custom_notifications: OnceCell::new(), + client_supports_recipe_param_requests: OnceCell::new(), use_login_shell_path: OnceCell::new(), client_cx: OnceCell::new(), config_dir: options.config_dir, @@ -904,6 +919,7 @@ impl GooseAcpAgent { disable_session_naming: options.disable_session_naming, provider_inventory, additional_source_roots: options.additional_source_roots, + recipe_path_cache: Arc::new(Mutex::new(HashMap::new())), }) } @@ -986,13 +1002,18 @@ impl GooseAcpAgent { config: &Config, mcp_servers: Vec, goose_extensions: Option>, + recipe_extensions: Option<&[ExtensionConfig]>, ) -> Result, agent_client_protocol::Error> { let mut extensions = Vec::new(); for builtin in &self.builtins { push_or_replace_extension(&mut extensions, builtin_to_extension_config(builtin)); } - if let Some(goose_extensions) = goose_extensions { + if let Some(recipe_extensions) = recipe_extensions { + for extension in recipe_extensions { + push_or_replace_extension(&mut extensions, extension.clone()); + } + } else if let Some(goose_extensions) = goose_extensions { for extension in extensions::goose_extensions_to_configs(goose_extensions)? { push_or_replace_extension(&mut extensions, extension); } @@ -1118,7 +1139,7 @@ impl GooseAcpAgent { || EnabledExtensionsState::from_extension_data(&session.extension_data).is_none() { let extension_data = - self.build_enabled_extensions_data(config, &session, mcp_servers, None)?; + self.build_enabled_extensions_data(config, &session, mcp_servers, None, None)?; builder = builder.extension_data(extension_data); session_needs_update = true; } @@ -1148,8 +1169,14 @@ impl GooseAcpAgent { session: &Session, mcp_servers: Vec, goose_extensions: Option>, + recipe_extensions: Option<&[ExtensionConfig]>, ) -> Result { - let extensions = self.initial_session_extensions(config, mcp_servers, goose_extensions)?; + let extensions = self.initial_session_extensions( + config, + mcp_servers, + goose_extensions, + recipe_extensions, + )?; let mut extension_data = session.extension_data.clone(); EnabledExtensionsState::new(extensions) .to_extension_data(&mut extension_data) @@ -1885,6 +1912,14 @@ fn extract_client_supports_goose_custom_notifications( .unwrap_or(false) } +fn extract_client_supports_recipe_param_requests( + goose_client_capabilities: Option<&GooseClientCapabilities>, +) -> bool { + goose_client_capabilities + .and_then(|goose| goose.recipe_parameter_requests) + .unwrap_or(false) +} + fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConfirmation { PermissionConfirmation { principal_type: PrincipalType::Tool, @@ -2101,6 +2136,9 @@ impl GooseAcpAgent { let _ = self.client_supports_goose_custom_notifications.set( extract_client_supports_goose_custom_notifications(goose_client_capabilities.as_ref()), ); + let _ = self.client_supports_recipe_param_requests.set( + extract_client_supports_recipe_param_requests(goose_client_capabilities.as_ref()), + ); let _ = self .client_supports_acp_elicitation .set(elicitation::client_supports_form_elicitation(&args)); diff --git a/crates/goose/src/acp/server/agent_requests.rs b/crates/goose/src/acp/server/agent_requests.rs new file mode 100644 index 000000000000..4611ed405486 --- /dev/null +++ b/crates/goose/src/acp/server/agent_requests.rs @@ -0,0 +1,47 @@ +//! Goose-custom **agent → client** requests: server-initiated JSON-RPC requests +//! that expect a response from the client (unlike notifications, which are +//! fire-and-forget). This module aggregates their JSON schemas for the ACP +//! schema generator, parallel to `custom_notification_schemas`. +//! +//! To expose a new agent → client request in the generated schema, define its +//! params/response types (deriving `JsonSchema`) next to the feature that sends +//! it, then add one line to [`agent_request_schemas`]. + +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() +} + +/// Schema descriptor for a single agent → client request. Unlike notification +/// descriptors, request descriptors include both params and response types. +fn agent_request_schema( + generator: &mut SchemaGenerator, + method: &str, +) -> CustomMethodSchema +where + Req: JsonSchema, + Resp: JsonSchema, +{ + CustomMethodSchema { + method: method.to_string(), + params_schema: Some(generator.subschema_for::()), + params_type_name: Some(short_type_name::()), + response_schema: Some(generator.subschema_for::()), + response_type_name: Some(short_type_name::()), + } +} + +/// Schemas for every goose-custom agent → client request. Collected by the ACP +/// schema generator binary. +pub fn agent_request_schemas(generator: &mut SchemaGenerator) -> Vec { + vec![agent_request_schema::< + RequestRecipeParams, + RecipeParamsResponse, + >(generator, RECIPE_PARAMS_METHOD)] +} diff --git a/crates/goose/src/acp/server/load_session.rs b/crates/goose/src/acp/server/load_session.rs index 9600ec4312d4..ad5b1b84ee60 100644 --- a/crates/goose/src/acp/server/load_session.rs +++ b/crates/goose/src/acp/server/load_session.rs @@ -218,27 +218,7 @@ impl GooseAcpAgent { response = response.config_options(co); } - let mut meta = serde_json::Map::new(); - if let Some(recipe) = &session.recipe { - if let Ok(v) = serde_json::to_value(recipe) { - meta.insert("recipe".to_string(), v); - } - } - if let Some(values) = &session.user_recipe_values { - if let Ok(v) = serde_json::to_value(values) { - meta.insert("userRecipeValues".to_string(), v); - } - } - if let Ok(v) = serde_json::to_value(&extension_results) { - meta.insert("extensionResults".to_string(), v); - } - meta.insert( - "workingDir".to_string(), - serde_json::Value::String(session.working_dir.to_string_lossy().to_string()), - ); - if !meta.is_empty() { - response = response.meta(meta); - } + response = response.meta(session_response_meta(&session, &extension_results)); debug!( target: "perf", diff --git a/crates/goose/src/acp/server/new_session.rs b/crates/goose/src/acp/server/new_session.rs index 0cb556351436..3a0a124de70e 100644 --- a/crates/goose/src/acp/server/new_session.rs +++ b/crates/goose/src/acp/server/new_session.rs @@ -1,12 +1,25 @@ use crate::acp::custom_requests::GooseExtension; use crate::acp::server::{meta_string, validate_absolute_cwd, ResultExt}; +use crate::agents::ExtensionLoadResult; use crate::config::{Config, GooseMode}; -use crate::session::SessionType; +use crate::recipe::{Recipe, Settings}; +use crate::session::{ExtensionData, Session, SessionType}; use super::GooseAcpAgent; use agent_client_protocol::schema::{Meta, NewSessionRequest, NewSessionResponse, SessionId}; use agent_client_protocol::{Client, ConnectionTo}; +use goose_providers::model::ModelConfig; use std::collections::HashMap; +use std::path::PathBuf; + +struct InitialSessionConfig { + provider: String, + model_config: ModelConfig, + extension_data: ExtensionData, + recipe: Option, + user_recipe_values: Option>, + project_id: Option, +} impl GooseAcpAgent { pub(super) async fn handle_new_session( @@ -15,95 +28,221 @@ impl GooseAcpAgent { args: NewSessionRequest, ) -> Result { validate_absolute_cwd(&args.cwd)?; + let config = Config::global(); let project_id = meta_string(args.meta.as_ref(), "projectId")?; let session_type = match meta_string(args.meta.as_ref(), "client")? { Some(_) => SessionType::User, None => SessionType::Acp, }; - let config = Config::global(); - let (resolved_provider, resolved_model_config) = - match meta_string(args.meta.as_ref(), "provider")? { - Some(provider) => { - let model_config = - super::resolve_provider_default_model_config(&provider).await?; - (provider, model_config) - } - None => super::resolve_default_provider_model_config(config)?, - }; - let goose_extensions = meta_goose_extensions(args.meta.as_ref())?; let current_mode: GooseMode = config.get_goose_mode().unwrap_or_default(); - let mut goose_session = self + let recipe = self.resolve_recipe_from_meta(args.meta.as_ref()).await?; + let session_name = match recipe.as_ref() { + Some((recipe, _)) if !recipe.title.trim().is_empty() => recipe.title.clone(), + _ => "New Chat".to_string(), + }; + + let session = self .session_manager - .create_session( - args.cwd.clone(), - "New Chat".to_string(), - session_type, - current_mode, - ) + .create_session(args.cwd.clone(), session_name, session_type, current_mode) .await .internal_err_ctx("Failed to create session")?; - let mut builder = self.session_manager.update(&goose_session.id); + match self + .finish_new_session_setup(cx, config, &session, args, recipe, project_id) + .await + { + Ok(response) => Ok(response), + Err(error) => { + self.cleanup_failed_new_session(&session.id).await; + Err(error) + } + } + } + + async fn finish_new_session_setup( + &self, + cx: &ConnectionTo, + config: &Config, + session: &Session, + args: NewSessionRequest, + recipe: Option<(Recipe, PathBuf)>, + project_id: Option, + ) -> Result { + let rendered_recipe = self + .configure_new_session(cx, config, session, args, recipe, project_id) + .await?; + + let reloaded_session = self.reload_session(&session.id).await?; + let (agent, extension_results) = self + .activate_acp_session(cx, &reloaded_session, HashMap::new()) + .await?; + if let Some(recipe) = &rendered_recipe { + self.apply_recipe(&agent, recipe).await; + } + + let reloaded_session = self.reload_session(&session.id).await?; + let response = self + .build_new_session_response(&reloaded_session, &extension_results) + .await?; + super::send_session_setup_notifications( + cx, + &reloaded_session, + self.supports_goose_custom_notifications(), + )?; + Ok(response) + } + + async fn cleanup_failed_new_session(&self, session_id: &str) { + let _ = self.session_manager.delete_session(session_id).await; + self.sessions.lock().await.remove(session_id); + let _ = self.agent_manager.remove_session(session_id).await; + } + + async fn configure_new_session( + &self, + cx: &ConnectionTo, + config: &Config, + session: &Session, + args: NewSessionRequest, + recipe: Option<(Recipe, PathBuf)>, + project_id: Option, + ) -> Result, agent_client_protocol::Error> { + let (rendered, user_recipe_values) = self + .render_recipe_for_session(cx, &session.id, recipe.as_ref()) + .await?; + + let recipe_settings = rendered.as_ref().and_then(|r| r.settings.as_ref()); + let (provider, model_config) = self + .resolve_provider_and_model(config, args.meta.as_ref(), recipe_settings) + .await?; + + let goose_extensions = meta_goose_extensions(args.meta.as_ref())?; + let recipe_extensions = rendered.as_ref().and_then(|r| r.extensions.as_deref()); let extension_data = self.build_enabled_extensions_data( config, - &goose_session, + session, args.mcp_servers, goose_extensions, + recipe_extensions, )?; - builder = builder - .provider_name(resolved_provider) - .model_config(resolved_model_config) - .extension_data(extension_data); - if let Some(pid) = project_id { - builder = builder.project_id(Some(pid)); - } - builder - .apply() - .await - .internal_err_ctx("Failed to update session")?; - goose_session = self - .session_manager - .get_session(&goose_session.id, false) + self.apply_initial_session_config( + &session.id, + InitialSessionConfig { + provider, + model_config, + extension_data, + recipe: recipe.map(|(recipe, _)| recipe), + user_recipe_values, + project_id, + }, + ) + .await?; + + Ok(rendered) + } + + async fn reload_session( + &self, + session_id: &str, + ) -> Result { + self.session_manager + .get_session(session_id, false) .await - .internal_err_ctx("Failed to reload session")?; - let session_id_str = goose_session.id.clone(); + .internal_err_ctx("Failed to reload session") + } - let (_agent, extension_results) = self - .activate_acp_session(cx, &goose_session, HashMap::new()) - .await?; + async fn resolve_provider_and_model( + &self, + config: &Config, + meta: Option<&Meta>, + recipe_settings: Option<&Settings>, + ) -> Result<(String, ModelConfig), agent_client_protocol::Error> { + let recipe_provider = recipe_settings.and_then(|s| s.goose_provider.clone()); + let recipe_model = recipe_settings.and_then(|s| s.goose_model.clone()); + + let provider = match recipe_provider { + Some(provider) => provider, + None => match meta_string(meta, "provider")? { + Some(provider) => provider, + None => { + if let Some(model) = recipe_model.as_deref() { + let provider = config.get_goose_provider().map_err(|error| { + agent_client_protocol::Error::internal_error() + .data(format!("Failed to resolve provider: {}", error)) + })?; + let model_config = model_config_from_recipe_settings(&provider, model)?; + return Ok((provider, model_config)); + } + + return super::resolve_default_provider_model_config(config); + } + }, + }; - let goose_session = self + let model_config = match recipe_model { + Some(model) => model_config_from_recipe_settings(&provider, &model)?, + None => super::resolve_provider_default_model_config(&provider).await?, + }; + + Ok((provider, model_config)) + } + + async fn apply_initial_session_config( + &self, + session_id: &str, + config: InitialSessionConfig, + ) -> Result<(), agent_client_protocol::Error> { + let mut builder = self .session_manager - .get_session(&goose_session.id, false) + .update(session_id) + .provider_name(config.provider) + .model_config(config.model_config) + .extension_data(config.extension_data); + if let Some(recipe) = config.recipe { + builder = builder.recipe(Some(recipe)); + } + if config.user_recipe_values.is_some() { + builder = builder.user_recipe_values(config.user_recipe_values); + } + if let Some(project_id) = config.project_id { + builder = builder.project_id(Some(project_id)); + } + builder + .apply() .await - .internal_err_ctx("Failed to reload session")?; - - let acp_session_id = SessionId::new(session_id_str.clone()); + .internal_err_ctx("Failed to update session")?; + Ok(()) + } + async fn build_new_session_response( + &self, + session: &Session, + extension_results: &[ExtensionLoadResult], + ) -> Result { let (mode_state, model_state, config_options) = - super::build_session_setup_config(&self.provider_inventory, &goose_session).await?; + super::build_session_setup_config(&self.provider_inventory, session).await?; - let mut response = NewSessionResponse::new(acp_session_id.clone()).modes(mode_state); + let mut response = + NewSessionResponse::new(SessionId::new(session.id.clone())).modes(mode_state); if let Some(ms) = model_state { response = response.models(ms); } if let Some(co) = config_options { response = response.config_options(co); } - if let Ok(extension_results) = serde_json::to_value(&extension_results) { - let mut meta = serde_json::Map::new(); - meta.insert("extensionResults".to_string(), extension_results); - response = response.meta(meta); - } - super::send_session_setup_notifications( - cx, - &goose_session, - self.supports_goose_custom_notifications(), - )?; + response = response.meta(super::session_response_meta(session, extension_results)); Ok(response) } } +fn model_config_from_recipe_settings( + provider: &str, + model: &str, +) -> Result { + crate::model_config::model_config_from_user_config(provider, model) + .internal_err_ctx("Failed to build model config from recipe settings") +} + fn meta_goose_extensions( meta: Option<&Meta>, ) -> Result>, agent_client_protocol::Error> { diff --git a/crates/goose/src/acp/server/recipe.rs b/crates/goose/src/acp/server/recipe.rs new file mode 100644 index 000000000000..04f6a40cf0a9 --- /dev/null +++ b/crates/goose/src/acp/server/recipe.rs @@ -0,0 +1,263 @@ +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/bin/generate_acp_schema.rs b/crates/goose/src/bin/generate_acp_schema.rs index bfbf1f7a90ce..e152811202d8 100644 --- a/crates/goose/src/bin/generate_acp_schema.rs +++ b/crates/goose/src/bin/generate_acp_schema.rs @@ -1,5 +1,5 @@ use goose::acp::custom_notifications::custom_notification_schemas; -use goose::acp::server::GooseAcpAgent; +use goose::acp::server::{agent_request_schemas, GooseAcpAgent}; use schemars::SchemaGenerator; use serde_json::{json, Map, Value}; use std::collections::{BTreeSet, HashMap}; @@ -11,6 +11,19 @@ fn main() { let mut generator = SchemaGenerator::default(); let methods = GooseAcpAgent::custom_method_schemas(&mut generator); let notifications = custom_notification_schemas(&mut generator); + let agent_requests = agent_request_schemas(&mut generator); + + // Types used by agent → client requests are answered by the client, so + // they're tagged `x-side: "client"` rather than `"agent"`. + let client_side_type_names: BTreeSet = agent_requests + .iter() + .flat_map(|m| { + m.params_type_name + .iter() + .chain(m.response_type_name.iter()) + .cloned() + }) + .collect(); // Collect $defs from the generator (all types referenced via subschema_for). let mut defs: Map = generator @@ -21,7 +34,11 @@ fn main() { // Track which types map to which methods so we can detect shared types. let mut type_methods: HashMap> = HashMap::new(); - for m in methods.iter().chain(notifications.iter()) { + for m in methods + .iter() + .chain(notifications.iter()) + .chain(agent_requests.iter()) + { let method = m.method.clone(); if let Some(name) = &m.params_type_name { type_methods @@ -82,7 +99,12 @@ fn main() { let generated_name = generated_type_name(name, &unstable_type_names); if let Some(def) = defs.get_mut(&generated_name) { if let Some(obj) = def.as_object_mut() { - obj.insert("x-side".into(), json!("agent")); + let side = if client_side_type_names.contains(name) { + "client" + } else { + "agent" + }; + obj.insert("x-side".into(), json!(side)); if methods_list.len() == 1 { obj.insert("x-method".into(), json!(methods_list[0])); } @@ -95,7 +117,10 @@ fn main() { let mut request_variants: Vec = Vec::new(); let mut response_variants: Vec = Vec::new(); let mut notification_variants: Vec = Vec::new(); + let mut agent_request_variants: Vec = Vec::new(); + let mut agent_response_variants: Vec = Vec::new(); let mut seen_response_types: BTreeSet = BTreeSet::new(); + let mut seen_agent_response_types: BTreeSet = BTreeSet::new(); for m in &methods { if let Some(name) = &m.params_type_name { @@ -129,6 +154,27 @@ fn main() { } } + for m in &agent_requests { + if let Some(name) = &m.params_type_name { + let generated_name = generated_type_name(name, &unstable_type_names); + agent_request_variants.push(json!({ + "allOf": [{ "$ref": format!("#/$defs/{generated_name}") }], + "description": format!("Params for {}", m.method), + "title": generated_name, + })); + } + + if let Some(name) = &m.response_type_name { + let generated_name = generated_type_name(name, &unstable_type_names); + if seen_agent_response_types.insert(generated_name.clone()) { + agent_response_variants.push(json!({ + "allOf": [{ "$ref": format!("#/$defs/{generated_name}") }], + "title": generated_name, + })); + } + } + } + // Build ExtRequest — mirrors AgentRequest structure. defs.insert( "ExtRequest".into(), @@ -209,6 +255,68 @@ fn main() { }), ); + // Build ExtAgentRequest — server-initiated request (agent → client), + // structurally identical to ExtRequest but answered by the client. + defs.insert( + "ExtAgentRequest".into(), + json!({ + "properties": { + "id": { "type": "string" }, + "method": { "type": "string" }, + "params": { + "anyOf": [ + { "anyOf": agent_request_variants }, + { "description": "Untyped params", "type": ["object", "null"] }, + ] + } + }, + "required": ["id", "method"], + "type": "object", + "x-docs-ignore": true, + }), + ); + + // Build ExtAgentResponse — the client's reply (client → agent). + defs.insert( + "ExtAgentResponse".into(), + json!({ + "anyOf": [ + { + "properties": { + "id": { "type": "string" }, + "result": { + "anyOf": [ + { "anyOf": agent_response_variants }, + { "description": "Untyped result" }, + ] + } + }, + "required": ["id"], + "title": "Success", + "type": "object", + }, + { + "properties": { + "error": { + "type": "object", + "properties": { + "code": { "type": "integer" }, + "message": { "type": "string" }, + "data": {} + }, + "required": ["code", "message"], + }, + "id": { "type": "string" }, + }, + "required": ["id", "error"], + "title": "Error", + "type": "object", + } + ], + "x-docs-ignore": true, + }), + ); + // Assemble the root schema document. let root = json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -229,6 +337,16 @@ fn main() { "allOf": [{ "$ref": "#/$defs/ExtNotification" }], "description": "Extension notification (agent → client, fire-and-forget)", "title": "Notification", + }, + { + "allOf": [{ "$ref": "#/$defs/ExtAgentRequest" }], + "description": "Extension agent request (agent → client)", + "title": "AgentRequest", + }, + { + "allOf": [{ "$ref": "#/$defs/ExtAgentResponse" }], + "description": "Extension agent response (client → agent)", + "title": "AgentResponse", } ], }); @@ -271,9 +389,26 @@ fn main() { }) }) .collect(); + let agent_request_entries: Vec = agent_requests + .iter() + .map(|m| { + json!({ + "method": &m.method, + "requestType": m + .params_type_name + .as_ref() + .map(|name| generated_type_name(name, &unstable_type_names)), + "responseType": m + .response_type_name + .as_ref() + .map(|name| generated_type_name(name, &unstable_type_names)), + }) + }) + .collect(); let meta = json!({ "methods": method_entries, "notifications": notification_entries, + "agentRequests": agent_request_entries, }); let meta_str = serde_json::to_string_pretty(&meta).expect("failed to serialize meta"); let meta_path = package_path.join("acp-meta.json"); diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 3de1b8dda65e..e051d4e4e36d 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)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum RecipeParameterRequirement { Required, @@ -171,7 +171,7 @@ impl fmt::Display for RecipeParameterRequirement { } } -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum RecipeParameterInputType { String, @@ -194,7 +194,7 @@ impl fmt::Display for RecipeParameterInputType { } } -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema, schemars::JsonSchema)] pub struct RecipeParameter { pub key: String, pub input_type: RecipeParameterInputType, diff --git a/crates/goose/tests/acp_server_test.rs b/crates/goose/tests/acp_server_test.rs index 7a4c883b47cd..6cac3df41152 100644 --- a/crates/goose/tests/acp_server_test.rs +++ b/crates/goose/tests/acp_server_test.rs @@ -2,8 +2,9 @@ #[path = "acp_common_tests/mod.rs"] mod common_tests; use agent_client_protocol::schema::{ - ListSessionsRequest, ListSessionsResponse, SessionConfigKind, SessionConfigOptionCategory, - SessionConfigOptionValue, SessionInfo, SetSessionConfigOptionRequest, + ListSessionsRequest, ListSessionsResponse, NewSessionRequest, SessionConfigKind, + SessionConfigOptionCategory, SessionConfigOptionValue, SessionInfo, + SetSessionConfigOptionRequest, }; use agent_client_protocol::ErrorCode; use common_tests::fixtures::server::AcpServerConnection; @@ -25,6 +26,8 @@ use common_tests::{ use goose::config::GooseMode; use goose::conversation::message::{Message, MessageMetadata}; use goose::custom_requests::{GetSessionInfoRequest, GetSessionInfoResponse}; +use goose::recipe::{Recipe, Settings}; +use goose::recipe_deeplink; use goose::session::{SessionManager, SessionType}; use std::path::Path; @@ -592,6 +595,91 @@ fn test_new_session_uses_current_config_mode() { run_test(async { run_new_session_uses_current_config_mode::().await }); } +#[test] +fn test_new_session_honors_recipe_model_without_recipe_provider() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let conn = new_connection(data_root.path()).await; + let work_dir = tempfile::tempdir().unwrap(); + let recipe_model = "gpt-4.1"; + let recipe = Recipe::builder() + .title("Recipe model") + .description("A recipe that only overrides the model") + .instructions("Use the requested model") + .settings(Settings { + goose_provider: None, + goose_model: Some(recipe_model.to_string()), + temperature: None, + max_turns: None, + }) + .build() + .unwrap(); + let mut meta = serde_json::Map::new(); + meta.insert( + "recipeDeeplink".to_string(), + serde_json::Value::String(recipe_deeplink::encode(&recipe).unwrap()), + ); + + let response = conn + .cx() + .send_request(NewSessionRequest::new(work_dir.path()).meta(meta)) + .block_task() + .await + .unwrap(); + let session_info = get_session_info_request( + &conn, + GetSessionInfoRequest { + session_id: response.session_id.0.to_string(), + }, + ) + .await + .unwrap(); + let meta = session_info + .session + .meta + .expect("session info should include meta"); + + assert_eq!( + meta.get("modelId").and_then(|v| v.as_str()), + Some(recipe_model) + ); + assert_eq!( + meta.get("providerId").and_then(|v| v.as_str()), + Some("openai") + ); + }); +} + +#[test] +fn test_new_session_cleans_up_when_config_fails() { + run_test(async { + let data_root = tempfile::tempdir().unwrap(); + let conn = new_connection(data_root.path()).await; + let work_dir = tempfile::tempdir().unwrap(); + let mut meta = serde_json::Map::new(); + meta.insert( + "enabledExtensions".to_string(), + serde_json::Value::String("invalid".to_string()), + ); + + let error: anyhow::Error = conn + .cx() + .send_request(NewSessionRequest::new(work_dir.path()).meta(meta)) + .block_task() + .await + .unwrap_err() + .into(); + + assert_invalid_params(error); + + let sessions = SessionManager::new(data_root.path().to_path_buf()) + .list_all_sessions() + .await + .unwrap(); + assert!(sessions.is_empty()); + }); +} + #[test] fn test_model_set() { run_test(async { run_model_set::().await }); diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 00d6878cb5d5..64128f965a61 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -12,6 +12,8 @@ import { openSharedSessionFromDeepLink, importNostrSessionFromDeepLink } from '. import { type SharedSessionDetails } from './sharedSessions'; import { ErrorUI } from './components/ErrorBoundary'; import { ExtensionInstallModal } from './components/ExtensionInstallModal'; +import RecipeParamsModalContainer from './components/RecipeParamsModalContainer'; +import { isRecipeParamsCancelled } from './acp/errors'; import { toast, ToastContainer } from 'react-toastify'; import AnnouncementModal from './components/AnnouncementModal'; import TelemetryConsentPrompt from './components/TelemetryConsentPrompt'; @@ -96,7 +98,8 @@ const PairRouteWrapper = ({ const routeState = (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; const [searchParams, setSearchParams] = useSearchParams(); - const [isCreatingSession, setIsCreatingSession] = useState(false); + const isCreatingSessionRef = useRef(false); + const navigate = useNavigate(); const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined; @@ -109,9 +112,9 @@ const PairRouteWrapper = ({ if ( (initialMessage || recipeDeeplinkFromConfig || recipeIdFromConfig) && !resumeSessionId && - !isCreatingSession + !isCreatingSessionRef.current ) { - setIsCreatingSession(true); + isCreatingSessionRef.current = true; (async () => { try { @@ -137,6 +140,10 @@ const PairRouteWrapper = ({ return prev; }); } catch (error) { + if (isRecipeParamsCancelled(error)) { + navigate('/'); + return; + } console.error('Failed to create session:', error); trackErrorWithContext(error, { component: 'PairRouteWrapper', @@ -144,12 +151,10 @@ const PairRouteWrapper = ({ recoverable: true, }); } finally { - setIsCreatingSession(false); + isCreatingSessionRef.current = false; } })(); } - // Note: isCreatingSession is intentionally NOT in the dependency array - // It's only used as a guard to prevent concurrent session creation // eslint-disable-next-line react-hooks/exhaustive-deps }, [ initialMessage, @@ -668,6 +673,7 @@ export function AppInner() { pauseOnHover /> +
diff --git a/ui/desktop/src/acp/__tests__/recipeParamRequests.test.ts b/ui/desktop/src/acp/__tests__/recipeParamRequests.test.ts new file mode 100644 index 000000000000..87291d5d158d --- /dev/null +++ b/ui/desktop/src/acp/__tests__/recipeParamRequests.test.ts @@ -0,0 +1,137 @@ +import type { RequestRecipeParams_unstable } from '@aaif/goose-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + cancelAcpRecipeParamRequest, + getAcpRecipeParamRequestsSnapshot, + requestAcpRecipeParams, + resolveAcpRecipeParamRequest, +} from '../recipeParamRequests'; + +vi.mock('../../acpChatFeatureFlag', () => ({ + USE_ACP_CHAT: true, +})); + +function recipeParamRequest(): RequestRecipeParams_unstable { + return { + sessionId: 'session-1', + parameters: [ + { + key: 'topic', + description: 'Topic', + input_type: 'string', + requirement: 'user_prompt', + }, + ], + }; +} + +function optionalRecipeParamRequest(): RequestRecipeParams_unstable { + return { + sessionId: 'session-1', + parameters: [ + { + key: 'tone', + description: 'Tone', + input_type: 'string', + requirement: 'optional', + default: 'concise', + }, + ], + }; +} + +function setRecipeParameters(values: Record): void { + Object.defineProperty(window, 'appConfig', { + configurable: true, + value: { + get: vi.fn((key: string) => (key === 'recipeParameters' ? values : undefined)), + }, + }); +} + +function cancelPendingRecipeParamRequests(): void { + for (const request of getAcpRecipeParamRequestsSnapshot()) { + cancelAcpRecipeParamRequest(request.id); + } +} + +describe('ACP recipe param requests', () => { + beforeEach(() => { + cancelPendingRecipeParamRequests(); + }); + + afterEach(() => { + cancelPendingRecipeParamRequests(); + Reflect.deleteProperty(window, 'appConfig'); + }); + + it('keeps missing user_prompt parameters pending for user input', async () => { + setRecipeParameters({}); + + const response = requestAcpRecipeParams(recipeParamRequest()); + const [pendingRequest] = getAcpRecipeParamRequestsSnapshot(); + + expect(pendingRequest).toMatchObject({ + sessionId: 'session-1', + parameters: [ + { + key: 'topic', + requirement: 'user_prompt', + }, + ], + initialValues: {}, + }); + + cancelAcpRecipeParamRequest(pendingRequest.id); + await expect(response).resolves.toEqual({ action: 'cancel' }); + }); + + it('keeps user_prompt parameters pending when configured values are available', async () => { + setRecipeParameters({ topic: 'release notes' }); + + const response = requestAcpRecipeParams(recipeParamRequest()); + const [pendingRequest] = getAcpRecipeParamRequestsSnapshot(); + + expect(pendingRequest).toMatchObject({ + sessionId: 'session-1', + parameters: [ + { + key: 'topic', + requirement: 'user_prompt', + }, + ], + initialValues: { topic: 'release notes' }, + }); + + resolveAcpRecipeParamRequest(pendingRequest.id, { topic: 'release notes' }); + await expect(response).resolves.toEqual({ + action: 'submit', + values: { topic: 'release notes' }, + }); + }); + + it('keeps optional-only parameters pending for user confirmation', async () => { + setRecipeParameters({}); + + const response = requestAcpRecipeParams(optionalRecipeParamRequest()); + const [pendingRequest] = getAcpRecipeParamRequestsSnapshot(); + + expect(pendingRequest).toMatchObject({ + sessionId: 'session-1', + parameters: [ + { + key: 'tone', + requirement: 'optional', + default: 'concise', + }, + ], + initialValues: {}, + }); + + resolveAcpRecipeParamRequest(pendingRequest.id, { tone: 'detailed' }); + await expect(response).resolves.toEqual({ + action: 'submit', + values: { tone: 'detailed' }, + }); + }); +}); diff --git a/ui/desktop/src/acp/acpConnection.ts b/ui/desktop/src/acp/acpConnection.ts index 1622ee772ca5..1680b2b9456e 100644 --- a/ui/desktop/src/acp/acpConnection.ts +++ b/ui/desktop/src/acp/acpConnection.ts @@ -12,6 +12,7 @@ import { import { createWebSocketStream } from './createWebSocketStream'; import { requestAcpElicitation } from './elicitationRequests'; import { requestAcpPermission } from './permissionRequests'; +import { requestAcpRecipeParams } from './recipeParamRequests'; let clientPromise: Promise | null = null; let resolvedClient: GooseClient | null = null; @@ -20,6 +21,7 @@ function createClientCallbacks(): () => GooseClientCallbacks { return () => ({ requestPermission: requestAcpPermission, unstable_createElicitation: requestAcpElicitation, + unstable_sessionRecipeRequestParams: requestAcpRecipeParams, sessionUpdate: handleAcpSessionNotification, unstable_sessionUpdate: handleAcpGooseSessionNotification, }); @@ -54,6 +56,7 @@ async function initializeConnection(): Promise { goose: { mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES, customNotifications: true, + recipeParameterRequests: true, }, }, }, diff --git a/ui/desktop/src/acp/chatSessionController.ts b/ui/desktop/src/acp/chatSessionController.ts index cf05dc200e31..ecd4c8c8890d 100644 --- a/ui/desktop/src/acp/chatSessionController.ts +++ b/ui/desktop/src/acp/chatSessionController.ts @@ -22,6 +22,7 @@ import { acpTruncateSessionConversation, isAcpSessionLoadInFlight, sessionInfoToSession, + type AcpRecipeOptions, } from './sessions'; export interface AcpLoadSessionOptions { @@ -37,7 +38,11 @@ export interface AcpSubmitMessageOptions extends AcpSnapshotOptions { } export interface AcpChatSessionController { - createSession(cwd: string, gooseExtensions: GooseExtension[]): Promise; + createSession( + cwd: string, + gooseExtensions: GooseExtension[], + recipe?: AcpRecipeOptions + ): Promise; loadSession(sessionId: string, options?: AcpLoadSessionOptions): Promise; submitMessage( sessionId: string, @@ -76,8 +81,12 @@ function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Mess }; } -async function createSession(cwd: string, gooseExtensions: GooseExtension[]): Promise { - const { sessionId, sessionInfo, meta } = await acpNewSession(cwd, gooseExtensions); +async function createSession( + cwd: string, + gooseExtensions: GooseExtension[], + recipe?: AcpRecipeOptions +): Promise { + const { sessionId, sessionInfo, meta } = await acpNewSession(cwd, gooseExtensions, recipe); const session = sessionInfoToSession(sessionInfo, meta); showExtensionLoadResults(meta.extensionResults); diff --git a/ui/desktop/src/acp/errors.ts b/ui/desktop/src/acp/errors.ts index aec28b2ba8aa..5e2dc86b9630 100644 --- a/ui/desktop/src/acp/errors.ts +++ b/ui/desktop/src/acp/errors.ts @@ -5,6 +5,13 @@ export interface AcpCreditsExhaustedError { const CREDITS_EXHAUSTED_REASON = 'credits_exhausted'; +// Kept in sync with RECIPE_PARAMS_CANCELLED_REASON in crates/goose/src/acp/server/recipe.rs. +const RECIPE_PARAMS_CANCELLED_REASON = 'recipe_params_cancelled'; + +export function isRecipeParamsCancelled(error: unknown): boolean { + return asAcpJsonRpcError(error)?.data?.reason === RECIPE_PARAMS_CANCELLED_REASON; +} + export function parseAcpCreditsExhaustedError(error: unknown): AcpCreditsExhaustedError | null { const jsonRpcError = asAcpJsonRpcError(error); if (jsonRpcError?.data?.reason !== CREDITS_EXHAUSTED_REASON) { diff --git a/ui/desktop/src/acp/recipeParamRequests.ts b/ui/desktop/src/acp/recipeParamRequests.ts new file mode 100644 index 000000000000..e00328df3391 --- /dev/null +++ b/ui/desktop/src/acp/recipeParamRequests.ts @@ -0,0 +1,90 @@ +import type { + RecipeParameter, + RecipeParamsResponse_unstable, + RequestRecipeParams_unstable, +} from '@aaif/goose-sdk'; +import { v7 as uuidv7 } from 'uuid'; +import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; + +export interface AcpRecipeParamRequest { + id: string; + sessionId: string; + parameters: RecipeParameter[]; + initialValues?: Record; +} + +interface PendingRecipeParamRequest { + request: AcpRecipeParamRequest; + resolve: (response: RecipeParamsResponse_unstable) => void; +} + +const pendingRequests = new Map(); +const listeners = new Set<() => void>(); +let snapshot: AcpRecipeParamRequest[] = []; + +function emit(): void { + snapshot = Array.from(pendingRequests.values(), (pending) => pending.request); + for (const listener of listeners) { + listener(); + } +} + +export function subscribeAcpRecipeParamRequests(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getAcpRecipeParamRequestsSnapshot(): AcpRecipeParamRequest[] { + return snapshot; +} + +function configuredParameterValues(): Record { + const configured = window.appConfig?.get('recipeParameters') as + | Record + | undefined; + return configured ?? {}; +} + +export async function requestAcpRecipeParams( + request: RequestRecipeParams_unstable +): Promise { + if (!USE_ACP_CHAT) { + return { action: 'cancel' }; + } + + const initialValues = configuredParameterValues(); + const paramRequest: AcpRecipeParamRequest = { + id: `acp_recipe_params_${uuidv7()}`, + sessionId: request.sessionId, + parameters: request.parameters, + initialValues, + }; + + return new Promise((resolve) => { + pendingRequests.set(paramRequest.id, { request: paramRequest, resolve }); + emit(); + }); +} + +export function resolveAcpRecipeParamRequest(id: string, values: Record): boolean { + const pending = pendingRequests.get(id); + if (!pending) { + return false; + } + pendingRequests.delete(id); + emit(); + pending.resolve({ action: 'submit', values }); + return true; +} + +export function cancelAcpRecipeParamRequest(id: string): void { + const pending = pendingRequests.get(id); + if (!pending) { + return; + } + pendingRequests.delete(id); + emit(); + pending.resolve({ action: 'cancel' }); +} diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index fb99b060f616..4dee3fed161b 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -209,15 +209,26 @@ export interface AcpNewSessionResult { meta: LoadSessionMeta; } +export interface AcpRecipeOptions { + recipeId?: string; + recipeDeeplink?: string; +} + export async function acpNewSession( cwd: string, - gooseExtensions: GooseExtension[] + gooseExtensions: GooseExtension[], + recipe?: AcpRecipeOptions ): Promise { const client = await getAcpClient(); const meta: Record = { client: 'goose-desktop' }; if (gooseExtensions.length > 0) { meta.enabledExtensions = gooseExtensions; } + if (recipe?.recipeId) { + meta.recipeId = recipe.recipeId; + } else if (recipe?.recipeDeeplink) { + meta.recipeDeeplink = recipe.recipeDeeplink; + } const request: NewSessionRequest = { cwd, mcpServers: [], _meta: meta }; const response = await client.newSession(request); const sessionId = String(response.sessionId); diff --git a/ui/desktop/src/acpChatFeatureFlag.ts b/ui/desktop/src/acpChatFeatureFlag.ts index 34ebb17537a1..edea9d78a04d 100644 --- a/ui/desktop/src/acpChatFeatureFlag.ts +++ b/ui/desktop/src/acpChatFeatureFlag.ts @@ -1 +1 @@ -export const USE_ACP_CHAT = false; +export const USE_ACP_CHAT = false; \ No newline at end of file diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 058712d582cc..0b27450b1afe 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -18,7 +18,7 @@ import { useNavigationContextSafe } from './Layout/NavigationContext'; import { cn } from '../utils'; import { useChatSession } from '../hooks/useChatSession'; import { USE_ACP_CHAT } from '../acpChatFeatureFlag'; -import { acpUpdateWorkingDir } from '../acp/sessions'; +import { acpDeleteSession, acpUpdateWorkingDir } from '../acp/sessions'; import { useNavigation } from '../hooks/useNavigation'; import { RecipeHeader } from './RecipeHeader'; import { RecipeWarningModal } from './ui/RecipeWarningModal'; @@ -240,9 +240,20 @@ export default function BaseChat({ if (recipe && accept) { await window.electron.recordRecipeHash(recipe); setHasNotAcceptedRecipe(false); - } else { - setView('chat'); + return; } + + if (sessionId) { + try { + await acpDeleteSession(sessionId); + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId } }) + ); + } catch (error) { + console.error('Failed to delete declined recipe session:', error); + } + } + setView('chat'); }; // Track if this is the initial render for session resuming diff --git a/ui/desktop/src/components/ParameterInputModal.tsx b/ui/desktop/src/components/ParameterInputModal.tsx index c20a05c82d71..bb79732fb3de 100644 --- a/ui/desktop/src/components/ParameterInputModal.tsx +++ b/ui/desktop/src/components/ParameterInputModal.tsx @@ -61,6 +61,10 @@ interface ParameterInputModalProps { initialValues?: Record; } +function needsUserValue(param: Parameter): boolean { + return param.requirement === 'required' || param.requirement === 'user_prompt'; +} + const ParameterInputModal: React.FC = ({ parameters, onSubmit, @@ -93,7 +97,7 @@ const ParameterInputModal: React.FC = ({ const handleSubmit = (): void => { setValidationErrors({}); - const requiredParams: Parameter[] = parameters.filter((p) => p.requirement === 'required'); + const requiredParams: Parameter[] = parameters.filter(needsUserValue); const errors: Record = {}; requiredParams.forEach((param) => { @@ -159,9 +163,7 @@ const ParameterInputModal: React.FC = ({ className="block text-md font-medium text-text-primary mb-2" > {param.description || param.key} - {param.requirement === 'required' && ( - * - )} + {needsUserValue(param) && *} {param.input_type === 'select' && param.options ? ( diff --git a/ui/desktop/src/components/RecipeParamsModalContainer.tsx b/ui/desktop/src/components/RecipeParamsModalContainer.tsx new file mode 100644 index 000000000000..a4c98052c7b3 --- /dev/null +++ b/ui/desktop/src/components/RecipeParamsModalContainer.tsx @@ -0,0 +1,30 @@ +import React, { useSyncExternalStore } from 'react'; +import { + cancelAcpRecipeParamRequest, + getAcpRecipeParamRequestsSnapshot, + resolveAcpRecipeParamRequest, + subscribeAcpRecipeParamRequests, +} from '../acp/recipeParamRequests'; +import type { Parameter } from '../recipe'; +import ParameterInputModal from './ParameterInputModal'; + +export default function RecipeParamsModalContainer(): React.ReactElement | null { + const requests = useSyncExternalStore( + subscribeAcpRecipeParamRequests, + getAcpRecipeParamRequestsSnapshot + ); + const request = requests[0]; + if (!request) { + return null; + } + + return ( + resolveAcpRecipeParamRequest(request.id, values)} + onClose={() => cancelAcpRecipeParamRequest(request.id)} + /> + ); +} diff --git a/ui/desktop/src/components/__tests__/ParameterInputModal.test.tsx b/ui/desktop/src/components/__tests__/ParameterInputModal.test.tsx index a3394826daa4..847ac97070d6 100644 --- a/ui/desktop/src/components/__tests__/ParameterInputModal.test.tsx +++ b/ui/desktop/src/components/__tests__/ParameterInputModal.test.tsx @@ -92,6 +92,31 @@ describe('ParameterInputModal', () => { }); expect(defaultProps.onSubmit).not.toHaveBeenCalled(); }); + + it('shows validation errors for user-prompt parameters', async () => { + const user = userEvent.setup(); + renderWithIntl( + + ); + + const submitButton = screen.getByText('Start Recipe'); + await user.click(submitButton); + + await waitFor(() => { + expect(screen.getByText('Topic is required')).toBeInTheDocument(); + }); + expect(defaultProps.onSubmit).not.toHaveBeenCalled(); + }); }); describe('Cancel Behavior', () => { diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index e9e97782d829..d2786eaa0bdf 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -25,11 +25,12 @@ import { useEscapeKey } from '../../hooks/useEscapeKey'; import { deleteRecipe, RecipeManifest, - startAgent, scheduleRecipe, setRecipeSlashCommand, recipeToYaml, } from '../../api'; +import { createSession } from '../../sessions'; +import { isRecipeParamsCancelled } from '../../acp/errors'; import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm'; import CreateEditRecipeModal from './CreateEditRecipeModal'; import { generateDeepLink } from '../../recipe'; @@ -378,14 +379,7 @@ export default function RecipesView() { const handleStartRecipeChat = async (recipeId: string) => { try { - const newAgent = await startAgent({ - body: { - working_dir: getInitialWorkingDir(), - recipe_id: recipeId, - }, - throwOnError: true, - }); - const session = newAgent.data; + const session = await createSession(getInitialWorkingDir(), { recipeId }); trackRecipeStarted(true, undefined, false); window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED, { detail: { session } })); @@ -398,10 +392,14 @@ export default function RecipesView() { : undefined, }); } catch (error) { + if (isRecipeParamsCancelled(error)) { + setView('chat'); + return; + } console.error('Failed to load recipe:', error); const errorMsg = errorMessage(error, 'Failed to load recipe'); trackRecipeStarted(false, getErrorType(error), false); - setError(errorMsg); + toastError({ title: intl.formatMessage(i18n.errorLoadingRecipes), msg: errorMsg }); } }; diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 41e51aec2b87..67de82af7749 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -3,7 +3,6 @@ import { DEFAULT_CHAT_TITLE } from './contexts/ChatContext'; import type { setViewType } from './hooks/useNavigation'; import type { FixedExtensionEntry } from './components/ConfigContext'; import { AppEvents } from './constants/events'; -import { decodeRecipe, Recipe } from './recipe'; import { USE_ACP_CHAT } from './acpChatFeatureFlag'; import { acpChatSessionController } from './acp/chatSessionController'; import { getConfiguredGooseExtensions, gooseExtensionName } from './acp/extensions'; @@ -76,21 +75,23 @@ async function createAcpSession( .filter((entry) => selectedNames.has(gooseExtensionName(entry.extension))) .map((entry) => entry.extension) : []; - return acpChatSessionController.createSession(workingDir, gooseExtensions); + return acpChatSessionController.createSession(workingDir, gooseExtensions, { + recipeId: options?.recipeId, + recipeDeeplink: options?.recipeDeeplink, + }); } export async function createSession( workingDir: string, options?: CreateSessionOptions ): Promise { - const hasRecipe = Boolean(options?.recipeId || options?.recipeDeeplink); - if (USE_ACP_CHAT && !hasRecipe) { + if (USE_ACP_CHAT) { return createAcpSession(workingDir, options); } const body: { working_dir: string; - recipe?: Recipe; + recipe_deeplink?: string; recipe_id?: string; extension_overrides?: ExtensionConfig[]; } = { @@ -100,7 +101,7 @@ export async function createSession( if (options?.recipeId) { body.recipe_id = options.recipeId; } else if (options?.recipeDeeplink) { - body.recipe = await decodeRecipe(options.recipeDeeplink); + body.recipe_deeplink = options.recipeDeeplink; } const extensionConfigs = selectedExtensionConfigs(options); diff --git a/ui/sdk/generate-schema.ts b/ui/sdk/generate-schema.ts index 8d67c5d1b356..b9dc6112054f 100644 --- a/ui/sdk/generate-schema.ts +++ b/ui/sdk/generate-schema.ts @@ -74,6 +74,7 @@ async function postProcessTypes() { async function postProcessIndex(meta: { methods: unknown[]; notifications?: unknown[]; + agentRequests?: unknown[]; }) { const indexPath = resolve(OUTPUT_DIR, "index.ts"); let src = await fs.readFile(indexPath, "utf8"); @@ -95,6 +96,10 @@ export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number]; export const GOOSE_EXT_NOTIFICATIONS = ${JSON.stringify(meta.notifications ?? [], null, 2)} as const; export type GooseExtNotification = (typeof GOOSE_EXT_NOTIFICATIONS)[number]; + +export const GOOSE_EXT_AGENT_REQUESTS = ${JSON.stringify(meta.agentRequests ?? [], null, 2)} as const; + +export type GooseExtAgentRequest = (typeof GOOSE_EXT_AGENT_REQUESTS)[number]; `, { parser: "typescript" }, ); @@ -138,6 +143,12 @@ interface NotificationMeta { paramsType: string | null; } +interface AgentRequestMeta { + method: string; + requestType: string | null; + responseType: string | null; +} + function methodToHandlerName(method: string): string { let methodParts = method.split(/[/_]/).filter((part) => part.length > 0); let prefix = ""; @@ -186,6 +197,7 @@ function methodToCamelCase(method: string): string { async function generateClient(meta: { methods: MethodMeta[]; notifications?: NotificationMeta[]; + agentRequests?: AgentRequestMeta[]; }) { const typeImports = new Set(); const zodImports = new Set(); @@ -269,10 +281,77 @@ async function generateClient(meta: { ); } + const agentRequestHandlerFields: string[] = []; + const agentRequestDispatchCases: string[] = []; + + for (const r of meta.agentRequests ?? []) { + const handlerName = methodToHandlerName(r.method); + const argType = r.requestType ?? "Record"; + const retType = r.responseType ?? "Record"; + + if (r.requestType) typeImports.add(r.requestType); + if (r.responseType) typeImports.add(r.responseType); + + agentRequestHandlerFields.push( + ` ${handlerName}?: (request: ${argType}) => Promise<${retType}>;`, + ); + + const parseLine = r.requestType + ? (() => { + zodImports.add(`z${r.requestType}`); + return `const parsed = z${r.requestType}.parse(params) as ${r.requestType};`; + })() + : `const parsed = params as Record;`; + + agentRequestDispatchCases.push( + ` case "${r.method}": { + if (callbacks.${handlerName}) { + ${parseLine} + return await callbacks.${handlerName}(parsed); + } + if (callbacks.extMethod) { + return await callbacks.extMethod(method, params); + } + throw new Error(\`unhandled ext method: \${method}\`); + }`, + ); + } + const handlersInterface = `export interface GooseExtNotifications { ${handlerFields.join("\n")} }`; + const agentRequestsInterface = `export interface GooseExtAgentRequests { +${agentRequestHandlerFields.join("\n")} +}`; + + const agentRequestDispatcherFn = `export function installGooseExtAgentRequestDispatcher( + callbacks: GooseClientCallbacks, +): Client { + const dispatcher: Pick = { + extMethod: async (method, params) => { + switch (method) { +${agentRequestDispatchCases.join("\n")} + default: + if (callbacks.extMethod) { + return await callbacks.extMethod(method, params); + } + throw new Error(\`unhandled ext method: \${method}\`); + } + }, + }; + return new Proxy(callbacks, { + get(target, property) { + if (property === "extMethod") { + return dispatcher.extMethod; + } + + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Client; +}`; + const dispatcherFn = `export function installGooseExtNotificationDispatcher( callbacks: GooseClientCallbacks, ): Client { @@ -323,12 +402,17 @@ ${methodDefs.join("\n")} ${handlersInterface} +${agentRequestsInterface} + export type GooseClientCallbacks = - Omit & - Partial> & - GooseExtNotifications; + Omit & + Partial> & + GooseExtNotifications & + GooseExtAgentRequests; ${dispatcherFn} + +${agentRequestDispatcherFn} `; src = await prettier.format(src, { parser: "typescript" }); diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index f4303b6ae566..6d9277e0040f 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -92,11 +92,13 @@ import type { ProviderSupportedModelsListResponse_unstable, ReadResourceRequest_unstable, ReadResourceResponse_unstable, + RecipeParamsResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_unstable, + RequestRecipeParams_unstable, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SteerSessionRequest_unstable, @@ -144,6 +146,7 @@ import { zProviderSupportedModelsListResponse_unstable, zReadResourceResponse_unstable, zRefreshProviderInventoryResponse_unstable, + zRequestRecipeParams_unstable, zSteerSessionResponse_unstable, zUpdateSourceResponse_unstable, } from './zod.gen.js'; @@ -781,9 +784,19 @@ export interface GooseExtNotifications { ) => Promise; } -export type GooseClientCallbacks = Omit & - Partial> & - GooseExtNotifications; +export interface GooseExtAgentRequests { + unstable_sessionRecipeRequestParams?: ( + request: RequestRecipeParams_unstable, + ) => Promise; +} + +export type GooseClientCallbacks = Omit< + Client, + "extNotification" | "extMethod" +> & + Partial> & + GooseExtNotifications & + GooseExtAgentRequests; export function installGooseExtNotificationDispatcher( callbacks: GooseClientCallbacks, @@ -815,3 +828,41 @@ export function installGooseExtNotificationDispatcher( }, }) as Client; } + +export function installGooseExtAgentRequestDispatcher( + callbacks: GooseClientCallbacks, +): Client { + const dispatcher: Pick = { + extMethod: async (method, params) => { + switch (method) { + case "_goose/unstable/session/recipe/request-params": { + if (callbacks.unstable_sessionRecipeRequestParams) { + const parsed = zRequestRecipeParams_unstable.parse( + params, + ) as RequestRecipeParams_unstable; + return await callbacks.unstable_sessionRecipeRequestParams(parsed); + } + if (callbacks.extMethod) { + return await callbacks.extMethod(method, params); + } + throw new Error(`unhandled ext method: ${method}`); + } + default: + if (callbacks.extMethod) { + return await callbacks.extMethod(method, params); + } + throw new Error(`unhandled ext method: ${method}`); + } + }, + }; + return new Proxy(callbacks, { + get(target, property) { + if (property === "extMethod") { + return dispatcher.extMethod; + } + + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Client; +} diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index bc1346f4c8af..50f6a0bb44ae 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, 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, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveSessionExtensionRequest_unstable, RenameSessionRequest_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, 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 const GOOSE_EXT_METHODS = [ { @@ -320,3 +320,13 @@ export const GOOSE_EXT_NOTIFICATIONS = [ ] as const; export type GooseExtNotification = (typeof GOOSE_EXT_NOTIFICATIONS)[number]; + +export const GOOSE_EXT_AGENT_REQUESTS = [ + { + method: "_goose/unstable/session/recipe/request-params", + requestType: "RequestRecipeParams_unstable", + responseType: "RecipeParamsResponse_unstable", + }, +] as const; + +export type GooseExtAgentRequest = (typeof GOOSE_EXT_AGENT_REQUESTS)[number]; diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index 1eb028c6a731..b2cd9179e137 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -1596,6 +1596,33 @@ export type StatusMessageUpdate = { status: StatusMessage; }; +export type RequestRecipeParams_unstable = { + sessionId: string; + 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?: { + [key: string]: string; + }; +}; + +export type RecipeParamsAction = 'submit' | 'cancel'; + export type ExtRequest = { id: string; method: string; @@ -1622,3 +1649,23 @@ export type ExtNotification = { [key: string]: unknown; } | null; }; + +export type ExtAgentRequest = { + id: string; + method: string; + params?: RequestRecipeParams_unstable | { + [key: string]: unknown; + } | null; +}; + +export type ExtAgentResponse = { + id: string; + result?: RecipeParamsResponse_unstable | unknown; +} | { + error: { + code: number; + message: string; + data?: unknown; + }; + id: string; +}; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 3345667f08bc..1f6f3604dd7a 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -1568,6 +1568,48 @@ 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) +}); + +export const zRecipeParamsAction = z.enum(['submit', 'cancel']); + +export const zRecipeParamsResponse_unstable = z.object({ + action: zRecipeParamsAction.optional().default('submit'), + values: z.record(z.string()).optional().default({}) +}); + export const zExtRequest = z.object({ id: z.string(), method: z.string(), @@ -1708,3 +1750,33 @@ export const zExtNotification = z.object({ ]) ]).optional() }); + +export const zExtAgentRequest = z.object({ + id: z.string(), + method: z.string(), + params: z.union([ + zRequestRecipeParams_unstable, + z.union([ + z.record(z.unknown()), + z.null() + ]) + ]).optional() +}); + +export const zExtAgentResponse = z.union([ + z.object({ + id: z.string(), + result: z.union([ + zRecipeParamsResponse_unstable, + z.unknown() + ]).optional() + }), + z.object({ + error: z.object({ + code: z.number().int(), + message: z.string(), + data: z.unknown().optional() + }), + id: z.string() + }) +]); diff --git a/ui/sdk/src/goose-client.ts b/ui/sdk/src/goose-client.ts index f969f7a570be..ca816b5f64ff 100644 --- a/ui/sdk/src/goose-client.ts +++ b/ui/sdk/src/goose-client.ts @@ -27,6 +27,7 @@ import { } from "@agentclientprotocol/sdk"; import { GooseExtClient, + installGooseExtAgentRequestDispatcher, installGooseExtNotificationDispatcher, type GooseClientCallbacks, } from "./generated/client.gen.js"; @@ -45,7 +46,9 @@ export class GooseClient { ? createHttpStream(streamOrUrl) : streamOrUrl; const toAcpClient = () => - installGooseExtNotificationDispatcher(toClient()); + installGooseExtAgentRequestDispatcher( + installGooseExtNotificationDispatcher(toClient()), + ); this.conn = new ClientSideConnection(toAcpClient, stream); this.ext = new GooseExtClient(this.conn); } diff --git a/ui/sdk/tests/client-callbacks.test.ts b/ui/sdk/tests/client-callbacks.test.ts index e2b4bdc0e8d2..c9dbfa6c1bde 100644 --- a/ui/sdk/tests/client-callbacks.test.ts +++ b/ui/sdk/tests/client-callbacks.test.ts @@ -1,7 +1,14 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { installGooseExtNotificationDispatcher } from "../src/generated/client.gen.ts"; -import type { GooseSessionNotification_unstable } from "../src/generated/types.gen.ts"; +import { + installGooseExtAgentRequestDispatcher, + installGooseExtNotificationDispatcher, +} from "../src/generated/client.gen.ts"; +import type { + GooseSessionNotification_unstable, + RecipeParamsResponse_unstable, + RequestRecipeParams_unstable, +} from "../src/generated/types.gen.ts"; import type { RequestPermissionRequest, RequestPermissionResponse, @@ -52,6 +59,54 @@ class MinimalCallbacks { async sessionUpdate(_params: SessionNotification): Promise {} } +class AgentRequestCallbacks extends MinimalCallbacks { + events: string[] = []; + + async unstable_sessionRecipeRequestParams( + request: RequestRecipeParams_unstable, + ): Promise { + this.events.push(`typed:${request.sessionId}`); + return { action: "submit", values: { name: "Ada" } }; + } + + async extMethod( + method: string, + _params: Record, + ): Promise> { + this.events.push(`extMethod:${method}`); + return { action: "cancel" }; + } +} + +class GenericAgentRequestCallbacks extends MinimalCallbacks { + events: string[] = []; + + async extMethod( + method: string, + _params: Record, + ): Promise> { + this.events.push(`extMethod:${method}`); + return { action: "cancel" }; + } +} + +const recipeParamRequest: RequestRecipeParams_unstable = { + sessionId: "session-1", + parameters: [ + { + key: "name", + input_type: "string", + requirement: "user_prompt", + description: "Name", + }, + ], +}; + +const recipeParamRequestParams = recipeParamRequest as unknown as Record< + string, + unknown +>; + test("dispatcher preserves class-backed callback receivers", async () => { const callbacks = new ClassBackedCallbacks(); const client = installGooseExtNotificationDispatcher(callbacks); @@ -83,3 +138,44 @@ test("raw extNotification is optional", async () => { await client.extNotification!("example/unknown", {}); }); + +test("agent request dispatcher prefers typed callbacks", async () => { + const callbacks = new AgentRequestCallbacks(); + const client = installGooseExtAgentRequestDispatcher(callbacks); + + const response = await client.extMethod!( + "_goose/unstable/session/recipe/request-params", + recipeParamRequestParams, + ); + + assert.deepEqual(response, { action: "submit", values: { name: "Ada" } }); + assert.deepEqual(callbacks.events, ["typed:session-1"]); +}); + +test("agent request dispatcher falls back to raw extMethod", async () => { + const callbacks = new GenericAgentRequestCallbacks(); + const client = installGooseExtAgentRequestDispatcher(callbacks); + + const response = await client.extMethod!( + "_goose/unstable/session/recipe/request-params", + recipeParamRequestParams, + ); + + assert.deepEqual(response, { action: "cancel" }); + assert.deepEqual(callbacks.events, [ + "extMethod:_goose/unstable/session/recipe/request-params", + ]); +}); + +test("agent request dispatcher throws when a request is unhandled", async () => { + const client = installGooseExtAgentRequestDispatcher(new MinimalCallbacks()); + + await assert.rejects( + () => + client.extMethod!( + "_goose/unstable/session/recipe/request-params", + recipeParamRequestParams, + ), + /unhandled ext method: _goose\/unstable\/session\/recipe\/request-params/, + ); +});