added recipe handling in new_session (#9935)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)?))
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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 @@
|
||||
export const USE_ACP_CHAT = false;
|
||||
export const USE_ACP_CHAT = false;
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user