Applied server side call to parse and save recipe (#5022)

This commit is contained in:
Lifei Zhou
2025-10-09 15:45:46 +11:00
committed by GitHub
parent fc7836d649
commit 1396d315e5
42 changed files with 868 additions and 2126 deletions
Generated
+18 -6
View File
@@ -2818,6 +2818,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"serde_path_to_error",
"serde_yaml",
"tempfile",
"thiserror 1.0.69",
@@ -5868,18 +5869,28 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "serde"
version = "1.0.218"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.218"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -5912,12 +5923,13 @@ dependencies = [
[[package]]
name = "serde_path_to_error"
version = "0.1.17"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
+1 -1
View File
@@ -1019,7 +1019,7 @@ pub async fn cli() -> Result<()> {
.and_then(|rf| {
goose::recipe::template_recipe::parse_recipe_content(
&rf.content,
rf.parent_dir.to_string_lossy().to_string(),
Some(rf.parent_dir.to_string_lossy().to_string()),
)
.ok()
.map(|(r, _)| r.version)
+14 -90
View File
@@ -1,43 +1,25 @@
use anyhow::Result;
use console::style;
use goose::recipe::validate_recipe::validate_recipe_template_from_file;
use crate::recipes::github_recipe::RecipeSource;
use crate::recipes::recipe::load_recipe_for_validation;
use crate::recipes::search_recipe::list_available_recipes;
use crate::recipes::search_recipe::{list_available_recipes, load_recipe_file};
use goose::recipe_deeplink;
/// Validates a recipe file
///
/// # Arguments
///
/// * `file_path` - Path to the recipe file to validate
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_validate(recipe_name: &str) -> Result<()> {
// Load and validate the recipe file
match load_recipe_for_validation(recipe_name) {
Ok(_) => {
println!("{} recipe file is valid", style("✓").green().bold());
Ok(())
}
Err(err) => {
println!("{} {}", style("✗").red().bold(), err);
Err(err)
}
}
let recipe_file = load_recipe_file(recipe_name)?;
validate_recipe_template_from_file(&recipe_file).map_err(|err| {
anyhow::anyhow!(
"{} recipe file is invalid: {}",
style("✗").red().bold(),
err
)
})?;
println!("{} recipe file is valid", style("✓").green().bold());
Ok(())
}
/// Generates a deeplink for a recipe file
///
/// # Arguments
///
/// * `recipe_name` - Path to the recipe file
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_deeplink(recipe_name: &str) -> Result<String> {
match generate_deeplink(recipe_name) {
Ok((deeplink_url, recipe)) => {
@@ -60,15 +42,6 @@ pub fn handle_deeplink(recipe_name: &str) -> Result<String> {
}
}
/// Opens a recipe in Goose Desktop
///
/// # Arguments
///
/// * `recipe_name` - Path to the recipe file
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_open(recipe_name: &str) -> Result<()> {
// Generate the deeplink using the helper function (no printing)
// This reuses all the validation and encoding logic
@@ -107,16 +80,6 @@ pub fn handle_open(recipe_name: &str) -> Result<()> {
}
}
/// Lists all available recipes from local paths and GitHub repositories
///
/// # Arguments
///
/// * `format` - Output format ("text" or "json")
/// * `verbose` - Whether to show detailed information
///
/// # Returns
///
/// Result indicating success or failure
pub fn handle_list(format: &str, verbose: bool) -> Result<()> {
let recipes = match list_available_recipes() {
Ok(recipes) => recipes,
@@ -168,18 +131,10 @@ pub fn handle_list(format: &str, verbose: bool) -> Result<()> {
Ok(())
}
/// Helper function to generate a deeplink
///
/// # Arguments
///
/// * `recipe_name` - Path to the recipe file
///
/// # Returns
///
/// Result containing the deeplink URL and recipe
fn generate_deeplink(recipe_name: &str) -> Result<(String, goose::recipe::Recipe)> {
let recipe_file = load_recipe_file(recipe_name)?;
// Load the recipe file first to validate it
let recipe = load_recipe_for_validation(recipe_name)?;
let recipe = validate_recipe_template_from_file(&recipe_file)?;
match recipe_deeplink::encode(&recipe) {
Ok(encoded) => {
let full_url = format!("goose://recipe?config={}", encoded);
@@ -227,20 +182,6 @@ prompt: "Test prompt content {{ name }}"
instructions: "Test instructions"
"#;
const RECIPE_WITH_INVALID_JSON_SCHEMA: &str = r#"
title: "Test Recipe with Invalid JSON Schema"
description: "A test recipe with invalid JSON schema"
prompt: "Test prompt content"
instructions: "Test instructions"
response:
json_schema:
type: invalid_type
properties:
result:
type: unknown_type
required: "should_be_array_not_string"
"#;
#[test]
fn test_handle_deeplink_valid_recipe() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
@@ -305,23 +246,6 @@ response:
assert!(result.is_err());
}
#[test]
fn test_handle_validation_recipe_with_invalid_json_schema() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let recipe_path = create_test_recipe_file(
&temp_dir,
"test_recipe.yaml",
RECIPE_WITH_INVALID_JSON_SCHEMA,
);
let result = handle_validate(&recipe_path);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("JSON schema validation failed"));
}
#[test]
fn test_generate_deeplink_valid_recipe() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
@@ -331,7 +331,7 @@ fn get_github_recipe_info(repo: &str, dir_name: &str, recipe_filename: &str) ->
.map_err(|e| anyhow!("Failed to convert content to string: {}", e))?;
// Parse the recipe content
let (recipe, _) = parse_recipe_content(&content, format!("{}/{}", repo, dir_name))?;
let (recipe, _) = parse_recipe_content(&content, Some(format!("{}/{}", repo, dir_name)))?;
return Ok(RecipeInfo {
name: dir_name.to_string(),
+5 -30
View File
@@ -7,13 +7,13 @@ use crate::recipes::secret_discovery::{discover_recipe_secrets, SecretRequiremen
use anyhow::Result;
use goose::config::Config;
use goose::recipe::build_recipe::{
apply_values_to_parameters, build_recipe_from_template, validate_recipe_parameters, RecipeError,
apply_values_to_parameters, build_recipe_from_template, RecipeError,
};
use goose::recipe::read_recipe_file_content::RecipeFile;
use goose::recipe::template_recipe::render_recipe_for_preview;
use goose::recipe::validate_recipe::validate_recipe_parameters;
use goose::recipe::Recipe;
use serde_json::Value;
use std::collections::HashMap;
fn create_user_prompt_callback() -> impl Fn(&str, &str) -> Result<String> {
|key: &str, description: &str| -> Result<String> {
@@ -131,29 +131,11 @@ pub fn render_recipe_as_yaml(recipe_name: &str, params: Vec<(String, String)>) -
}
}
pub fn load_recipe_for_validation(recipe_name: &str) -> Result<Recipe> {
let (recipe_file, recipe_dir_str) = load_recipe_file_with_dir(recipe_name)?;
let recipe_file_content = &recipe_file.content;
validate_recipe_parameters(recipe_file_content, &recipe_dir_str)?;
let recipe = render_recipe_for_preview(
recipe_file_content,
recipe_dir_str.to_string(),
&HashMap::new(),
)?;
if let Some(response) = &recipe.response {
if let Some(json_schema) = &response.json_schema {
validate_json_schema(json_schema)?;
}
}
Ok(recipe)
}
pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Result<()> {
let (recipe_file, recipe_dir_str) = load_recipe_file_with_dir(recipe_name)?;
let recipe_file_content = &recipe_file.content;
let recipe_parameters = validate_recipe_parameters(recipe_file_content, &recipe_dir_str)?;
let recipe_parameters =
validate_recipe_parameters(recipe_file_content, Some(recipe_dir_str.clone()))?;
let (params_for_template, missing_params) = apply_values_to_parameters(
&params,
@@ -163,7 +145,7 @@ pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Resul
)?;
let recipe = render_recipe_for_preview(
recipe_file_content,
recipe_dir_str.to_string(),
Some(recipe_dir_str.clone()),
&params_for_template,
)?;
print_recipe_explanation(&recipe);
@@ -172,13 +154,6 @@ pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Resul
Ok(())
}
fn validate_json_schema(schema: &serde_json::Value) -> Result<()> {
match jsonschema::validator_for(schema) {
Ok(_) => Ok(()),
Err(err) => Err(anyhow::anyhow!("JSON schema validation failed: {}", err)),
}
}
#[cfg(test)]
mod tests {
use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement};
+1
View File
@@ -39,6 +39,7 @@ utoipa = { version = "4.1", features = ["axum_extras", "chrono"] }
reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking", "multipart"], default-features = false }
tokio-util = "0.7.15"
uuid = { version = "1.11", features = ["v4"] }
serde_path_to_error = "0.1.20"
[[bin]]
name = "goosed"
+120 -18
View File
@@ -1,17 +1,42 @@
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::rejection::JsonRejection;
use axum::routing::get;
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use goose::recipe::local_recipes;
use goose::recipe::validate_recipe::validate_recipe_template_from_content;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use goose::session::SessionManager;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_path_to_error::deserialize as deserialize_with_path;
use utoipa::ToSchema;
fn format_json_rejection_message(rejection: &JsonRejection) -> String {
match rejection {
JsonRejection::JsonDataError(err) => {
format!("Request body validation failed: {}", clean_data_error(err))
}
JsonRejection::JsonSyntaxError(err) => format!("Invalid JSON payload: {}", err.body_text()),
JsonRejection::MissingJsonContentType(err) => err.body_text(),
JsonRejection::BytesRejection(err) => err.body_text(),
_ => rejection.body_text(),
}
}
fn clean_data_error(err: &axum::extract::rejection::JsonDataError) -> String {
let message = err.body_text();
message
.strip_prefix("Failed to deserialize the JSON body into the target type: ")
.map(|s| s.to_string())
.unwrap_or_else(|| message.to_string())
}
use crate::routes::errors::ErrorResponse;
use crate::routes::recipe_utils::get_all_recipes_manifests;
use crate::state::AppState;
@@ -19,7 +44,6 @@ use crate::state::AppState;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateRecipeRequest {
session_id: String,
// Optional fields
#[serde(default)]
author: Option<AuthorRequest>,
}
@@ -72,7 +96,6 @@ pub struct ScanRecipeResponse {
pub struct SaveRecipeRequest {
recipe: Recipe,
id: Option<String>,
is_global: Option<bool>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ParseRecipeRequest {
@@ -86,7 +109,6 @@ pub struct ParseRecipeResponse {
#[derive(Debug, Serialize, ToSchema)]
pub struct RecipeManifestResponse {
name: String,
recipe: Recipe,
#[serde(rename = "lastModified")]
last_modified: String,
@@ -115,7 +137,6 @@ pub struct ListRecipeResponse {
),
tag = "Recipe Management"
)]
/// Create a Recipe configuration from the current session
async fn create_recipe(
State(state): State<Arc<AppState>>,
Json(request): Json<CreateRecipeRequest>,
@@ -125,7 +146,6 @@ async fn create_recipe(
request.session_id
);
// Load messages from session
let session = match SessionManager::get_session(&request.session_id, true).await {
Ok(session) => session,
Err(e) => {
@@ -148,7 +168,6 @@ async fn create_recipe(
let agent = state.get_agent_for_route(request.session_id).await?;
// Create base recipe from agent state and messages
let recipe_result = agent.create_recipe(conversation).await;
match recipe_result {
@@ -261,7 +280,6 @@ async fn list_recipes(
let file_path = recipe_manifest_with_path.file_path.clone();
recipe_file_hash_map.insert(id.clone(), file_path);
RecipeManifestResponse {
name: recipe_manifest_with_path.name.clone(),
recipe: recipe_manifest_with_path.recipe.clone(),
id: id.clone(),
last_modified: recipe_manifest_with_path.last_modified.clone(),
@@ -291,10 +309,9 @@ async fn delete_recipe(
State(state): State<Arc<AppState>>,
Json(request): Json<DeleteRecipeRequest>,
) -> StatusCode {
let recipe_file_hash_map = state.recipe_file_hash_map.lock().await;
let file_path = match recipe_file_hash_map.get(&request.id) {
Some(path) => path,
None => return StatusCode::NOT_FOUND,
let file_path = match get_recipe_file_path_by_id(state.clone(), &request.id).await {
Ok(path) => path,
Err(err) => return err.status,
};
if fs::remove_file(file_path).is_err() {
@@ -317,14 +334,18 @@ async fn delete_recipe(
)]
async fn save_recipe(
State(state): State<Arc<AppState>>,
Json(request): Json<SaveRecipeRequest>,
payload: Result<Json<Value>, JsonRejection>,
) -> Result<StatusCode, ErrorResponse> {
let file_path = match request.id {
Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(),
let Json(raw_json) = payload.map_err(json_rejection_to_error_response)?;
let request = deserialize_save_recipe_request(raw_json)?;
validate_recipe(&request.recipe)?;
let file_path = match request.id.as_ref() {
Some(id) => Some(get_recipe_file_path_by_id(state.clone(), id).await?),
None => None,
};
match local_recipes::save_recipe_to_file(request.recipe, request.is_global, file_path) {
match local_recipes::save_recipe_to_file(request.recipe, file_path) {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(e) => Err(ErrorResponse {
message: e.to_string(),
@@ -333,6 +354,85 @@ async fn save_recipe(
}
}
fn json_rejection_to_error_response(rejection: JsonRejection) -> ErrorResponse {
ErrorResponse {
message: format_json_rejection_message(&rejection),
status: StatusCode::BAD_REQUEST,
}
}
fn validate_recipe(recipe: &Recipe) -> Result<(), ErrorResponse> {
let recipe_json = serde_json::to_string(recipe).map_err(|err| ErrorResponse {
message: err.to_string(),
status: StatusCode::BAD_REQUEST,
})?;
validate_recipe_template_from_content(&recipe_json, None).map_err(|err| ErrorResponse {
message: err.to_string(),
status: StatusCode::BAD_REQUEST,
})?;
Ok(())
}
fn deserialize_save_recipe_request(value: Value) -> Result<SaveRecipeRequest, ErrorResponse> {
let payload = value.to_string();
let mut deserializer = serde_json::Deserializer::from_str(&payload);
let result: Result<SaveRecipeRequest, _> = deserialize_with_path(&mut deserializer);
result.map_err(|err| {
let path = err.path().to_string();
let inner = err.into_inner();
let message = if path.is_empty() {
format!("Save recipe validation failed: {}", inner)
} else {
format!(
"save recipe validation failed at {}: {}",
path.trim_start_matches('.'),
inner
)
};
ErrorResponse {
message,
status: StatusCode::BAD_REQUEST,
}
})
}
async fn get_recipe_file_path_by_id(
state: Arc<AppState>,
id: &str,
) -> Result<PathBuf, ErrorResponse> {
let cached_path = {
let map = state.recipe_file_hash_map.lock().await;
map.get(id).cloned()
};
if let Some(path) = cached_path {
return Ok(path);
}
let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default();
let mut recipe_file_hash_map = HashMap::new();
let mut resolved_path: Option<PathBuf> = None;
for recipe_manifest_with_path in &recipe_manifest_with_paths {
if recipe_manifest_with_path.id == id {
resolved_path = Some(recipe_manifest_with_path.file_path.clone());
}
recipe_file_hash_map.insert(
recipe_manifest_with_path.id.clone(),
recipe_manifest_with_path.file_path.clone(),
);
}
state.set_recipe_file_hash_map(recipe_file_hash_map).await;
resolved_path.ok_or_else(|| ErrorResponse {
message: format!("Recipe not found: {}", id),
status: StatusCode::NOT_FOUND,
})
}
#[utoipa::path(
post,
path = "/recipes/parse",
@@ -347,9 +447,11 @@ async fn save_recipe(
async fn parse_recipe(
Json(request): Json<ParseRecipeRequest>,
) -> Result<Json<ParseRecipeResponse>, ErrorResponse> {
let recipe = Recipe::from_content(&request.content).map_err(|e| ErrorResponse {
message: format!("Invalid recipe format: {}", e),
status: StatusCode::BAD_REQUEST,
let recipe = validate_recipe_template_from_content(&request.content, None).map_err(|e| {
ErrorResponse {
message: format!("Invalid recipe format: {}", e),
status: StatusCode::BAD_REQUEST,
}
})?;
Ok(Json(ParseRecipeResponse { recipe }))
@@ -8,14 +8,8 @@ use anyhow::Result;
use goose::recipe::local_recipes::list_local_recipes;
use goose::recipe::Recipe;
use std::path::Path;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
pub struct RecipeManifestWithPath {
pub id: String,
pub name: String,
pub recipe: Recipe,
pub file_path: PathBuf,
pub last_modified: String,
@@ -37,16 +31,9 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
else {
continue;
};
let recipe_metadata =
RecipeManifestMetadata::from_yaml_file(&file_path).unwrap_or_else(|_| {
RecipeManifestMetadata {
name: recipe.title.clone(),
}
});
let manifest_with_path = RecipeManifestWithPath {
id: short_id_from_path(file_path.to_string_lossy().as_ref()),
name: recipe_metadata.name,
recipe,
file_path,
last_modified,
@@ -57,44 +44,3 @@ pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
Ok(recipe_manifests_with_path)
}
// this is a temporary struct to deserilize the UI recipe files. should not be used for other purposes.
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
struct RecipeManifestMetadata {
pub name: String,
}
impl RecipeManifestMetadata {
pub fn from_yaml_file(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path.display(), e))?;
let metadata = serde_yaml::from_str::<Self>(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?;
Ok(metadata)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_from_yaml_file_success() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("test_recipe.yaml");
let yaml_content = r#"
name: "Test Recipe"
isGlobal: true
recipe: recipe_content
"#;
fs::write(&file_path, yaml_content).unwrap();
let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap();
assert_eq!(result.name, "Test Recipe");
}
}
+7 -5
View File
@@ -237,11 +237,13 @@ async fn run_now_handler(
.and_then(|content| {
goose::recipe::template_recipe::parse_recipe_content(
&content,
std::path::Path::new(&job.source)
.parent()
.unwrap_or_else(|| std::path::Path::new(""))
.to_string_lossy()
.to_string(),
Some(
std::path::Path::new(&job.source)
.parent()
.unwrap_or_else(|| std::path::Path::new(""))
.to_string_lossy()
.to_string(),
),
)
.ok()
.map(|(r, _)| r.version)
+8 -96
View File
@@ -1,11 +1,12 @@
use crate::recipe::read_recipe_file_content::{read_parameter_file_content, RecipeFile};
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_content_with_params};
use crate::recipe::template_recipe::render_recipe_content_with_params;
use crate::recipe::validate_recipe::validate_recipe_template_from_content;
use crate::recipe::{
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
BUILT_IN_RECIPE_DIR_PARAM,
};
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, thiserror::Error)]
@@ -34,7 +35,11 @@ where
let recipe_dir_str = recipe_parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?;
let recipe_parameters = validate_recipe_template_from_content(
&recipe_file_content,
Some(recipe_dir_str.to_string()),
)?
.parameters;
let (params_for_template, missing_params) =
apply_values_to_parameters(&params, recipe_parameters, recipe_dir_str, user_prompt_fn)?;
@@ -48,18 +53,6 @@ where
Ok((rendered_content, missing_params))
}
pub fn validate_recipe_parameters(
recipe_file_content: &str,
recipe_dir_str: &str,
) -> Result<Option<Vec<RecipeParameter>>> {
let (raw_recipe, template_variables) =
parse_recipe_content(recipe_file_content, recipe_dir_str.to_string())?;
let recipe_parameters = raw_recipe.parameters;
validate_optional_parameters(&recipe_parameters)?;
validate_parameters_in_template(&recipe_parameters, &template_variables)?;
Ok(recipe_parameters)
}
pub fn build_recipe_from_template<F>(
recipe_file: RecipeFile,
params: Vec<(String, String)>,
@@ -94,87 +87,6 @@ where
Ok(recipe)
}
fn validate_parameters_in_template(
recipe_parameters: &Option<Vec<RecipeParameter>>,
template_variables: &HashSet<String>,
) -> Result<()> {
let mut template_variables = template_variables.clone();
template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM);
let param_keys: HashSet<String> = recipe_parameters
.as_ref()
.unwrap_or(&vec![])
.iter()
.map(|p| p.key.clone())
.collect();
let missing_keys = template_variables
.difference(&param_keys)
.collect::<Vec<_>>();
let extra_keys = param_keys
.difference(&template_variables)
.collect::<Vec<_>>();
if missing_keys.is_empty() && extra_keys.is_empty() {
return Ok(());
}
let mut message = String::new();
if !missing_keys.is_empty() {
message.push_str(&format!(
"Missing definitions for parameters in the recipe file: {}.",
missing_keys
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
if !extra_keys.is_empty() {
message.push_str(&format!(
"\nUnnecessary parameter definitions: {}.",
extra_keys
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
Err(anyhow::anyhow!("{}", message.trim_end()))
}
fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Result<()> {
let empty_params = vec![];
let params = parameters.as_ref().unwrap_or(&empty_params);
let file_params_with_defaults: Vec<String> = params
.iter()
.filter(|p| matches!(p.input_type, RecipeParameterInputType::File) && p.default.is_some())
.map(|p| p.key.clone())
.collect();
if !file_params_with_defaults.is_empty() {
return Err(anyhow::anyhow!("File parameters cannot have default values to avoid importing sensitive user files: {}", file_params_with_defaults.join(", ")));
}
let optional_params_without_default_values: Vec<String> = params
.iter()
.filter(|p| {
matches!(p.requirement, RecipeParameterRequirement::Optional) && p.default.is_none()
})
.map(|p| p.key.clone())
.collect();
if optional_params_without_default_values.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!("Optional parameters missing default values in the recipe: {}. Please provide defaults.", optional_params_without_default_values.join(", ")))
}
}
pub fn apply_values_to_parameters<F>(
user_params: &[(String, String)],
recipe_parameters: Option<Vec<RecipeParameter>>,
@@ -303,6 +303,27 @@ fn test_build_recipe_from_template_success_without_parameters() {
assert!(recipe.parameters.is_none());
}
#[test]
fn test_build_recipe_from_template_missing_prompt_and_instructions() {
let instructions_and_parameters = "";
let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters);
let build_recipe_result = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT);
assert!(build_recipe_result.is_err());
let err = build_recipe_result.unwrap_err();
println!("{}", err);
match err {
RecipeError::TemplateRendering { source } => {
let err_str = source.to_string();
assert!(
err_str.contains("Recipe must specify at least one of `instructions` or `prompt`.")
);
}
_ => panic!("Expected TemplateRendering error"),
}
}
#[test]
fn test_template_inheritance() {
let parent_content = r#"
+28 -32
View File
@@ -119,8 +119,16 @@ fn scan_directory_for_recipes(dir: &Path) -> Result<Vec<(PathBuf, Recipe)>> {
if path.is_file() {
if let Some(extension) = path.extension() {
if RECIPE_FILE_EXTENSIONS.contains(&extension.to_string_lossy().as_ref()) {
if let Ok(recipe) = Recipe::from_file_path(&path) {
recipes.push((path.clone(), recipe));
match Recipe::from_file_path(&path) {
Ok(recipe) => recipes.push((path.clone(), recipe)),
Err(e) => {
let error_message = format!(
"Failed to load recipe from file {}: {}",
path.display(),
e
);
tracing::error!("{}", error_message);
}
}
}
}
@@ -130,7 +138,7 @@ fn scan_directory_for_recipes(dir: &Path) -> Result<Vec<(PathBuf, Recipe)>> {
Ok(recipes)
}
fn generate_recipe_filename(title: &str) -> String {
fn generate_recipe_filename(title: &str, recipe_library_dir: &Path) -> PathBuf {
let base_name = title
.to_lowercase()
.chars()
@@ -145,41 +153,29 @@ fn generate_recipe_filename(title: &str) -> String {
} else {
base_name
};
format!("{}.yaml", filename)
let mut candidate = recipe_library_dir.join(format!("{}.yaml", filename));
if !candidate.exists() {
return candidate;
}
let mut counter = 1;
loop {
candidate = recipe_library_dir.join(format!("{}-{}.yaml", filename, counter));
if !candidate.exists() {
return candidate;
}
counter += 1;
}
}
pub fn save_recipe_to_file(
recipe: Recipe,
is_global: Option<bool>,
file_path: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
let is_global_value = is_global.unwrap_or(true);
let default_file_path =
get_recipe_library_dir(is_global_value).join(generate_recipe_filename(&recipe.title));
pub fn save_recipe_to_file(recipe: Recipe, file_path: Option<PathBuf>) -> anyhow::Result<PathBuf> {
let recipe_library_dir = get_recipe_library_dir(true);
let file_path_value = match file_path {
Some(path) => path,
None => {
if default_file_path.exists() {
return Err(anyhow::anyhow!(
"Recipe file already exists at: {:?}",
default_file_path
));
}
default_file_path
}
None => generate_recipe_filename(&recipe.title, &recipe_library_dir),
};
let all_recipes = list_local_recipes()?;
for (existing_path, existing_recipe) in &all_recipes {
if existing_recipe.title == recipe.title && existing_path != &file_path_value {
return Err(anyhow::anyhow!(
"Recipe with title '{}' already exists",
recipe.title
));
}
}
let yaml_content = serde_yaml::to_string(&recipe)?;
fs::write(&file_path_value, yaml_content)?;
+19 -28
View File
@@ -15,7 +15,9 @@ use utoipa::ToSchema;
pub mod build_recipe;
pub mod local_recipes;
pub mod read_recipe_file_content;
mod recipe_extension_adapter;
pub mod template_recipe;
pub mod validate_recipe;
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"];
@@ -42,7 +44,11 @@ pub struct Recipe {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>, // the prompt to start the session with
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(
skip_serializing_if = "Option::is_none",
default,
deserialize_with = "recipe_extension_adapter::deserialize_recipe_extensions"
)]
pub extensions: Option<Vec<ExtensionConfig>>, // a list of extensions to enable
#[serde(skip_serializing_if = "Option::is_none")]
@@ -253,34 +259,19 @@ impl Recipe {
}
pub fn from_content(content: &str) -> Result<Self> {
// Parse using YAML parser (since JSON is a subset of YAML, this handles both)
let mut value: serde_yaml::Value = serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("Failed to parse recipe content as YAML/JSON: {}", e))?;
// Handle nested legacy recipe format
if let Some(nested_recipe) = value.get("recipe") {
value = nested_recipe.clone();
}
if let Some(extensions) = value
.get_mut("extensions")
.and_then(|v| v.as_sequence_mut())
{
for ext in extensions.iter_mut() {
if let Some(obj) = ext.as_mapping_mut() {
if let Some(desc) = obj.get("description") {
if desc.is_null() || desc.as_str().is_some_and(|s| s.is_empty()) {
if let Some(name) = obj.get("name").and_then(|n| n.as_str()) {
obj.insert("description".into(), name.into());
}
}
}
let recipe: Recipe = match serde_yaml::from_str::<serde_yaml::Value>(content) {
Ok(yaml_value) => {
if let Some(nested_recipe) = yaml_value.get("recipe") {
serde_yaml::from_value(nested_recipe.clone())
.map_err(|e| anyhow::anyhow!("Failed to parse nested recipe: {}", e))?
} else {
serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("Failed to parse recipe: {}", e))?
}
}
}
let recipe: Recipe = serde_yaml::from_value(value)
.map_err(|e| anyhow::anyhow!("Failed to deserialize recipe: {}", e))?;
Err(_) => serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("Failed to parse recipe: {}", e))?,
};
if let Some(ref retry_config) = recipe.retry {
if let Err(validation_error) = retry_config.validate() {
@@ -778,7 +769,7 @@ isGlobal: true"#;
} = &extensions[0]
{
assert_eq!(name, "test_extension");
assert_eq!(description, "test_extension");
assert_eq!(description, "");
} else {
panic!("Expected Stdio extension");
}
@@ -0,0 +1,283 @@
use crate::agents::extension::{Envs, ExtensionConfig};
use rmcp::model::Tool;
use serde::de::Deserializer;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
#[serde(tag = "type")]
enum RecipeExtensionConfigInternal {
#[serde(rename = "sse")]
Sse {
name: String,
#[serde(default)]
description: Option<String>,
uri: String,
#[serde(default)]
envs: Envs,
#[serde(default)]
env_keys: Vec<String>,
timeout: Option<u64>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "stdio")]
Stdio {
name: String,
#[serde(default)]
description: Option<String>,
cmd: String,
args: Vec<String>,
#[serde(default)]
envs: Envs,
#[serde(default)]
env_keys: Vec<String>,
timeout: Option<u64>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "builtin")]
Builtin {
name: String,
#[serde(default)]
description: Option<String>,
display_name: Option<String>,
timeout: Option<u64>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "platform")]
Platform {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "streamable_http")]
StreamableHttp {
name: String,
#[serde(default)]
description: Option<String>,
uri: String,
#[serde(default)]
envs: Envs,
#[serde(default)]
env_keys: Vec<String>,
#[serde(default)]
headers: HashMap<String, String>,
timeout: Option<u64>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "frontend")]
Frontend {
name: String,
#[serde(default)]
description: Option<String>,
tools: Vec<Tool>,
instructions: Option<String>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "inline_python")]
InlinePython {
name: String,
#[serde(default)]
description: Option<String>,
code: String,
timeout: Option<u64>,
#[serde(default)]
dependencies: Option<Vec<String>>,
#[serde(default)]
available_tools: Vec<String>,
},
}
macro_rules! map_recipe_extensions {
($value:expr; $( $variant:ident { $( $field:ident ),* $(,)? } ),+ $(,)?) => {{
match $value {
$(
RecipeExtensionConfigInternal::$variant {
name,
description,
$( $field ),*
} => ExtensionConfig::$variant {
name,
description: description.unwrap_or_default(),
$( $field ),*
},
)+
}
}};
}
impl From<RecipeExtensionConfigInternal> for ExtensionConfig {
fn from(internal_variant: RecipeExtensionConfigInternal) -> Self {
map_recipe_extensions!(
internal_variant;
Sse {
uri,
envs,
env_keys,
timeout,
bundled,
available_tools
},
Stdio {
cmd,
args,
envs,
env_keys,
timeout,
bundled,
available_tools
},
Builtin {
display_name,
timeout,
bundled,
available_tools
},
Platform {
bundled,
available_tools
},
StreamableHttp {
uri,
envs,
env_keys,
headers,
timeout,
bundled,
available_tools
},
Frontend {
tools,
instructions,
bundled,
available_tools
},
InlinePython {
code,
timeout,
dependencies,
available_tools
}
)
}
}
pub fn deserialize_recipe_extensions<'de, D>(
deserializer: D,
) -> Result<Option<Vec<ExtensionConfig>>, D::Error>
where
D: Deserializer<'de>,
{
let remotes = Option::<Vec<RecipeExtensionConfigInternal>>::deserialize(deserializer)?;
Ok(remotes.map(|items| {
items
.into_iter()
.map(ExtensionConfig::from)
.collect::<Vec<_>>()
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
struct Wrapper {
#[serde(deserialize_with = "deserialize_recipe_extensions")]
extensions: Option<Vec<ExtensionConfig>>,
}
#[test]
fn builtin_extension_defaults_description() {
let wrapper: Wrapper = serde_json::from_value(json!({
"extensions": [{
"type": "builtin",
"name": "test-builtin",
"display_name": "Test Builtin",
"timeout": 120,
"bundled": true,
"available_tools": ["tool_a", "tool_b"],
}]
}))
.expect("failed to deserialize extensions");
let extensions = wrapper.extensions.expect("expected extensions");
assert_eq!(extensions.len(), 1);
match &extensions[0] {
ExtensionConfig::Builtin {
name,
description,
display_name,
timeout,
bundled,
available_tools,
} => {
assert_eq!(name, "test-builtin");
assert_eq!(description, "");
assert_eq!(display_name.as_deref(), Some("Test Builtin"));
assert_eq!(*timeout, Some(120));
assert_eq!(*bundled, Some(true));
assert_eq!(
available_tools,
&vec!["tool_a".to_string(), "tool_b".to_string()]
);
}
other => panic!("unexpected extension variant: {:?}", other),
}
}
#[test]
fn builtin_extension_null_description_defaults_to_empty() {
let wrapper: Wrapper = serde_json::from_value(json!({
"extensions": [{
"type": "builtin",
"name": "null-description-builtin",
"description": null,
}]
}))
.expect("failed to deserialize extensions with null description");
let extensions = wrapper.extensions.expect("expected extensions");
assert_eq!(extensions.len(), 1);
match &extensions[0] {
ExtensionConfig::Builtin {
name,
description,
display_name,
timeout,
bundled,
available_tools,
} => {
assert_eq!(name, "null-description-builtin");
assert_eq!(description, "");
assert!(display_name.is_none());
assert!(timeout.is_none());
assert!(bundled.is_none());
assert!(available_tools.is_empty());
}
other => panic!("unexpected extension variant: {:?}", other),
}
}
}
+20 -17
View File
@@ -98,7 +98,7 @@ pub fn render_recipe_content_with_params(
let env = add_template_in_env(
&content_with_safe_variables,
params.get(BUILT_IN_RECIPE_DIR_PARAM).unwrap().clone(),
params.get(BUILT_IN_RECIPE_DIR_PARAM).cloned(),
UndefinedBehavior::Strict,
)?;
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
@@ -110,23 +110,26 @@ pub fn render_recipe_content_with_params(
fn add_template_in_env(
content: &str,
recipe_dir: String,
recipe_dir: Option<String>,
undefined_behavior: UndefinedBehavior,
) -> Result<Environment<'_>> {
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(undefined_behavior);
env.set_loader(move |name| {
let path = Path::new(recipe_dir.as_str()).join(name);
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"could not read template",
)
.with_source(e)),
}
});
if let Some(recipe_dir) = recipe_dir {
env.set_loader(move |name| {
let path = Path::new(recipe_dir.as_str()).join(name);
match std::fs::read_to_string(&path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"could not read template",
)
.with_source(e)),
}
});
}
env.add_template(CURRENT_TEMPLATE_NAME, content)?;
Ok(env)
@@ -134,7 +137,7 @@ fn add_template_in_env(
fn get_env_with_template_variables(
content: &str,
recipe_dir: String,
recipe_dir: Option<String>,
undefined_behavior: UndefinedBehavior,
) -> Result<(Environment<'_>, HashSet<String>)> {
let env = add_template_in_env(content, recipe_dir, undefined_behavior)?;
@@ -149,7 +152,7 @@ fn get_env_with_template_variables(
pub fn parse_recipe_content(
content: &str,
recipe_dir: String,
recipe_dir: Option<String>,
) -> Result<(Recipe, HashSet<String>)> {
// Pre-process template variables to handle invalid variable names
let preprocessed_content = preprocess_template_variables(content)?;
@@ -171,7 +174,7 @@ pub fn parse_recipe_content(
// render the recipe for validation, deeplink and explain, etc.
pub fn render_recipe_for_preview(
content: &str,
recipe_dir: String,
recipe_dir: Option<String>,
params: &HashMap<String, String>,
) -> Result<Recipe> {
// Pre-process template variables to handle invalid variable names
+156
View File
@@ -0,0 +1,156 @@
use crate::recipe::read_recipe_file_content::RecipeFile;
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_for_preview};
use crate::recipe::{
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
BUILT_IN_RECIPE_DIR_PARAM,
};
use anyhow::Result;
use std::collections::{HashMap, HashSet};
pub fn validate_recipe_parameters(
recipe_file_content: &str,
recipe_dir_str: Option<String>,
) -> Result<Option<Vec<RecipeParameter>>> {
let (recipe_template, template_variables) =
parse_recipe_content(recipe_file_content, recipe_dir_str)?;
let recipe_parameters = recipe_template.parameters;
validate_optional_parameters(&recipe_parameters)?;
validate_parameters_in_template(&recipe_parameters, &template_variables)?;
Ok(recipe_parameters)
}
fn validate_json_schema(schema: &serde_json::Value) -> Result<()> {
match jsonschema::validator_for(schema) {
Ok(_) => Ok(()),
Err(err) => Err(anyhow::anyhow!("JSON schema validation failed: {}", err)),
}
}
pub fn validate_recipe_template_from_file(recipe_file: &RecipeFile) -> Result<Recipe> {
let recipe_dir = recipe_file
.parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?
.to_string();
validate_recipe_template_from_content(&recipe_file.content, Some(recipe_dir))
}
pub fn validate_recipe_template_from_content(
recipe_content: &str,
recipe_dir: Option<String>,
) -> Result<Recipe> {
validate_recipe_parameters(recipe_content, recipe_dir.clone())?;
let recipe = render_recipe_for_preview(recipe_content, recipe_dir, &HashMap::new())?;
validate_prompt_or_instructions(&recipe)?;
if let Some(response) = &recipe.response {
if let Some(json_schema) = &response.json_schema {
validate_json_schema(json_schema)?;
}
}
Ok(recipe)
}
fn validate_prompt_or_instructions(recipe: &Recipe) -> Result<()> {
let has_instructions = recipe
.instructions
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
let has_prompt = recipe
.prompt
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if has_instructions || has_prompt {
return Ok(());
}
Err(anyhow::anyhow!(
"Recipe must specify at least one of `instructions` or `prompt`."
))
}
fn validate_parameters_in_template(
recipe_parameters: &Option<Vec<RecipeParameter>>,
template_variables: &HashSet<String>,
) -> Result<()> {
let mut template_variables = template_variables.clone();
template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM);
let param_keys: HashSet<String> = recipe_parameters
.as_ref()
.unwrap_or(&vec![])
.iter()
.map(|p| p.key.clone())
.collect();
let missing_keys = template_variables
.difference(&param_keys)
.collect::<Vec<_>>();
let extra_keys = param_keys
.difference(&template_variables)
.collect::<Vec<_>>();
if missing_keys.is_empty() && extra_keys.is_empty() {
return Ok(());
}
let mut message = String::new();
if !missing_keys.is_empty() {
message.push_str(&format!(
"Missing definitions for parameters in the recipe file: {}.",
missing_keys
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
if !extra_keys.is_empty() {
message.push_str(&format!(
"\nUnnecessary parameter definitions: {}.",
extra_keys
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
Err(anyhow::anyhow!("{}", message.trim_end()))
}
fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Result<()> {
let empty_params = vec![];
let params = parameters.as_ref().unwrap_or(&empty_params);
let file_params_with_defaults: Vec<String> = params
.iter()
.filter(|p| matches!(p.input_type, RecipeParameterInputType::File) && p.default.is_some())
.map(|p| p.key.clone())
.collect();
if !file_params_with_defaults.is_empty() {
return Err(anyhow::anyhow!("File parameters cannot have default values to avoid importing sensitive user files: {}", file_params_with_defaults.join(", ")));
}
let optional_params_without_default_values: Vec<String> = params
.iter()
.filter(|p| {
matches!(p.requirement, RecipeParameterRequirement::Optional) && p.default.is_none()
})
.map(|p| p.key.clone())
.collect();
if optional_params_without_default_values.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!("Optional parameters missing default values in the recipe: {}. Please provide defaults.", optional_params_without_default_values.join(", ")))
}
}
-9
View File
@@ -931,7 +931,6 @@
"tags": [
"Recipe Management"
],
"summary": "Create a Recipe configuration from the current session",
"operationId": "create_recipe",
"requestBody": {
"content": {
@@ -3619,7 +3618,6 @@
"RecipeManifestResponse": {
"type": "object",
"required": [
"name",
"recipe",
"lastModified",
"id"
@@ -3631,9 +3629,6 @@
"lastModified": {
"type": "string"
},
"name": {
"type": "string"
},
"recipe": {
"$ref": "#/components/schemas/Recipe"
}
@@ -3843,10 +3838,6 @@
"type": "string",
"nullable": true
},
"is_global": {
"type": "boolean",
"nullable": true
},
"recipe": {
"$ref": "#/components/schemas/Recipe"
}
-3
View File
@@ -274,9 +274,6 @@ export const startTetrateSetup = <ThrowOnError extends boolean = false>(options?
});
};
/**
* Create a Recipe configuration from the current session
*/
export const createRecipe = <ThrowOnError extends boolean = false>(options: Options<CreateRecipeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<CreateRecipeResponses, CreateRecipeErrors, ThrowOnError>({
url: '/recipes/create',
-2
View File
@@ -571,7 +571,6 @@ export type Recipe = {
export type RecipeManifestResponse = {
id: string;
lastModified: string;
name: string;
recipe: Recipe;
};
@@ -650,7 +649,6 @@ export type RunNowResponse = {
export type SaveRecipeRequest = {
id?: string | null;
is_global?: boolean | null;
recipe: Recipe;
};
+2
View File
@@ -157,6 +157,7 @@ function BaseChatContent({
// Use shared recipe manager
const {
recipe,
recipeId,
recipeParameters,
filteredParameters,
initialPrompt,
@@ -478,6 +479,7 @@ function BaseChatContent({
sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
recipe={recipe}
recipeId={recipeId}
recipeAccepted={recipeAccepted}
initialPrompt={initialPrompt}
toolCount={toolCount || 0}
+3
View File
@@ -82,6 +82,7 @@ interface ChatInputProps {
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
disableAnimation?: boolean;
recipe?: Recipe | null;
recipeId?: string | null;
recipeAccepted?: boolean;
initialPrompt?: string;
toolCount: number;
@@ -109,6 +110,7 @@ export default function ChatInput({
sessionCosts,
setIsGoosehintsModalOpen,
recipe,
recipeId,
recipeAccepted,
initialPrompt,
toolCount,
@@ -1619,6 +1621,7 @@ export default function ChatInput({
setView={setView}
alerts={alerts}
recipe={recipe}
recipeId={recipeId}
hasMessages={messages.length > 0}
/>
</div>
@@ -4,33 +4,30 @@ import { Recipe, generateDeepLink, Parameter } from '../../recipe';
import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { Check, Save, Calendar, X, Play } from 'lucide-react';
import { ExtensionConfig, useConfig } from '../ConfigContext';
import { FixedExtensionEntry } from '../ConfigContext';
import { ExtensionConfig } from '../ConfigContext';
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
import { Button } from '../ui/button';
import { RecipeFormFields } from './shared/RecipeFormFields';
import { RecipeFormData } from './shared/recipeFormSchema';
import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
import { saveRecipe } from '../../recipe/recipe_management';
interface CreateEditRecipeModalProps {
isOpen: boolean;
onClose: (wasSaved?: boolean) => void;
recipe?: Recipe;
recipeName?: string;
isCreateMode?: boolean;
recipeId?: string | null;
}
export default function CreateEditRecipeModal({
isOpen,
onClose,
recipe,
recipeName: initialRecipeName,
isCreateMode = false,
recipeId,
}: CreateEditRecipeModalProps) {
const { getExtensions } = useConfig();
const getInitialValues = React.useCallback((): RecipeFormData => {
if (recipe) {
return {
@@ -43,8 +40,6 @@ export default function CreateEditRecipeModal({
jsonSchema: recipe.response?.json_schema
? JSON.stringify(recipe.response.json_schema, null, 2)
: '',
recipeName: initialRecipeName || '',
global: true,
};
}
return {
@@ -55,10 +50,8 @@ export default function CreateEditRecipeModal({
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
};
}, [recipe, initialRecipeName]);
}, [recipe]);
const form = useForm({
defaultValues: getInitialValues(),
@@ -83,22 +76,16 @@ export default function CreateEditRecipeModal({
setActivities(form.state.values.activities);
setParameters(form.state.values.parameters);
setJsonSchema(form.state.values.jsonSchema);
setRecipeName(form.state.values.recipeName);
setGlobal(form.state.values.global);
});
}, [form]);
const [extensionOptions, setExtensionOptions] = useState<FixedExtensionEntry[]>([]);
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
const [copied, setCopied] = useState(false);
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [recipeName, setRecipeName] = useState(form.state.values.recipeName);
const [global, setGlobal] = useState(form.state.values.global);
const [isSaving, setIsSaving] = useState(false);
// Initialize selected extensions for the recipe
const [recipeExtensions] = useState<string[]>(() => {
const [recipeExtensions] = useState<ExtensionConfig[]>(() => {
if (recipe?.extensions) {
return recipe.extensions.map((ext) => ext.name);
return recipe.extensions;
}
return [];
});
@@ -111,31 +98,6 @@ export default function CreateEditRecipeModal({
}
}, [recipe, form, getInitialValues]);
// Load extensions when modal opens
useEffect(() => {
if (isOpen && !extensionsLoaded) {
const loadExtensions = async () => {
try {
const extensions = await getExtensions(false);
console.log('Loading extensions for recipe modal');
if (extensions && extensions.length > 0) {
const initializedExtensions = extensions.map((ext) => ({
...ext,
enabled: recipeExtensions.includes(ext.name),
}));
setExtensionOptions(initializedExtensions);
setExtensionsLoaded(true);
}
} catch (error) {
console.error('Failed to load extensions:', error);
}
};
loadExtensions();
}
}, [isOpen, getExtensions, recipeExtensions, extensionsLoaded]);
const getCurrentRecipe = useCallback((): Recipe => {
// Transform the internal parameters state into the desired output format.
const formattedParameters = parameters.map((param) => {
@@ -180,22 +142,10 @@ export default function CreateEditRecipeModal({
prompt: prompt || undefined,
parameters: formattedParameters,
response: responseConfig,
extensions: recipeExtensions
.map((name) => {
const extension = extensionOptions.find((e) => e.name === name);
if (!extension) return null;
// Create a clean copy of the extension configuration
const { enabled: _enabled, ...cleanExtension } = extension;
// Remove legacy envs which could potentially include secrets
if ('envs' in cleanExtension) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { envs: _envs, ...finalExtension } = cleanExtension as any;
return finalExtension;
}
return cleanExtension;
})
.filter(Boolean) as ExtensionConfig[],
// Strip envs to avoid leaking secrets
extensions: recipeExtensions.map((extension) =>
'envs' in extension ? { ...extension, envs: undefined } : extension
) as ExtensionConfig[],
};
}, [
recipe,
@@ -207,7 +157,6 @@ export default function CreateEditRecipeModal({
parameters,
jsonSchema,
recipeExtensions,
extensionOptions,
]);
const requiredFieldsAreFilled = () => {
@@ -312,15 +261,12 @@ export default function CreateEditRecipeModal({
try {
const recipe = getCurrentRecipe();
await saveRecipe(recipe, {
name: (recipeName || '').trim(),
global: global,
});
await saveRecipe(recipe, recipeId);
onClose(true);
toastSuccess({
title: (recipeName || '').trim(),
title: (recipe.title || '').trim(),
msg: 'Recipe saved successfully',
});
} catch (error) {
@@ -348,21 +294,25 @@ export default function CreateEditRecipeModal({
setIsSaving(true);
try {
const recipe = getCurrentRecipe();
const recipeName = generateRecipeFilename(recipe);
await saveRecipe(recipe, {
name: recipeName,
global: true,
});
await saveRecipe(recipe, recipeId);
// Close modal first
onClose(true);
// Open recipe in a new window instead of navigating in the same window
window.electron.createChatWindow(undefined, undefined, undefined, undefined, recipe);
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
recipe,
undefined,
recipeId ?? undefined
);
toastSuccess({
title: recipeName,
title: recipe.title,
msg: 'Recipe saved and launched successfully',
});
} catch (error) {
@@ -509,7 +459,8 @@ export default function CreateEditRecipeModal({
undefined,
undefined,
undefined,
'schedules'
'schedules',
undefined
);
// Store the deep link in localStorage for the schedules view to pick up
localStorage.setItem('pendingScheduleDeepLink', deepLink);
@@ -9,7 +9,7 @@ import { RecipeFormData } from './shared/recipeFormSchema';
import { createRecipe } from '../../api/sdk.gen';
import { RecipeParameter } from './shared/recipeFormSchema';
import { toastError } from '../../toasts';
import { generateRecipeFilename } from '../../recipe/recipeStorage';
import { saveRecipe } from '../../recipe/recipe_management';
interface CreateRecipeFromSessionModalProps {
isOpen: boolean;
@@ -91,7 +91,6 @@ export default function CreateRecipeFromSessionModal({
form.setFieldValue('instructions', recipe.instructions || '');
form.setFieldValue('activities', recipe.activities || []);
form.setFieldValue('parameters', recipe.parameters || []);
form.setFieldValue('recipeName', generateRecipeFilename(recipe));
if (recipe.response?.json_schema) {
form.setFieldValue(
@@ -184,12 +183,7 @@ export default function CreateRecipeFromSessionModal({
extensions: [], // Will be populated based on current extensions
};
const { saveRecipe } = await import('../../recipe/recipeStorage');
await saveRecipe(recipe, {
name: formData.recipeName || formData.title,
title: formData.title,
global: formData.global,
});
await saveRecipe(recipe, null);
onRecipeCreated?.(recipe);
onClose();
@@ -5,17 +5,11 @@ import { Download } from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Recipe, decodeRecipe } from '../../recipe';
import { saveRecipe } from '../../recipe/recipeStorage';
import * as yaml from 'yaml';
import { toastSuccess, toastError } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { RecipeTitleField } from './shared/RecipeTitleField';
import { listSavedRecipes } from '../../recipe/recipeStorage';
import {
validateRecipe,
getValidationErrorMessages,
getRecipeJsonSchema,
} from '../../recipe/validation';
import { getRecipeJsonSchema } from '../../recipe/validation';
import { saveRecipe } from '../../recipe/recipe_management';
import { parseRecipe } from '../../api';
interface ImportRecipeFormProps {
isOpen: boolean;
@@ -39,16 +33,6 @@ const importRecipeSchema = z
if (!file) return true;
return file.size <= 1024 * 1024;
}, 'File is too large, max size is 1MB'),
recipeTitle: z
.string()
.min(1, 'Recipe title is required')
.max(100, 'Recipe title must be 100 characters or less')
.refine((title) => title.trim().length > 0, 'Recipe title cannot be empty')
.refine(
(title) => /^[^<>:"/\\|?*]+$/.test(title.trim()),
'Recipe title contains invalid characters (< > : " / \\ | ? *)'
),
global: z.boolean(),
})
.refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, {
message: 'Either of deeplink or recipe file are required',
@@ -91,57 +75,28 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
}
};
const parseRecipeUploadFile = async (fileContent: string, fileName: string): Promise<Recipe> => {
const isJsonFile = fileName.toLowerCase().endsWith('.json');
let parsed;
const parseRecipeFromFile = async (fileContent: string): Promise<Recipe> => {
try {
if (isJsonFile) {
parsed = JSON.parse(fileContent);
} else {
parsed = yaml.parse(fileContent);
}
let response = await parseRecipe({
body: {
content: fileContent,
},
throwOnError: true,
});
return response.data.recipe;
} catch (error) {
throw new Error(
`Failed to parse ${isJsonFile ? 'JSON' : 'YAML'} file: ${error instanceof Error ? error.message : 'Invalid format'}`
);
}
if (!parsed) {
throw new Error(`${isJsonFile ? 'JSON' : 'YAML'} file is empty or contains invalid content`);
}
// Handle both CLI format (flat structure) and Desktop format (nested under 'recipe' key)
const recipe = parsed.recipe || parsed;
return recipe as Recipe;
};
const validateTitleUniqueness = async (title: string): Promise<string | undefined> => {
if (!title.trim()) return undefined;
try {
const existingRecipes = await listSavedRecipes();
const titleExists = existingRecipes.some(
(recipe) => recipe.recipe.title?.toLowerCase() === title.toLowerCase()
);
if (titleExists) {
return `A recipe with the same title already exists`;
let error_message = 'unknown error';
if (typeof error === 'object' && error !== null && 'message' in error) {
error_message = error.message as string;
}
} catch (error) {
console.warn('Failed to validate title uniqueness:', error);
throw new Error(error_message);
}
return undefined;
};
const importRecipeForm = useForm({
defaultValues: {
deeplink: '',
recipeUploadFile: null as File | null,
recipeTitle: '',
global: true,
},
validators: {
onChange: importRecipeSchema,
@@ -160,41 +115,22 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
recipe = parsedRecipe;
} else {
const fileContent = await value.recipeUploadFile!.text();
recipe = await parseRecipeUploadFile(fileContent, value.recipeUploadFile!.name);
recipe = await parseRecipeFromFile(fileContent);
}
recipe.title = value.recipeTitle.trim();
const titleValidationError = await validateTitleUniqueness(value.recipeTitle.trim());
if (titleValidationError) {
throw new Error(titleValidationError);
}
const validationResult = validateRecipe(recipe);
if (!validationResult.success) {
const errorMessages = getValidationErrorMessages(validationResult.errors);
throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`);
}
await saveRecipe(recipe, {
name: '',
title: value.recipeTitle.trim(),
global: value.global,
});
await saveRecipe(recipe, null);
// Reset dialog state
importRecipeForm.reset({
deeplink: '',
recipeUploadFile: null,
recipeTitle: '',
global: true,
});
onClose();
onSuccess();
toastSuccess({
title: value.recipeTitle.trim(),
title: recipe.title.trim(),
msg: 'Recipe imported successfully',
});
} catch (error) {
@@ -215,14 +151,10 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
importRecipeForm.reset({
deeplink: '',
recipeUploadFile: null,
recipeTitle: '',
global: true,
});
onClose();
};
let recipeTitleFieldRef: { handleChange: (value: string) => void } | null = null;
const handleDeeplinkChange = async (
value: string,
field: { handleChange: (value: string) => void }
@@ -231,26 +163,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
if (value.trim()) {
try {
const recipe = await parseDeeplink(value.trim());
if (recipe && recipe.title) {
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange(recipe.title);
} else {
importRecipeForm.setFieldValue('recipeTitle', recipe.title);
}
}
await parseDeeplink(value.trim());
} catch (error) {
toastError({
title: 'Invalid Deeplink',
msg: `The deeplink format is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
} else {
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange('');
} else {
importRecipeForm.setFieldValue('recipeTitle', '');
}
}
};
@@ -260,26 +179,13 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
if (file) {
try {
const fileContent = await file.text();
const recipe = await parseRecipeUploadFile(fileContent, file.name);
if (recipe.title) {
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange(recipe.title);
} else {
importRecipeForm.setFieldValue('recipeTitle', recipe.title);
}
}
await parseRecipeFromFile(fileContent);
} catch (error) {
toastError({
title: 'Invalid Recipe File',
msg: `The recipe file format is invalid: ${error instanceof Error ? error.message : 'Unknown error'}`,
msg: error instanceof Error ? error.message : 'Unknown error',
});
}
} else {
if (recipeTitleFieldRef) {
recipeTitleFieldRef.handleChange('');
} else {
importRecipeForm.setFieldValue('recipeTitle', '');
}
}
};
@@ -421,61 +327,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
Ensure you review contents of recipe files before adding them to your goose
interface.
</p>
<importRecipeForm.Field name="recipeTitle">
{(field) => {
// Store reference to the field for programmatic updates
recipeTitleFieldRef = field;
return (
<RecipeTitleField
id="import-recipe-title"
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
errors={field.state.meta.errors.map((error) =>
typeof error === 'string' ? error : error?.message || String(error)
)}
/>
);
}}
</importRecipeForm.Field>
<importRecipeForm.Field name="global">
{(field) => (
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={field.state.value === true}
onChange={() => field.handleChange(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={field.state.value === false}
onChange={() => field.handleChange(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
)}
</importRecipeForm.Field>
</div>
<div className="flex justify-end space-x-3 mt-6">
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipeStorage';
import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipe_management';
import { FileText, Edit, Trash2, Play, Calendar, AlertCircle, Link } from 'lucide-react';
import { ScrollArea } from '../ui/scroll-area';
import { Card } from '../ui/card';
@@ -65,7 +65,7 @@ export default function RecipesView() {
}
};
const handleLoadRecipe = async (recipe: Recipe) => {
const handleLoadRecipe = async (recipe: Recipe, recipeId: string) => {
try {
// onLoadRecipe is not working for loading recipes. It looks correct
// but the instructions are not flowing through to the server.
@@ -82,7 +82,8 @@ export default function RecipesView() {
undefined, // version
undefined, // resumeSessionId
recipe, // recipe config
undefined // view type
undefined, // view type,
recipeId // recipe id
);
// }
} catch (err) {
@@ -98,7 +99,7 @@ export default function RecipesView() {
buttons: ['Cancel', 'Delete'],
defaultId: 0,
title: 'Delete Recipe',
message: `Are you sure you want to delete "${recipeManifest.name}"?`,
message: `Are you sure you want to delete "${recipeManifest.recipe.title}"?`,
detail: 'Recipe file will be deleted.',
});
@@ -110,7 +111,7 @@ export default function RecipesView() {
await deleteRecipe({ body: { id: recipeManifest.id } });
await loadSavedRecipes();
toastSuccess({
title: recipeManifest.name,
title: recipeManifest.recipe.title,
msg: 'Recipe deleted successfully',
});
} catch (err) {
@@ -174,7 +175,7 @@ export default function RecipesView() {
<Button
onClick={(e) => {
e.stopPropagation();
handleLoadRecipe(recipe);
handleLoadRecipe(recipe, recipeManifestResponse.id);
}}
size="sm"
className="h-8 w-8 p-0"
@@ -339,7 +340,7 @@ export default function RecipesView() {
isOpen={showEditor}
onClose={handleEditorClose}
recipe={selectedRecipe.recipe}
recipeName={selectedRecipe.name}
recipeId={selectedRecipe.id}
/>
)}
@@ -13,7 +13,7 @@ vi.mock('../../../toasts', () => ({
toastError: vi.fn(),
}));
vi.mock('../../../recipe/recipeStorage', () => ({
vi.mock('../../../recipe/recipe_management', () => ({
saveRecipe: vi.fn(),
}));
@@ -146,8 +146,6 @@ describe('CreateRecipeFromSessionModal', () => {
expect(screen.getByDisplayValue('Analyzed instructions with {{param1}}')).toBeInTheDocument();
const promptInput = screen.getByTestId('prompt-input');
expect(promptInput).toBeInTheDocument();
const recipeNameInput = screen.getByTestId('recipe-name-input');
expect(recipeNameInput).toBeInTheDocument();
});
it('shows recipe form fields after analysis', async () => {
@@ -164,21 +162,6 @@ describe('CreateRecipeFromSessionModal', () => {
expect(screen.getByTestId('description-input')).toBeInTheDocument();
expect(screen.getByTestId('instructions-input')).toBeInTheDocument();
expect(screen.getByTestId('prompt-input')).toBeInTheDocument();
expect(screen.getByTestId('recipe-name-input')).toBeInTheDocument();
});
it('shows save location options', async () => {
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('save-location-field')).toBeInTheDocument();
},
{ timeout: 2000 }
);
expect(screen.getByTestId('global-radio')).toBeInTheDocument();
expect(screen.getByTestId('directory-radio')).toBeInTheDocument();
});
});
@@ -201,21 +184,6 @@ describe('CreateRecipeFromSessionModal', () => {
expect(screen.getByDisplayValue('Modified Title')).toBeInTheDocument();
});
it('allows changing save location', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
await waitFor(
() => {
expect(screen.getByTestId('directory-radio')).toBeInTheDocument();
},
{ timeout: 2000 }
);
await user.click(screen.getByTestId('directory-radio'));
expect(screen.getByTestId('directory-radio')).toBeChecked();
});
it('validates required fields', async () => {
const user = userEvent.setup();
render(<CreateRecipeFromSessionModal {...defaultProps} />);
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { Parameter } from '../../../recipe';
import { RecipeNameField } from './RecipeNameField';
import ParameterInput from '../../parameter/ParameterInput';
import RecipeActivityEditor from '../RecipeActivityEditor';
@@ -24,8 +23,6 @@ interface RecipeFormFieldsProps {
onInstructionsChange?: (value: string) => void;
onPromptChange?: (value: string) => void;
onJsonSchemaChange?: (value: string) => void;
onRecipeNameChange?: (value: string) => void;
onGlobalChange?: (value: boolean) => void;
}
export function RecipeFormFields({
@@ -35,8 +32,6 @@ export function RecipeFormFields({
onInstructionsChange,
onPromptChange,
onJsonSchemaChange,
onRecipeNameChange,
onGlobalChange,
}: RecipeFormFieldsProps) {
const [showJsonSchemaEditor, setShowJsonSchemaEditor] = useState(false);
const [showInstructionsEditor, setShowInstructionsEditor] = useState(false);
@@ -460,71 +455,6 @@ export function RecipeFormFields({
</div>
)}
</form.Field>
{/* Recipe Name Field */}
<form.Field name="recipeName">
{(field: FormFieldApi<string | undefined>) => (
<div>
<div data-testid="recipe-name-field">
<RecipeNameField
id="recipe-name-field"
value={field.state.value || ''}
onChange={(value) => {
field.handleChange(value);
onRecipeNameChange?.(value);
}}
onBlur={field.handleBlur}
errors={field.state.meta.errors}
/>
</div>
</div>
)}
</form.Field>
{/* Save Location Field */}
<form.Field name="global">
{(field: FormFieldApi<boolean>) => (
<div data-testid="save-location-field">
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={field.state.value === true}
onChange={() => {
field.handleChange(true);
onGlobalChange?.(true);
}}
className="mr-2"
data-testid="global-radio"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={field.state.value === false}
onChange={() => {
field.handleChange(false);
onGlobalChange?.(false);
}}
className="mr-2"
data-testid="directory-radio"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
)}
</form.Field>
</div>
);
}
@@ -1,45 +0,0 @@
interface RecipeTitleFieldProps {
id: string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
errors: string[];
label?: string;
required?: boolean;
disabled?: boolean;
}
export function RecipeTitleField({
id,
value,
onChange,
onBlur,
errors,
label = 'Recipe Title',
required = true,
disabled = false,
}: RecipeTitleFieldProps) {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-text-standard mb-2">
{label} {required && <span className="text-red-500">*</span>}
</label>
<input
id={id}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
disabled={disabled}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
placeholder="My Recipe Title"
/>
<p className="text-xs text-text-muted mt-1">
This will be the display name shown in your recipe library
</p>
{errors.length > 0 && <p className="text-red-500 text-sm mt-1">{errors[0]}</p>}
</div>
);
}
@@ -1,212 +0,0 @@
import React, { useState } from 'react';
import { Button } from '../../ui/button';
import { Recipe } from '../../../recipe';
import { saveRecipe, generateRecipeFilename } from '../../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../../toasts';
import { useEscapeKey } from '../../../hooks/useEscapeKey';
import { Play } from 'lucide-react';
interface SaveRecipeDialogProps {
isOpen: boolean;
onClose: (wasSaved?: boolean) => void;
onSuccess?: () => void;
recipe: Recipe;
suggestedName?: string;
showSaveAndRun?: boolean;
onSaveAndRun?: (recipe: Recipe) => void;
}
export default function SaveRecipeDialog({
isOpen,
onClose,
onSuccess,
recipe,
suggestedName,
showSaveAndRun = false,
onSaveAndRun,
}: SaveRecipeDialogProps) {
const [saveRecipeName, setSaveRecipeName] = useState(
suggestedName || generateRecipeFilename(recipe)
);
const [saveGlobal, setSaveGlobal] = useState(true);
const [saving, setSaving] = useState(false);
useEscapeKey(isOpen, onClose);
React.useEffect(() => {
if (isOpen) {
setSaveRecipeName(suggestedName || generateRecipeFilename(recipe));
setSaveGlobal(true);
setSaving(false);
}
}, [isOpen, suggestedName, recipe]);
const handleSaveRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
if (!recipe.title || !recipe.description || !recipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(recipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
setSaveRecipeName('');
onClose(true);
toastSuccess({
title: saveRecipeName.trim(),
msg: 'Recipe saved successfully',
});
onSuccess?.();
} catch (error) {
console.error('Failed to save recipe:', error);
toastError({
title: 'Save Failed',
msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
const handleSaveAndRunRecipe = async () => {
if (!saveRecipeName.trim()) {
return;
}
setSaving(true);
try {
if (!recipe.title || !recipe.description || !recipe.instructions) {
throw new Error('Invalid recipe configuration: missing required fields');
}
await saveRecipe(recipe, {
name: saveRecipeName.trim(),
global: saveGlobal,
});
setSaveRecipeName('');
onClose(true);
toastSuccess({
title: saveRecipeName.trim(),
msg: 'Recipe saved and launched successfully',
});
// Launch the recipe in a new window
onSaveAndRun?.(recipe);
onSuccess?.();
} catch (error) {
console.error('Failed to save and run recipe:', error);
toastError({
title: 'Save and Run Failed',
msg: `Failed to save and run recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setSaving(false);
}
};
const handleClose = () => {
setSaveRecipeName('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[500] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-96 max-w-[90vw]">
<h3 className="text-lg font-medium text-text-standard mb-4">Save Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="recipe-name"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Name
</label>
<input
id="recipe-name"
type="text"
value={saveRecipeName}
onChange={(e) => setSaveRecipeName(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter recipe name"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={saveGlobal}
onChange={() => setSaveGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="save-location"
checked={!saveGlobal}
onChange={() => setSaveGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button type="button" onClick={handleClose} variant="ghost" disabled={saving}>
Cancel
</Button>
<Button
onClick={handleSaveRecipe}
disabled={!saveRecipeName.trim() || saving}
variant="outline"
>
{saving ? 'Saving...' : 'Save Recipe'}
</Button>
{showSaveAndRun && (
<Button
onClick={handleSaveAndRunRecipe}
disabled={!saveRecipeName.trim() || saving}
variant="default"
className="inline-flex items-center justify-center gap-2"
>
<Play className="w-4 h-4" />
{saving ? 'Saving...' : 'Save & Run Recipe'}
</Button>
)}
</div>
</div>
</div>
);
}
@@ -16,8 +16,6 @@ describe('RecipeFormFields', () => {
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
...initialValues,
};
@@ -129,19 +127,6 @@ describe('RecipeFormFields', () => {
});
});
describe('Always Visible Fields', () => {
it('always shows recipe name field', () => {
render(<TestWrapper />);
expect(screen.getByText('Recipe Name')).toBeInTheDocument();
});
it('always shows save location field', () => {
render(<TestWrapper />);
expect(screen.getByText('Save Location')).toBeInTheDocument();
expect(screen.getByText('Global - Available across all Goose sessions')).toBeInTheDocument();
});
});
describe('Pre-filled Values', () => {
it('displays pre-filled form values', () => {
const initialValues: Partial<RecipeFormData> = {
@@ -262,8 +247,6 @@ describe('RecipeFormFields', () => {
activities: [],
parameters: [],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -355,8 +338,6 @@ describe('RecipeFormFields', () => {
},
],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -522,8 +503,6 @@ describe('RecipeFormFields', () => {
},
],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -597,8 +576,6 @@ describe('RecipeFormFields', () => {
},
],
jsonSchema: '',
recipeName: '',
global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -17,8 +17,6 @@ describe('recipeFormSchema', () => {
},
],
jsonSchema: '{"type": "object"}',
recipeName: 'test_recipe',
global: true,
};
describe('Zod Schema Validation', () => {
@@ -154,16 +152,6 @@ describe('recipeFormSchema', () => {
expect(result.success).toBe(true);
});
it('rejects invalid JSON schema', () => {
const invalidData = { ...validFormData, jsonSchema: 'invalid json' };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const jsonError = result.error.issues.find((issue) => issue.path.includes('jsonSchema'));
expect(jsonError?.message).toBe('Invalid JSON schema format');
}
});
it('allows empty JSON schema', () => {
const validData = { ...validFormData, jsonSchema: '' };
const result = recipeFormSchema.safeParse(validData);
@@ -177,31 +165,6 @@ describe('recipeFormSchema', () => {
});
});
describe('Recipe Name Validation', () => {
it('allows empty recipe name', () => {
const validData = { ...validFormData, recipeName: '' };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('allows undefined recipe name', () => {
const validData = { ...validFormData, recipeName: undefined };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('rejects invalid recipe name characters', () => {
// The regex /^[^<>:"/\\|?*]+$/ rejects these specific characters
const invalidData = { ...validFormData, recipeName: 'invalid<name' };
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const nameError = result.error.issues.find((issue) => issue.path.includes('recipeName'));
expect(nameError?.message).toContain('invalid characters');
}
});
});
describe('Parameter Validation', () => {
it('validates parameters with all required fields', () => {
const validData = {
@@ -305,20 +268,6 @@ describe('recipeFormSchema', () => {
});
});
describe('Global Field Validation', () => {
it('validates global field as boolean true', () => {
const validData = { ...validFormData, global: true };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('validates global field as boolean false', () => {
const validData = { ...validFormData, global: false };
const result = recipeFormSchema.safeParse(validData);
expect(result.success).toBe(true);
});
});
describe('Multiple Validation Errors', () => {
it('handles multiple validation errors', () => {
const invalidData = {
@@ -326,7 +275,6 @@ describe('recipeFormSchema', () => {
title: 'AB', // Too short
description: 'Short', // Too short
instructions: 'Short', // Too short
jsonSchema: 'invalid json',
};
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
@@ -339,7 +287,6 @@ describe('recipeFormSchema', () => {
expect(result.error.issues.some((issue) => issue.path.includes('instructions'))).toBe(
true
);
expect(result.error.issues.some((issue) => issue.path.includes('jsonSchema'))).toBe(true);
}
});
});
@@ -1,5 +1,4 @@
import { z } from 'zod';
import { validateJsonSchema } from '../../../recipe/validation';
// Zod schema for Parameter - matching API RecipeParameter type
const parameterSchema = z.object({
@@ -39,29 +38,7 @@ export const recipeFormSchema = z.object({
parameters: z.array(parameterSchema).default([]),
jsonSchema: z
.string()
.optional()
.refine((value) => {
if (!value || !value.trim()) return true;
try {
const parsed = JSON.parse(value.trim());
const validationResult = validateJsonSchema(parsed);
return validationResult.success;
} catch {
return false;
}
}, 'Invalid JSON schema format'),
recipeName: z
.string()
.optional()
.refine((name) => {
if (!name || !name.trim()) return true;
return /^[^<>:"/\\|?*]+$/.test(name.trim());
}, 'Recipe name contains invalid characters (< > : " / \\ | ? *)'),
global: z.boolean().default(true),
jsonSchema: z.string().optional(),
});
export type RecipeFormData = z.infer<typeof recipeFormSchema>;
@@ -6,7 +6,7 @@ import { Select } from '../ui/Select';
import cronstrue from 'cronstrue';
import * as yaml from 'yaml';
import { Recipe, decodeRecipe } from '../../recipe';
import { getStorageDirectory } from '../../recipe/recipeStorage';
import { getStorageDirectory } from '../../recipe/recipe_management';
import ClockIcon from '../../assets/clock-icon.svg';
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
@@ -1,4 +1,4 @@
import { Sliders, ChefHat, Bot, Eye, Save } from 'lucide-react';
import { Sliders, ChefHat, Bot, Eye } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { SwitchModelModal } from '../subcomponents/SwitchModelModal';
@@ -17,9 +17,7 @@ import { getProviderMetadata } from '../modelInterface';
import { Alert } from '../../../alerts';
import BottomMenuAlertPopover from '../../../bottom_menu/BottomMenuAlertPopover';
import { Recipe } from '../../../../recipe';
import { generateRecipeFilename } from '../../../../recipe/recipeStorage';
import CreateEditRecipeModal from '../../../recipes/CreateEditRecipeModal';
import SaveRecipeDialog from '../../../recipes/shared/SaveRecipeDialog';
interface ModelsBottomBarProps {
sessionId: string | null;
@@ -27,6 +25,7 @@ interface ModelsBottomBarProps {
setView: (view: View) => void;
alerts: Alert[];
recipe?: Recipe | null;
recipeId?: string | null;
hasMessages?: boolean; // Add prop to know if there are messages to create a recipe from
}
@@ -36,6 +35,7 @@ export default function ModelsBottomBar({
setView,
alerts,
recipe,
recipeId,
hasMessages = false,
}: ModelsBottomBarProps) {
const {
@@ -54,9 +54,6 @@ export default function ModelsBottomBar({
const [isLeadWorkerActive, setIsLeadWorkerActive] = useState(false);
const [providerDefaultModel, setProviderDefaultModel] = useState<string | null>(null);
// Save recipe dialog state
const [showSaveDialog, setShowSaveDialog] = useState(false);
// View recipe modal state
const [showViewRecipeModal, setShowViewRecipeModal] = useState(false);
@@ -174,13 +171,6 @@ export default function ModelsBottomBar({
}
};
// Handle save recipe - show save dialog
const handleSaveRecipeClick = () => {
if (recipe) {
setShowSaveDialog(true);
}
};
return (
<div className="relative flex items-center" ref={dropdownRef}>
<BottomMenuAlertPopover alerts={alerts} />
@@ -219,10 +209,6 @@ export default function ModelsBottomBar({
<span>View/Edit Recipe</span>
<Eye className="ml-auto h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSaveRecipeClick}>
<span>Save Recipe</span>
<Save className="ml-auto h-4 w-4" />
</DropdownMenuItem>
</>
)}
@@ -256,15 +242,6 @@ export default function ModelsBottomBar({
<LeadWorkerSettings isOpen={isLeadWorkerModalOpen} onClose={handleLeadWorkerModalClose} />
) : null}
{/* Save Recipe Dialog */}
{showSaveDialog && recipe && (
<SaveRecipeDialog
isOpen={showSaveDialog}
onClose={() => setShowSaveDialog(false)}
recipe={recipe}
/>
)}
{/* View Recipe Modal */}
{/* todo: we don't have the actual recipe name when in chat only in recipes list view so we generate it for now */}
{recipe && (
@@ -272,7 +249,7 @@ export default function ModelsBottomBar({
isOpen={showViewRecipeModal}
onClose={() => setShowViewRecipeModal(false)}
recipe={recipe}
recipeName={generateRecipeFilename(recipe)}
recipeId={recipeId}
/>
)}
</div>
-1
View File
@@ -52,7 +52,6 @@ export function useAgent(): UseAgentReturn {
const [recipeFromAppConfig, setRecipeFromAppConfig] = useState<Recipe | null>(
(window.appConfig.get('recipe') as Recipe) || null
);
const { getExtensions, addExtension, read } = useConfig();
const resetChat = useCallback(() => {
+4
View File
@@ -286,8 +286,12 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => {
});
};
const recipeId: string | null =
(window.appConfig.get('recipeId') as string | null | undefined) ?? null;
return {
recipe: finalRecipe,
recipeId,
recipeParameters,
filteredParameters,
initialPrompt,
+28 -12
View File
@@ -509,7 +509,8 @@ const createChat = async (
recipe?: Recipe, // Recipe configuration when already loaded, takes precedence over deeplink
viewType?: string,
recipeDeeplink?: string, // Raw deeplink used as a fallback when recipe is not loaded. Required on new windows as we need to wait for the window to load before decoding.
scheduledJobId?: string // Scheduled job ID if applicable
scheduledJobId?: string, // Scheduled job ID if applicable
recipeId?: string
) => {
// Initialize variables for process and configuration
let port = 0;
@@ -581,6 +582,7 @@ const createChat = async (
GOOSE_BASE_URL_SHARE: baseUrlShare,
GOOSE_VERSION: version,
recipe: recipe,
recipeId: recipeId,
}),
],
partition: 'persist:goose', // Add this line to ensure persistence
@@ -1961,18 +1963,32 @@ async function appMain() {
}
});
ipcMain.on('create-chat-window', (_, query, dir, version, resumeSessionId, recipe, viewType) => {
if (!dir?.trim()) {
const recentDirs = loadRecentDirs();
dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
ipcMain.on(
'create-chat-window',
(_, query, dir, version, resumeSessionId, recipe, viewType, recipeId) => {
if (!dir?.trim()) {
const recentDirs = loadRecentDirs();
dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
}
// Log the recipe for debugging
console.log('Creating chat window with recipe:', recipe);
// Pass recipe as part of viewOptions when viewType is recipeEditor
createChat(
app,
query,
dir,
version,
resumeSessionId,
recipe,
viewType,
undefined,
undefined,
recipeId
);
}
// Log the recipe for debugging
console.log('Creating chat window with recipe:', recipe);
// Pass recipe as part of viewOptions when viewType is recipeEditor
createChat(app, query, dir, version, resumeSessionId, recipe, viewType);
});
);
ipcMain.on('notify', (_event, data) => {
try {
+14 -3
View File
@@ -53,7 +53,8 @@ type ElectronAPI = {
version?: string,
resumeSessionId?: string,
recipe?: Recipe,
viewType?: string
viewType?: string,
recipeId?: string
) => void;
logInfo: (txt: string) => void;
showNotification: (data: NotificationData) => void;
@@ -140,9 +141,19 @@ const electronAPI: ElectronAPI = {
version?: string,
resumeSessionId?: string,
recipe?: Recipe,
viewType?: string
viewType?: string,
recipeId?: string
) =>
ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId, recipe, viewType),
ipcRenderer.send(
'create-chat-window',
query,
dir,
version,
resumeSessionId,
recipe,
viewType,
recipeId
),
logInfo: (txt: string) => ipcRenderer.send('logInfo', txt),
showNotification: (data: NotificationData) => ipcRenderer.send('notify', data),
showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options),
-157
View File
@@ -1,157 +0,0 @@
import { listRecipes, RecipeManifestResponse } from '../api';
import { Recipe } from './index';
import * as yaml from 'yaml';
import { validateRecipe, getValidationErrorMessages } from './validation';
export interface SaveRecipeOptions {
name: string;
title?: string;
global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/)
}
export interface SavedRecipe {
name: string;
recipe: Recipe;
isGlobal: boolean;
lastModified: Date;
isArchived?: boolean;
filename: string; // The actual filename used
}
/**
* Sanitize a recipe name to be safe for use as a filename
*/
function sanitizeRecipeName(name: string): string {
return name.replace(/[^a-zA-Z0-9-_\s]/g, '').trim();
}
/**
* Parse a lastModified value that could be a string or Date
*/
function parseLastModified(val: string | Date): Date {
return val instanceof Date ? val : new Date(val);
}
/**
* Get the storage directory path for recipes
*/
export function getStorageDirectory(isGlobal: boolean): string {
if (isGlobal) {
return '~/.config/goose/recipes';
} else {
// For directory recipes, build absolute path using working directory
const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
return `${workingDir}/.goose/recipes`;
}
}
/**
* Get the file path for a recipe based on its name
*/
function getRecipeFilePath(recipeName: string, isGlobal: boolean): string {
const dir = getStorageDirectory(isGlobal);
return `${dir}/${recipeName}.yaml`;
}
/**
* Save recipe to file
*/
async function saveRecipeToFile(recipe: SavedRecipe): Promise<boolean> {
const filePath = getRecipeFilePath(recipe.name, recipe.isGlobal);
// Ensure directory exists
const dirPath = getStorageDirectory(recipe.isGlobal);
await window.electron.ensureDirectory(dirPath);
// Convert to YAML and save
const yamlContent = yaml.stringify(recipe);
return await window.electron.writeFile(filePath, yamlContent);
}
/**
* Save a recipe to a file using IPC.
*/
export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise<string> {
const { name, title, global = true } = options;
let sanitizedName: string;
if (title) {
recipe.title = title.trim();
sanitizedName = generateRecipeFilename(recipe);
if (!sanitizedName) {
throw new Error('Invalid recipe title - cannot generate filename');
}
} else {
// This branch should now be considered deprecated and will be removed once the same functionality
// is incorporated in CreateRecipeForm
sanitizedName = sanitizeRecipeName(name);
if (!sanitizedName) {
throw new Error('Invalid recipe name');
}
}
const validationResult = validateRecipe(recipe);
if (!validationResult.success) {
const errorMessages = getValidationErrorMessages(validationResult.errors);
throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`);
}
try {
// Create saved recipe object
const savedRecipe: SavedRecipe = {
name: sanitizedName,
filename: sanitizedName,
recipe: recipe,
isGlobal: global,
lastModified: new Date(),
isArchived: false,
};
// Save to file
const success = await saveRecipeToFile(savedRecipe);
if (!success) {
throw new Error('Failed to save recipe file');
}
// Return identifier for the saved recipe
return `${global ? 'global' : 'local'}:${sanitizedName}`;
} catch (error) {
throw new Error(
`Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function listSavedRecipes(): Promise<RecipeManifestResponse[]> {
try {
const listRecipeResponse = await listRecipes();
return listRecipeResponse?.data?.recipe_manifest_responses ?? [];
} catch (error) {
console.warn('Failed to list saved recipes:', error);
return [];
}
}
export function convertToLocaleDateString(lastModified: string): string {
if (lastModified) {
return parseLastModified(lastModified).toLocaleDateString();
}
return '';
}
/**
* Generate a suggested filename for a recipe based on its title.
*
* @param recipe The recipe to generate a filename for
* @returns A sanitized filename suitable for use as a recipe name
*/
export function generateRecipeFilename(recipe: Recipe): string {
const baseName = recipe.title
.toLowerCase()
.replace(/[^a-zA-Z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.trim();
return baseName || 'untitled-recipe';
}
@@ -0,0 +1,50 @@
import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api';
export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise<void> {
try {
await saveRecipeApi({
body: {
recipe,
id: recipeId,
},
throwOnError: true,
});
} catch (error) {
let error_message = 'unknown error';
if (typeof error === 'object' && error !== null && 'message' in error) {
error_message = error.message as string;
}
throw new Error(error_message);
}
}
export async function listSavedRecipes(): Promise<RecipeManifestResponse[]> {
try {
const listRecipeResponse = await listRecipes();
return listRecipeResponse?.data?.recipe_manifest_responses ?? [];
} catch (error) {
console.warn('Failed to list saved recipes:', error);
return [];
}
}
function parseLastModified(val: string | Date): Date {
return val instanceof Date ? val : new Date(val);
}
export function convertToLocaleDateString(lastModified: string): string {
if (lastModified) {
return parseLastModified(lastModified).toLocaleDateString();
}
return '';
}
export function getStorageDirectory(isGlobal: boolean): string {
if (isGlobal) {
return '~/.config/goose/recipes';
} else {
// For directory recipes, build absolute path using working directory
const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
return `${workingDir}/.goose/recipes`;
}
}
+1 -531
View File
@@ -1,379 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
validateRecipe,
validateJsonSchema,
getValidationErrorMessages,
getRecipeJsonSchema,
} from './validation';
import type { Recipe } from '../api/types.gen';
import { getRecipeJsonSchema } from './validation';
describe('Recipe Validation', () => {
const validRecipe: Recipe = {
version: '1.0.0',
title: 'Test Recipe',
description: 'A test recipe for validation',
instructions: 'Do something useful',
activities: ['Test activity 1', 'Test activity 2'],
extensions: [
{
type: 'builtin',
name: 'developer',
display_name: 'Developer',
description: 'Developer',
timeout: 300,
bundled: true,
},
],
};
const validRecipeWithPrompt: Recipe = {
version: '1.0.0',
title: 'Prompt Recipe',
description: 'A recipe using prompt instead of instructions',
prompt: 'You are a helpful assistant',
activities: ['Help users'],
extensions: [
{
type: 'builtin',
name: 'developer',
description: 'Developer',
},
],
};
const validRecipeWithParameters: Recipe = {
version: '1.0.0',
title: 'Parameterized Recipe',
description: 'A recipe with parameters',
instructions: 'Process the file at {{ file_path }}',
parameters: [
{
key: 'file_path',
input_type: 'string',
requirement: 'required',
description: 'Path to the file to process',
},
],
activities: ['Process file'],
extensions: [
{
type: 'builtin',
name: 'developer',
description: 'developer',
},
],
};
const validRecipeWithAuthor: Recipe = {
version: '1.0.0',
title: 'Authored Recipe',
author: {
contact: 'test@example.com',
},
description: 'A recipe with author information',
instructions: 'Do something',
activities: ['Activity'],
extensions: [
{
type: 'builtin',
name: 'developer',
description: 'developer',
},
],
};
describe('validateRecipe', () => {
describe('valid recipes', () => {
it('validates a basic valid recipe', () => {
const result = validateRecipe(validRecipe);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.data).toEqual(validRecipe);
});
it('validates a recipe with prompt instead of instructions', () => {
const result = validateRecipe(validRecipeWithPrompt);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.data).toEqual(validRecipeWithPrompt);
});
it('validates a recipe with parameters', () => {
const result = validateRecipe(validRecipeWithParameters);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.data).toEqual(validRecipeWithParameters);
});
it('validates a recipe with author information', () => {
const result = validateRecipe(validRecipeWithAuthor);
if (!result.success) {
console.log('Author validation errors:', result.errors);
}
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
it('validates a recipe with minimal required fields', () => {
const minimalRecipe = {
version: '1.0.0',
title: 'Minimal',
description: 'Minimal recipe',
instructions: 'Do something',
activities: ['Activity'],
extensions: [],
};
const result = validateRecipe(minimalRecipe);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
describe('invalid recipes', () => {
it('rejects recipe without title', () => {
const invalidRecipe = {
...validRecipe,
title: undefined,
};
const result = validateRecipe(invalidRecipe);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
expect(result.data).toBeUndefined();
});
it('rejects recipe without description', () => {
const invalidRecipe = {
...validRecipe,
description: undefined,
};
const result = validateRecipe(invalidRecipe);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it('allows recipe without version (version is optional)', () => {
const recipeWithoutVersion = {
...validRecipe,
version: undefined,
};
const result = validateRecipe(recipeWithoutVersion);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('rejects recipe without instructions or prompt', () => {
const invalidRecipe = {
...validRecipe,
instructions: undefined,
prompt: undefined,
};
const result = validateRecipe(invalidRecipe);
expect(result.success).toBe(false);
expect(result.errors).toContain('Either instructions or prompt must be provided');
});
it('validates recipe with minimal extension structure', () => {
const recipeWithMinimalExtension = {
...validRecipe,
extensions: [
{
type: 'builtin',
name: 'developer',
description: 'description',
},
],
};
const result = validateRecipe(recipeWithMinimalExtension);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('validates recipe with incomplete parameter structure', () => {
const recipeWithIncompleteParam = {
...validRecipe,
parameters: [
{
key: 'test',
},
],
};
const result = validateRecipe(recipeWithIncompleteParam);
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
it('rejects non-object input', () => {
const result = validateRecipe('not an object');
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it('rejects null input', () => {
const result = validateRecipe(null);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
it('rejects undefined input', () => {
const result = validateRecipe(undefined);
expect(result.success).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});
});
describe('edge cases', () => {
it('handles empty arrays gracefully', () => {
const recipeWithEmptyArrays = {
...validRecipe,
activities: [],
extensions: [],
parameters: [],
};
const result = validateRecipe(recipeWithEmptyArrays);
expect(result.success).toBe(true);
});
it('handles extra properties', () => {
const recipeWithExtra = {
...validRecipe,
extraField: 'should be ignored or handled gracefully',
};
const result = validateRecipe(recipeWithExtra);
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
it('handles very long strings', () => {
const longString = 'a'.repeat(10000);
const recipeWithLongStrings = {
...validRecipe,
title: longString,
description: longString,
instructions: longString,
};
const result = validateRecipe(recipeWithLongStrings);
expect(typeof result.success).toBe('boolean');
});
});
});
describe('validateJsonSchema', () => {
describe('valid JSON schemas', () => {
it('validates a simple JSON schema', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name'],
};
const result = validateJsonSchema(schema);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.data).toEqual(schema);
});
it('validates null schema', () => {
const result = validateJsonSchema(null);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.data).toBe(null);
});
it('validates undefined schema', () => {
const result = validateJsonSchema(undefined);
expect(result.success).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.data).toBe(undefined);
});
it('validates complex JSON schema', () => {
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
users: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
profile: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string' },
},
},
},
},
},
},
};
const result = validateJsonSchema(schema);
expect(result.success).toBe(true);
expect(result.data).toEqual(schema);
});
});
describe('invalid JSON schemas', () => {
it('rejects string input', () => {
const result = validateJsonSchema('not an object');
expect(result.success).toBe(false);
expect(result.errors).toContain('JSON Schema must be an object');
});
it('rejects number input', () => {
const result = validateJsonSchema(42);
expect(result.success).toBe(false);
expect(result.errors).toContain('JSON Schema must be an object');
});
it('rejects boolean input', () => {
const result = validateJsonSchema(true);
expect(result.success).toBe(false);
expect(result.errors).toContain('JSON Schema must be an object');
});
it('validates array input as valid JSON schema', () => {
const result = validateJsonSchema(['not', 'an', 'object']);
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
});
});
describe('helper functions', () => {
describe('getValidationErrorMessages', () => {
it('returns the same array of error messages', () => {
const errors = ['title: Required', 'description: Required', 'Invalid format'];
const messages = getValidationErrorMessages(errors);
expect(messages).toEqual(errors);
expect(messages).toHaveLength(3);
});
it('handles empty array', () => {
const errors: string[] = [];
const messages = getValidationErrorMessages(errors);
expect(messages).toHaveLength(0);
expect(messages).toEqual([]);
});
});
});
describe('getRecipeJsonSchema', () => {
it('returns a valid JSON schema object', () => {
const schema = getRecipeJsonSchema();
@@ -401,162 +29,4 @@ describe('Recipe Validation', () => {
expect(schema1).toEqual(schema2);
});
});
describe('error handling and edge cases', () => {
it('handles validation errors gracefully', () => {
// Test with malformed data that might cause validation to throw
const malformedData = {
version: { not: 'a string' },
title: ['not', 'a', 'string'],
description: 123,
instructions: null,
activities: 'not an array',
extensions: 'not an array',
};
const result = validateRecipe(malformedData);
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
it('handles circular references gracefully', () => {
const circularObj: Record<string, unknown> = { title: 'Test' };
(circularObj as Record<string, unknown>).self = circularObj;
const result = validateRecipe(circularObj);
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
it('handles very deep nested objects', () => {
let deepObj: Record<string, unknown> = {
version: '1.0.0',
title: 'Deep',
description: 'Test',
};
let current: Record<string, unknown> = deepObj;
// Create a deeply nested structure
for (let i = 0; i < 100; i++) {
const nested = { level: i };
current.nested = nested;
current = nested as Record<string, unknown>;
}
const result = validateRecipe(deepObj);
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
});
describe('real-world recipe examples', () => {
it('validates readme-bot style recipe', () => {
const readmeBotRecipe = {
version: '1.0.0',
title: 'Readme Bot',
author: {
contact: 'DOsinga',
},
description: 'Generates or updates a readme',
instructions: 'You are a documentation expert',
activities: [
'Scan project directory for documentation context',
'Generate a new README draft',
'Compare new draft with existing README.md',
],
extensions: [
{
type: 'builtin',
name: 'developer',
display_name: 'Developer',
timeout: 300,
bundled: true,
},
],
prompt: "Here's what to do step by step: 1. The current folder is a software project...",
};
const result = validateRecipe(readmeBotRecipe);
if (!result.success) {
console.log('ReadmeBot validation errors:', result.errors);
}
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
it('validates lint-my-code style recipe with parameters', () => {
const lintRecipe = {
version: '1.0.0',
title: 'Lint My Code',
author: {
contact: 'iandouglas',
},
description:
'Analyzes code files for syntax and layout issues using available linting tools',
instructions:
'You are a code quality expert that helps identify syntax and layout issues in code files',
activities: [
'Detect file type and programming language',
'Check for available linting tools in the project',
'Run appropriate linters for syntax and layout checking',
'Provide recommendations if no linters are found',
],
parameters: [
{
key: 'file_path',
input_type: 'string',
requirement: 'required',
description: 'Path to the file you want to lint',
},
],
extensions: [
{
type: 'builtin',
name: 'developer',
display_name: 'Developer',
timeout: 300,
bundled: true,
},
],
prompt:
'I need you to lint the file at {{ file_path }} for syntax and layout issues only...',
};
const result = validateRecipe(lintRecipe);
if (!result.success) {
console.log('LintRecipe validation errors:', result.errors);
}
expect(typeof result.success).toBe('boolean');
expect(Array.isArray(result.errors)).toBe(true);
});
it('validates 404Portfolio style recipe with multiple extensions', () => {
const portfolioRecipe = {
version: '1.0.0',
title: '404Portfolio',
description: 'Create personalized, creative 404 pages using public profile data',
instructions: 'Create an engaging 404 error page that tells a creative story...',
activities: [
'Build error page from GitHub repos',
'Generate error page from dev.to blog posts',
'Create a 404 page featuring Bluesky bio',
],
extensions: [
{
type: 'builtin',
name: 'developer',
description: 'developer',
},
{
type: 'builtin',
name: 'computercontroller',
description: 'computercontroller',
},
],
};
const result = validateRecipe(portfolioRecipe);
expect(result.success).toBe(true);
});
});
});
-282
View File
@@ -1,6 +1,3 @@
import { z } from 'zod';
import type { Recipe } from '../api/types.gen';
/**
* OpenAPI-based validation utilities for Recipe objects.
*
@@ -115,285 +112,6 @@ function resolveRefs(
return schema;
}
export type RecipeValidationResult = {
success: boolean;
errors: string[];
data?: Recipe | unknown;
};
/**
* Converts an OpenAPI schema to a Zod schema dynamically
*/
function openApiSchemaToZod(schema: Record<string, unknown>): z.ZodTypeAny {
if (!schema) {
return z.any();
}
// Handle different schema types
switch (schema.type) {
case 'string': {
let stringSchema = z.string();
if (typeof schema.minLength === 'number') {
stringSchema = stringSchema.min(schema.minLength);
}
if (typeof schema.maxLength === 'number') {
stringSchema = stringSchema.max(schema.maxLength);
}
if (Array.isArray(schema.enum)) {
return z.enum(schema.enum as [string, ...string[]]);
}
if (schema.format === 'date-time') {
stringSchema = stringSchema.datetime();
}
if (typeof schema.pattern === 'string') {
stringSchema = stringSchema.regex(new RegExp(schema.pattern));
}
return schema.nullable ? stringSchema.nullable() : stringSchema;
}
case 'number':
case 'integer': {
let numberSchema = schema.type === 'integer' ? z.number().int() : z.number();
if (typeof schema.minimum === 'number') {
numberSchema = numberSchema.min(schema.minimum);
}
if (typeof schema.maximum === 'number') {
numberSchema = numberSchema.max(schema.maximum);
}
return schema.nullable ? numberSchema.nullable() : numberSchema;
}
case 'boolean':
return schema.nullable ? z.boolean().nullable() : z.boolean();
case 'array': {
const itemSchema = schema.items
? openApiSchemaToZod(schema.items as Record<string, unknown>)
: z.any();
let arraySchema = z.array(itemSchema);
if (typeof schema.minItems === 'number') {
arraySchema = arraySchema.min(schema.minItems);
}
if (typeof schema.maxItems === 'number') {
arraySchema = arraySchema.max(schema.maxItems);
}
return schema.nullable ? arraySchema.nullable() : arraySchema;
}
case 'object':
if (schema.properties && typeof schema.properties === 'object') {
const shape: Record<string, z.ZodTypeAny> = {};
for (const [propName, propSchema] of Object.entries(schema.properties)) {
shape[propName] = openApiSchemaToZod(propSchema as Record<string, unknown>);
}
// Make optional properties optional based on required array
const optionalShape: Record<string, z.ZodTypeAny> = {};
const requiredFields =
schema.required && Array.isArray(schema.required) ? schema.required : [];
for (const [propName, zodSchema] of Object.entries(shape)) {
if (requiredFields.includes(propName)) {
optionalShape[propName] = zodSchema;
} else {
optionalShape[propName] = zodSchema.optional();
}
}
let objectSchema = z.object(optionalShape);
if (schema.additionalProperties === true) {
return schema.nullable
? objectSchema.passthrough().nullable()
: objectSchema.passthrough();
} else if (schema.additionalProperties === false) {
return schema.nullable ? objectSchema.strict().nullable() : objectSchema.strict();
}
return schema.nullable ? objectSchema.nullable() : objectSchema;
}
return schema.nullable ? z.record(z.any()).nullable() : z.record(z.any());
default:
// Handle $ref, allOf, oneOf, anyOf, etc.
if (typeof schema.$ref === 'string') {
// Resolve the $ref and convert the resolved schema to Zod
const resolvedSchema = resolveRefs(schema, openApiSpec as Record<string, unknown>);
// If resolution changed the schema, convert the resolved version
if (resolvedSchema !== schema) {
return openApiSchemaToZod(resolvedSchema);
}
// If resolution failed, fall back to z.any()
return z.any();
}
if (Array.isArray(schema.allOf)) {
// Intersection of all schemas
return schema.allOf.reduce((acc: z.ZodTypeAny, subSchema: unknown) => {
return acc.and(openApiSchemaToZod(subSchema as Record<string, unknown>));
}, z.any());
}
if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
// Union of schemas
const schemaArray = (schema.oneOf || schema.anyOf) as unknown[];
const schemas = schemaArray.map((subSchema: unknown) =>
openApiSchemaToZod(subSchema as Record<string, unknown>)
);
return z.union(schemas as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]);
}
return z.any();
}
}
/**
* Validates a value against an OpenAPI schema using Zod
*/
function validateAgainstSchema(value: unknown, schema: Record<string, unknown>): string[] {
if (!schema) {
return ['Schema not found'];
}
try {
// Resolve $refs in the schema before converting to Zod
const resolvedSchema = resolveRefs(schema, openApiSpec as Record<string, unknown>);
const zodSchema = openApiSchemaToZod(resolvedSchema);
const result = zodSchema.safeParse(value);
if (result.success) {
return [];
} else {
return result.error.errors.map((err) => {
const path = err.path.length > 0 ? `${err.path.join('.')}: ` : '';
return `${path}${err.message}`;
});
}
} catch (error) {
return [`Schema conversion error: ${error instanceof Error ? error.message : 'Unknown error'}`];
}
}
/**
* Validates a recipe object against the OpenAPI-derived schema.
* This provides structural validation that automatically stays in sync
* with the backend's OpenAPI specification.
*/
export function validateRecipe(recipe: unknown): RecipeValidationResult {
try {
const schema = getRecipeSchema();
if (!schema) {
return {
success: false,
errors: ['Recipe schema not found in OpenAPI specification'],
};
}
const errors = validateAgainstSchema(recipe, schema as Record<string, unknown>);
// Additional business logic validation
if (typeof recipe === 'object' && recipe !== null) {
const recipeObj = recipe as Partial<Recipe>;
if (!recipeObj.instructions && !recipeObj.prompt) {
errors.push('Either instructions or prompt must be provided');
}
}
if (errors.length === 0) {
return {
success: true,
errors: [],
data: recipe as Recipe,
};
} else {
return {
success: false,
errors,
data: undefined,
};
}
} catch (error) {
return {
success: false,
errors: [`Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`],
data: undefined,
};
}
}
/**
* JSON schema validation for the response.json_schema field.
* Uses basic structural validation instead of AJV to avoid CSP eval security issues.
*/
export function validateJsonSchema(schema: unknown): RecipeValidationResult {
try {
// Allow null/undefined schemas
if (schema === null || schema === undefined) {
return { success: true, errors: [], data: schema as unknown };
}
if (typeof schema !== 'object') {
return {
success: false,
errors: ['JSON Schema must be an object'],
data: undefined,
};
}
const schemaObj = schema as Record<string, unknown>;
const errors: string[] = [];
// Check for valid JSON Schema structure
if (schemaObj.type && typeof schemaObj.type !== 'string' && !Array.isArray(schemaObj.type)) {
errors.push('Invalid type field: must be a string or array');
}
// Check for valid properties structure if it exists
if (schemaObj.properties && typeof schemaObj.properties !== 'object') {
errors.push('Invalid properties field: must be an object');
}
// Check for valid required array if it exists
if (schemaObj.required && !Array.isArray(schemaObj.required)) {
errors.push('Invalid required field: must be an array');
}
// Check for valid items structure if it exists (for array types)
if (schemaObj.items && typeof schemaObj.items !== 'object' && !Array.isArray(schemaObj.items)) {
errors.push('Invalid items field: must be an object or array');
}
if (errors.length > 0) {
return {
success: false,
errors: errors.map((err) => `Invalid JSON Schema: ${err}`),
data: undefined,
};
}
return {
success: true,
errors: [],
data: schema as unknown,
};
} catch (error) {
return {
success: false,
errors: [
`JSON Schema validation error: ${error instanceof Error ? error.message : 'Unknown error'}`,
],
data: undefined,
};
}
}
/**
* Helper function to format validation error messages
*/
export function getValidationErrorMessages(errors: string[]): string[] {
return errors;
}
/**
* Returns a JSON schema representation derived directly from the OpenAPI specification.
* This schema is used for documentation in form help text.