added recipe handling in new_session (#9935)

This commit is contained in:
Lifei Zhou
2026-06-23 09:05:19 +10:00
committed by GitHub
parent bc8609485b
commit ca8938cb43
32 changed files with 1784 additions and 135 deletions
+7
View File
@@ -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"
}
]
}
+233
View File
@@ -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"
}
]
}
+26
View File
@@ -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<String, serde_j
meta
}
pub(super) fn session_response_meta(
session: &Session,
extension_results: &[ExtensionLoadResult],
) -> serde_json::Map<String, serde_json::Value> {
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)
+42 -4
View File
@@ -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<GooseMcpHostInfo>,
client_supports_acp_elicitation: OnceCell<bool>,
client_supports_goose_custom_notifications: OnceCell<bool>,
client_supports_recipe_param_requests: OnceCell<bool>,
use_login_shell_path: OnceCell<bool>,
client_cx: OnceCell<ConnectionTo<Client>>,
config_dir: std::path::PathBuf,
@@ -219,6 +223,7 @@ pub struct GooseAcpAgent {
disable_session_naming: bool,
provider_inventory: ProviderInventoryService,
additional_source_roots: Vec<SourceRoot>,
recipe_path_cache: Arc<Mutex<HashMap<String, PathBuf>>>,
}
/// Shorten a session/thread id for perf log correlation.
@@ -300,6 +305,8 @@ struct GooseClientCapabilities {
mcp_host_capabilities: Option<GooseMcpHostCapabilities>,
#[serde(rename = "customNotifications", default)]
custom_notifications: Option<bool>,
#[serde(rename = "recipeParameterRequests", default)]
recipe_parameter_requests: Option<bool>,
}
#[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<McpServer>,
goose_extensions: Option<Vec<GooseExtension>>,
recipe_extensions: Option<&[ExtensionConfig]>,
) -> Result<Vec<ExtensionConfig>, 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<McpServer>,
goose_extensions: Option<Vec<GooseExtension>>,
recipe_extensions: Option<&[ExtensionConfig]>,
) -> Result<ExtensionData, agent_client_protocol::Error> {
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));
@@ -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<T>() -> String {
let full = std::any::type_name::<T>();
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<Req, Resp>(
generator: &mut SchemaGenerator,
method: &str,
) -> CustomMethodSchema
where
Req: JsonSchema,
Resp: JsonSchema,
{
CustomMethodSchema {
method: method.to_string(),
params_schema: Some(generator.subschema_for::<Req>()),
params_type_name: Some(short_type_name::<Req>()),
response_schema: Some(generator.subschema_for::<Resp>()),
response_type_name: Some(short_type_name::<Resp>()),
}
}
/// Schemas for every goose-custom agent → client request. Collected by the ACP
/// schema generator binary.
pub fn agent_request_schemas(generator: &mut SchemaGenerator) -> Vec<CustomMethodSchema> {
vec![agent_request_schema::<
RequestRecipeParams,
RecipeParamsResponse,
>(generator, RECIPE_PARAMS_METHOD)]
}
+1 -21
View File
@@ -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",
+197 -58
View File
@@ -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<Recipe>,
user_recipe_values: Option<HashMap<String, String>>,
project_id: Option<String>,
}
impl GooseAcpAgent {
pub(super) async fn handle_new_session(
@@ -15,95 +28,221 @@ impl GooseAcpAgent {
args: NewSessionRequest,
) -> Result<NewSessionResponse, agent_client_protocol::Error> {
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<Client>,
config: &Config,
session: &Session,
args: NewSessionRequest,
recipe: Option<(Recipe, PathBuf)>,
project_id: Option<String>,
) -> Result<NewSessionResponse, agent_client_protocol::Error> {
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<Client>,
config: &Config,
session: &Session,
args: NewSessionRequest,
recipe: Option<(Recipe, PathBuf)>,
project_id: Option<String>,
) -> Result<Option<Recipe>, 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));
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<Session, agent_client_protocol::Error> {
self.session_manager
.get_session(session_id, false)
.await
.internal_err_ctx("Failed to reload session")
}
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 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
.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 update session")?;
Ok(())
}
goose_session = self
.session_manager
.get_session(&goose_session.id, false)
.await
.internal_err_ctx("Failed to reload session")?;
let session_id_str = goose_session.id.clone();
let (_agent, extension_results) = self
.activate_acp_session(cx, &goose_session, HashMap::new())
.await?;
let goose_session = self
.session_manager
.get_session(&goose_session.id, false)
.await
.internal_err_ctx("Failed to reload session")?;
let acp_session_id = SessionId::new(session_id_str.clone());
async fn build_new_session_response(
&self,
session: &Session,
extension_results: &[ExtensionLoadResult],
) -> Result<NewSessionResponse, agent_client_protocol::Error> {
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<ModelConfig, agent_client_protocol::Error> {
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<Option<Vec<GooseExtension>>, agent_client_protocol::Error> {
+263
View File
@@ -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<Option<(Recipe, PathBuf)>, 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<PathBuf, agent_client_protocol::Error> {
if let Some(path) = self.recipe_path_cache.lock().await.get(id).cloned() {
return Ok(path);
}
let map: HashMap<String, PathBuf> = 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<String, String>,
) -> Result<Option<Recipe>, 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::<fn(&str, &str) -> Result<String, anyhow::Error>>,
) {
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<Agent>, 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<Client>,
session_id: &str,
recipe: Option<&(Recipe, PathBuf)>,
) -> Result<(Option<Recipe>, Option<HashMap<String, String>>), 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<Client>,
session_id: &str,
recipe: &Recipe,
recipe_dir: &Path,
) -> Result<(Recipe, Option<HashMap<String, String>>), 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<Client>,
session_id: &str,
parameters: Vec<RecipeParameter>,
) -> Result<RecipeParamsResponse, agent_client_protocol::Error> {
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<RecipeParameter>,
}
#[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<String, String>,
}
#[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, agent_client_protocol::Error> {
UntypedMessage::new(RECIPE_PARAMS_METHOD, &self.0)
}
fn parse_message(
method: &str,
params: &impl serde::Serialize,
) -> Result<Self, agent_client_protocol::Error> {
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::Value, agent_client_protocol::Error> {
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<Self, agent_client_protocol::Error> {
Ok(Self(agent_client_protocol::util::json_cast(&value)?))
}
}
+138 -3
View File
@@ -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<String> = 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<String, Value> = 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<String, Vec<String>> = 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<Value> = Vec::new();
let mut response_variants: Vec<Value> = Vec::new();
let mut notification_variants: Vec<Value> = Vec::new();
let mut agent_request_variants: Vec<Value> = Vec::new();
let mut agent_response_variants: Vec<Value> = Vec::new();
let mut seen_response_types: BTreeSet<String> = BTreeSet::new();
let mut seen_agent_response_types: BTreeSet<String> = 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<Value> = 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");
+3 -3
View File
@@ -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,
+90 -2
View File
@@ -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::<AcpServerConnection>().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::<AcpServerConnection>().await });
+12 -6
View File
@@ -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
/>
<ExtensionInstallModal addExtension={addExtension} setView={setView} />
<RecipeParamsModalContainer />
<div className="relative w-screen h-screen overflow-hidden bg-background-secondary flex flex-col">
<div className="titlebar-drag-region" />
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
@@ -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<string, string>): 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' },
});
});
});
+3
View File
@@ -12,6 +12,7 @@ import {
import { createWebSocketStream } from './createWebSocketStream';
import { requestAcpElicitation } from './elicitationRequests';
import { requestAcpPermission } from './permissionRequests';
import { requestAcpRecipeParams } from './recipeParamRequests';
let clientPromise: Promise<GooseClient> | 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<GooseClient> {
goose: {
mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
customNotifications: true,
recipeParameterRequests: true,
},
},
},
+12 -3
View File
@@ -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<Session>;
createSession(
cwd: string,
gooseExtensions: GooseExtension[],
recipe?: AcpRecipeOptions
): Promise<Session>;
loadSession(sessionId: string, options?: AcpLoadSessionOptions): Promise<void>;
submitMessage(
sessionId: string,
@@ -76,8 +81,12 @@ function createAcpCreditsExhaustedMessage(error: AcpCreditsExhaustedError): Mess
};
}
async function createSession(cwd: string, gooseExtensions: GooseExtension[]): Promise<Session> {
const { sessionId, sessionInfo, meta } = await acpNewSession(cwd, gooseExtensions);
async function createSession(
cwd: string,
gooseExtensions: GooseExtension[],
recipe?: AcpRecipeOptions
): Promise<Session> {
const { sessionId, sessionInfo, meta } = await acpNewSession(cwd, gooseExtensions, recipe);
const session = sessionInfoToSession(sessionInfo, meta);
showExtensionLoadResults(meta.extensionResults);
+7
View File
@@ -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) {
+90
View File
@@ -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<string, string>;
}
interface PendingRecipeParamRequest {
request: AcpRecipeParamRequest;
resolve: (response: RecipeParamsResponse_unstable) => void;
}
const pendingRequests = new Map<string, PendingRecipeParamRequest>();
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<string, string> {
const configured = window.appConfig?.get('recipeParameters') as
| Record<string, string>
| undefined;
return configured ?? {};
}
export async function requestAcpRecipeParams(
request: RequestRecipeParams_unstable
): Promise<RecipeParamsResponse_unstable> {
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<RecipeParamsResponse_unstable>((resolve) => {
pendingRequests.set(paramRequest.id, { request: paramRequest, resolve });
emit();
});
}
export function resolveAcpRecipeParamRequest(id: string, values: Record<string, string>): 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' });
}
+12 -1
View File
@@ -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<AcpNewSessionResult> {
const client = await getAcpClient();
const meta: Record<string, unknown> = { 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);
+1 -1
View File
@@ -1 +1 @@
export const USE_ACP_CHAT = false;
export const USE_ACP_CHAT = false;
+14 -3
View File
@@ -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
@@ -61,6 +61,10 @@ interface ParameterInputModalProps {
initialValues?: Record<string, string>;
}
function needsUserValue(param: Parameter): boolean {
return param.requirement === 'required' || param.requirement === 'user_prompt';
}
const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
parameters,
onSubmit,
@@ -93,7 +97,7 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
const handleSubmit = (): void => {
setValidationErrors({});
const requiredParams: Parameter[] = parameters.filter((p) => p.requirement === 'required');
const requiredParams: Parameter[] = parameters.filter(needsUserValue);
const errors: Record<string, string> = {};
requiredParams.forEach((param) => {
@@ -159,9 +163,7 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
className="block text-md font-medium text-text-primary mb-2"
>
{param.description || param.key}
{param.requirement === 'required' && (
<span className="text-red-500 ml-1">*</span>
)}
{needsUserValue(param) && <span className="text-red-500 ml-1">*</span>}
</label>
{param.input_type === 'select' && param.options ? (
@@ -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 (
<ParameterInputModal
key={request.id}
parameters={request.parameters as Parameter[]}
initialValues={request.initialValues}
onSubmit={(values) => resolveAcpRecipeParamRequest(request.id, values)}
onClose={() => cancelAcpRecipeParamRequest(request.id)}
/>
);
}
@@ -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(
<ParameterInputModal
{...defaultProps}
parameters={[
{
key: 'topic',
description: 'Topic',
input_type: 'string',
requirement: 'user_prompt',
},
]}
/>
);
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', () => {
@@ -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 });
}
};
+7 -6
View File
@@ -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<Session> {
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);
+87 -3
View File
@@ -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<string>();
const zodImports = new Set<string>();
@@ -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<string, unknown>";
const retType = r.responseType ?? "Record<string, unknown>";
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<string, unknown>;`;
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<Client, "extMethod"> = {
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<Client, "extNotification"> &
Partial<Pick<Client, "extNotification">> &
GooseExtNotifications;
Omit<Client, "extNotification" | "extMethod"> &
Partial<Pick<Client, "extNotification" | "extMethod">> &
GooseExtNotifications &
GooseExtAgentRequests;
${dispatcherFn}
${agentRequestDispatcherFn}
`;
src = await prettier.format(src, { parser: "typescript" });
+54 -3
View File
@@ -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<void>;
}
export type GooseClientCallbacks = Omit<Client, "extNotification"> &
Partial<Pick<Client, "extNotification">> &
GooseExtNotifications;
export interface GooseExtAgentRequests {
unstable_sessionRecipeRequestParams?: (
request: RequestRecipeParams_unstable,
) => Promise<RecipeParamsResponse_unstable>;
}
export type GooseClientCallbacks = Omit<
Client,
"extNotification" | "extMethod"
> &
Partial<Pick<Client, "extNotification" | "extMethod">> &
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<Client, "extMethod"> = {
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;
}
+11 -1
View File
@@ -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];
+47
View File
@@ -1596,6 +1596,33 @@ export type StatusMessageUpdate = {
status: StatusMessage;
};
export type RequestRecipeParams_unstable = {
sessionId: string;
parameters: Array<RecipeParameter>;
};
export type RecipeParameter = {
key: string;
input_type: RecipeParameterInputType;
requirement: RecipeParameterRequirement;
description: string;
default?: string | null;
options?: Array<string> | 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;
};
+72
View File
@@ -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()
})
]);
+4 -1
View File
@@ -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);
}
+98 -2
View File
@@ -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<void> {}
}
class AgentRequestCallbacks extends MinimalCallbacks {
events: string[] = [];
async unstable_sessionRecipeRequestParams(
request: RequestRecipeParams_unstable,
): Promise<RecipeParamsResponse_unstable> {
this.events.push(`typed:${request.sessionId}`);
return { action: "submit", values: { name: "Ada" } };
}
async extMethod(
method: string,
_params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
this.events.push(`extMethod:${method}`);
return { action: "cancel" };
}
}
class GenericAgentRequestCallbacks extends MinimalCallbacks {
events: string[] = [];
async extMethod(
method: string,
_params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
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/,
);
});