From 7c0ebf9734f940aa2a866ae418f9d5ce5cf71ec0 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Mon, 6 Oct 2025 21:23:12 +1100
Subject: [PATCH 01/22] applied server side call save recipe
---
crates/goose-server/src/routes/recipe.rs | 31 ++++++++-----
.../goose-server/src/routes/recipe_utils.rs | 34 ++++++++++++++
crates/goose-server/src/state.rs | 10 +----
ui/desktop/src/components/BaseChat.tsx | 2 +
ui/desktop/src/components/ChatInput.tsx | 3 ++
.../recipes/CreateEditRecipeModal.tsx | 28 +++++++-----
.../recipes/CreateRecipeFromSessionModal.tsx | 11 ++---
.../components/recipes/ImportRecipeForm.tsx | 45 ++-----------------
.../src/components/recipes/RecipesView.tsx | 9 ++--
.../recipes/shared/SaveRecipeDialog.tsx | 15 +++----
.../models/bottom_bar/ModelsBottomBar.tsx | 4 ++
ui/desktop/src/hooks/useAgent.ts | 1 -
ui/desktop/src/hooks/useRecipeManager.ts | 4 ++
ui/desktop/src/main.ts | 38 +++++++++++-----
ui/desktop/src/preload.ts | 17 +++++--
ui/desktop/src/recipe/recipe_management.ts | 24 ++++++++++
16 files changed, 168 insertions(+), 108 deletions(-)
create mode 100644 ui/desktop/src/recipe/recipe_management.ts
diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs
index 26c79c8b543f..6b2530122203 100644
--- a/crates/goose-server/src/routes/recipe.rs
+++ b/crates/goose-server/src/routes/recipe.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::routes::errors::ErrorResponse;
-use crate::routes::recipe_utils::get_all_recipes_manifests;
+use crate::routes::recipe_utils::{get_all_recipes_manifests, save_recipe_file_hash_map, find_recipe_file_path_by_id};
use crate::state::AppState;
#[derive(Debug, Deserialize, ToSchema)]
@@ -250,7 +250,6 @@ async fn scan_recipe(
tag = "Recipe Management"
)]
async fn list_recipes(
- State(state): State>,
) -> Result, StatusCode> {
let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default();
let mut recipe_file_hash_map = HashMap::new();
@@ -268,7 +267,10 @@ async fn list_recipes(
}
})
.collect::>();
- state.set_recipe_file_hash_map(recipe_file_hash_map).await;
+ if let Err(e) = save_recipe_file_hash_map(&recipe_file_hash_map) {
+ tracing::error!("Failed to save recipe file hash map to temp file: {}", e);
+ return Err(StatusCode::INTERNAL_SERVER_ERROR);
+ }
Ok(Json(ListRecipeResponse {
recipe_manifest_responses,
@@ -288,13 +290,14 @@ async fn list_recipes(
tag = "Recipe Management"
)]
async fn delete_recipe(
- State(state): State>,
Json(request): Json,
) -> 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 find_recipe_file_path_by_id(&request.id) {
+ Ok(path) => path,
+ Err(e) => {
+ tracing::error!("Failed to find recipe file path: {}", e);
+ return StatusCode::NOT_FOUND;
+ }
};
if fs::remove_file(file_path).is_err() {
@@ -316,11 +319,19 @@ async fn delete_recipe(
tag = "Recipe Management"
)]
async fn save_recipe(
- State(state): State>,
Json(request): Json,
) -> Result {
let file_path = match request.id {
- Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(),
+ Some(id) => match find_recipe_file_path_by_id(&id) {
+ Ok(path) => Some(path),
+ Err(e) => {
+ tracing::error!("Failed to find recipe file path: {}", e);
+ return Err(ErrorResponse {
+ message: format!("Recipe not found: {}", e),
+ status: StatusCode::NOT_FOUND,
+ });
+ }
+ },
None => None,
};
diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs
index 1a76d947a661..0ce7d04e0353 100644
--- a/crates/goose-server/src/routes/recipe_utils.rs
+++ b/crates/goose-server/src/routes/recipe_utils.rs
@@ -1,3 +1,4 @@
+use std::collections::HashMap;
use std::fs;
use std::hash::DefaultHasher;
use std::hash::{Hash, Hasher};
@@ -58,6 +59,39 @@ pub fn get_all_recipes_manifests() -> Result> {
Ok(recipe_manifests_with_path)
}
+fn get_recipe_temp_file_path() -> std::path::PathBuf {
+ std::env::temp_dir().join("goose_recipe_file_map.json")
+}
+
+pub fn save_recipe_file_hash_map(hash_map: &HashMap) -> Result<()> {
+ let temp_path = get_recipe_temp_file_path();
+ let json_data = serde_json::to_string(hash_map)
+ .map_err(|e| anyhow::anyhow!("Failed to serialize hash map: {}", e))?;
+ fs::write(temp_path, json_data)
+ .map_err(|e| anyhow::anyhow!("Failed to write recipe id to file: {}", e))?;
+ Ok(())
+}
+
+fn load_recipe_file_hash_map() -> Result> {
+ let temp_path = get_recipe_temp_file_path();
+ if !temp_path.exists() {
+ return Ok(HashMap::new());
+ }
+ let json_data = fs::read_to_string(temp_path)
+ .map_err(|e| anyhow::anyhow!("Failed to read recipe id to file: {}", e))?;
+ let hash_map = serde_json::from_str(&json_data)
+ .map_err(|e| anyhow::anyhow!("Failed to deserialize hash map: {}", e))?;
+ Ok(hash_map)
+}
+
+pub fn find_recipe_file_path_by_id(recipe_id: &str) -> Result {
+ let recipe_file_hash_map = load_recipe_file_hash_map()?;
+ recipe_file_hash_map
+ .get(recipe_id)
+ .cloned()
+ .ok_or_else(|| anyhow::anyhow!("Recipe not found with id: {}", recipe_id))
+}
+
// 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 {
diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs
index b8b916bf9102..8be979922954 100644
--- a/crates/goose-server/src/state.rs
+++ b/crates/goose-server/src/state.rs
@@ -1,15 +1,13 @@
use axum::http::StatusCode;
use goose::execution::manager::AgentManager;
use goose::scheduler_trait::SchedulerTrait;
-use std::collections::{HashMap, HashSet};
-use std::path::PathBuf;
+use std::collections::HashSet;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct AppState {
pub(crate) agent_manager: Arc,
- pub recipe_file_hash_map: Arc>>,
pub session_counter: Arc,
/// Tracks sessions that have already emitted recipe telemetry to prevent double counting.
recipe_session_tracker: Arc>>,
@@ -20,7 +18,6 @@ impl AppState {
let agent_manager = AgentManager::instance().await?;
Ok(Arc::new(Self {
agent_manager,
- recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())),
session_counter: Arc::new(AtomicUsize::new(0)),
recipe_session_tracker: Arc::new(Mutex::new(HashSet::new())),
}))
@@ -30,11 +27,6 @@ impl AppState {
self.agent_manager.scheduler().await
}
- pub async fn set_recipe_file_hash_map(&self, hash_map: HashMap) {
- let mut map = self.recipe_file_hash_map.lock().await;
- *map = hash_map;
- }
-
pub async fn mark_recipe_run_if_absent(&self, session_id: &str) -> bool {
let mut sessions = self.recipe_session_tracker.lock().await;
if sessions.contains(session_id) {
diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx
index 07231583ab38..34fc7ca872c9 100644
--- a/ui/desktop/src/components/BaseChat.tsx
+++ b/ui/desktop/src/components/BaseChat.tsx
@@ -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}
diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx
index 9d2d34722a39..1daa742c11ec 100644
--- a/ui/desktop/src/components/ChatInput.tsx
+++ b/ui/desktop/src/components/ChatInput.tsx
@@ -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,
@@ -1625,6 +1627,7 @@ export default function ChatInput({
setView={setView}
alerts={alerts}
recipe={recipe}
+ recipeId={recipeId}
hasMessages={messages.length > 0}
/>
diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx
index d044d044866c..35f3d60e04c3 100644
--- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx
+++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx
@@ -11,8 +11,8 @@ 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;
@@ -20,6 +20,7 @@ interface CreateEditRecipeModalProps {
recipe?: Recipe;
recipeName?: string;
isCreateMode?: boolean;
+ recipeId?: string | null;
}
export default function CreateEditRecipeModal({
@@ -28,6 +29,7 @@ export default function CreateEditRecipeModal({
recipe,
recipeName: initialRecipeName,
isCreateMode = false,
+ recipeId,
}: CreateEditRecipeModalProps) {
const { getExtensions } = useConfig();
@@ -312,10 +314,7 @@ export default function CreateEditRecipeModal({
try {
const recipe = getCurrentRecipe();
- await saveRecipe(recipe, {
- name: (recipeName || '').trim(),
- global: global,
- });
+ await saveRecipe(recipe, global, recipeId);
onClose(true);
@@ -348,18 +347,22 @@ export default function CreateEditRecipeModal({
setIsSaving(true);
try {
const recipe = getCurrentRecipe();
- const recipeName = generateRecipeFilename(recipe);
- await saveRecipe(recipe, {
- name: recipeName,
- global: true,
- });
+ await saveRecipe(recipe, true, 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,
@@ -509,7 +512,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);
diff --git a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx
index 5cf5f357497d..79abe7e40932 100644
--- a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx
+++ b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx
@@ -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,8 @@ 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,
- });
+ // const { saveRecipe } = await import('../../recipe/recipeStorage');
+ await saveRecipe(recipe, formData.global, null);
onRecipeCreated?.(recipe);
onClose();
diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx
index 9aaec3656219..8dec37f46417 100644
--- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx
+++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx
@@ -5,17 +5,12 @@ 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';
interface ImportRecipeFormProps {
isOpen: boolean;
@@ -117,25 +112,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
return recipe as Recipe;
};
- const validateTitleUniqueness = async (title: string): Promise => {
- 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`;
- }
- } catch (error) {
- console.warn('Failed to validate title uniqueness:', error);
- }
-
- return undefined;
- };
-
const importRecipeForm = useForm({
defaultValues: {
deeplink: '',
@@ -165,22 +141,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
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, value.global, null);
// Reset dialog state
importRecipeForm.reset({
diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx
index fa2a4a804b98..cbf37287b2ca 100644
--- a/ui/desktop/src/components/recipes/RecipesView.tsx
+++ b/ui/desktop/src/components/recipes/RecipesView.tsx
@@ -65,8 +65,9 @@ export default function RecipesView() {
}
};
- const handleLoadRecipe = async (recipe: Recipe) => {
+ const handleLoadRecipe = async (recipe: Recipe, recipeId: string) => {
try {
+ console.log('====== RecipesView handleLoadRecipe recipeId:', recipeId);
// onLoadRecipe is not working for loading recipes. It looks correct
// but the instructions are not flowing through to the server.
// Needs a fix but commenting out to get prod back up and running.
@@ -82,7 +83,8 @@ export default function RecipesView() {
undefined, // version
undefined, // resumeSessionId
recipe, // recipe config
- undefined // view type
+ undefined, // view type,
+ recipeId // recipe id
);
// }
} catch (err) {
@@ -174,7 +176,7 @@ export default function RecipesView() {
-
-
- {(field) => {
- // Store reference to the field for programmatic updates
- recipeTitleFieldRef = field;
-
- return (
-
- typeof error === 'string' ? error : error?.message || String(error)
- )}
- />
- );
- }}
-
-
-
- {(field) => (
-
- )}
-
diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx
index cbf37287b2ca..a1bb7472ee55 100644
--- a/ui/desktop/src/components/recipes/RecipesView.tsx
+++ b/ui/desktop/src/components/recipes/RecipesView.tsx
@@ -67,7 +67,6 @@ export default function RecipesView() {
const handleLoadRecipe = async (recipe: Recipe, recipeId: string) => {
try {
- console.log('====== RecipesView handleLoadRecipe recipeId:', recipeId);
// onLoadRecipe is not working for loading recipes. It looks correct
// but the instructions are not flowing through to the server.
// Needs a fix but commenting out to get prod back up and running.
@@ -341,7 +340,6 @@ export default function RecipesView() {
isOpen={showEditor}
onClose={handleEditorClose}
recipe={selectedRecipe.recipe}
- recipeName={selectedRecipe.name}
recipeId={selectedRecipe.id}
/>
)}
diff --git a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx
index b5fe44ef56b9..553c04f763b5 100644
--- a/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx
+++ b/ui/desktop/src/components/recipes/shared/RecipeFormFields.tsx
@@ -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({
)}
-
- {/* Recipe Name Field */}
-
- {(field: FormFieldApi) => (
-
-
- {
- field.handleChange(value);
- onRecipeNameChange?.(value);
- }}
- onBlur={field.handleBlur}
- errors={field.state.meta.errors}
- />
-
-
- )}
-
-
- {/* Save Location Field */}
-
- {(field: FormFieldApi) => (
-
- )}
-
);
}
diff --git a/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx b/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx
deleted file mode 100644
index 52fc68cef083..000000000000
--- a/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx
+++ /dev/null
@@ -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 (
-
-
-
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"
- />
-
- This will be the display name shown in your recipe library
-
- {errors.length > 0 &&
{errors[0]}
}
-
- );
-}
diff --git a/ui/desktop/src/components/recipes/shared/SaveRecipeDialog.tsx b/ui/desktop/src/components/recipes/shared/SaveRecipeDialog.tsx
deleted file mode 100644
index 674a1d905e05..000000000000
--- a/ui/desktop/src/components/recipes/shared/SaveRecipeDialog.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import React, { useState } from 'react';
-import { Button } from '../../ui/button';
-import { Recipe } from '../../../recipe';
-import { generateRecipeFilename } from '../../../recipe/recipeStorage';
-import { toastSuccess, toastError } from '../../../toasts';
-import { useEscapeKey } from '../../../hooks/useEscapeKey';
-import { Play } from 'lucide-react';
-import { saveRecipe } from '../../../recipe/recipe_management';
-
-interface SaveRecipeDialogProps {
- isOpen: boolean;
- onClose: (wasSaved?: boolean) => void;
- onSuccess?: () => void;
- recipe: Recipe;
- recipeId?: string | null;
- suggestedName?: string;
- showSaveAndRun?: boolean;
- onSaveAndRun?: (recipe: Recipe) => void;
-}
-
-export default function SaveRecipeDialog({
- isOpen,
- onClose,
- onSuccess,
- recipe,
- recipeId,
- 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, saveGlobal, recipeId);
-
- 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, saveGlobal, recipeId);
-
- 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 (
-
-
-
Save Recipe
-
-
-
-
- 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
- />
-
-
-
-
-
-
-
-
- {showSaveAndRun && (
-
- )}
-
-
-
- );
-}
diff --git a/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx b/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx
index f46c507074a9..20dedcd8d8e1 100644
--- a/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx
+++ b/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx
@@ -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();
- expect(screen.getByText('Recipe Name')).toBeInTheDocument();
- });
-
- it('always shows save location field', () => {
- render();
- 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 = {
@@ -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);
diff --git a/ui/desktop/src/components/recipes/shared/__tests__/recipeFormSchema.test.ts b/ui/desktop/src/components/recipes/shared/__tests__/recipeFormSchema.test.ts
index 34255ec44fb7..2f0fcedfe6c2 100644
--- a/ui/desktop/src/components/recipes/shared/__tests__/recipeFormSchema.test.ts
+++ b/ui/desktop/src/components/recipes/shared/__tests__/recipeFormSchema.test.ts
@@ -17,8 +17,6 @@ describe('recipeFormSchema', () => {
},
],
jsonSchema: '{"type": "object"}',
- recipeName: 'test_recipe',
- global: true,
};
describe('Zod Schema Validation', () => {
@@ -177,31 +175,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 issue.path.includes('recipeName'));
- expect(nameError?.message).toContain('invalid characters');
- }
- });
- });
-
describe('Parameter Validation', () => {
it('validates parameters with all required fields', () => {
const validData = {
@@ -305,20 +278,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 = {
diff --git a/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts b/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts
index 6de1c423ef57..99a680bef76b 100644
--- a/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts
+++ b/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts
@@ -52,16 +52,6 @@ export const recipeFormSchema = z.object({
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),
});
export type RecipeFormData = z.infer;
diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx
index 8117466eb5da..e9a4c4f6c9c7 100644
--- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx
+++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx
@@ -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;
@@ -56,9 +54,6 @@ export default function ModelsBottomBar({
const [isLeadWorkerActive, setIsLeadWorkerActive] = useState(false);
const [providerDefaultModel, setProviderDefaultModel] = useState(null);
- // Save recipe dialog state
- const [showSaveDialog, setShowSaveDialog] = useState(false);
-
// View recipe modal state
const [showViewRecipeModal, setShowViewRecipeModal] = useState(false);
@@ -176,13 +171,6 @@ export default function ModelsBottomBar({
}
};
- // Handle save recipe - show save dialog
- const handleSaveRecipeClick = () => {
- if (recipe) {
- setShowSaveDialog(true);
- }
- };
-
return (
@@ -221,10 +209,6 @@ export default function ModelsBottomBar({
View/Edit Recipe
-
- Save Recipe
-
-
>
)}
@@ -258,16 +242,6 @@ export default function ModelsBottomBar({
) : null}
- {/* Save Recipe Dialog */}
- {showSaveDialog && recipe && (
- setShowSaveDialog(false)}
- recipe={recipe}
- recipeId={recipeId}
- />
- )}
-
{/* 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 && (
@@ -276,7 +250,6 @@ export default function ModelsBottomBar({
onClose={() => setShowViewRecipeModal(false)}
recipe={recipe}
recipeId={recipeId}
- recipeName={generateRecipeFilename(recipe)}
/>
)}
From 53396ef19808cfddcfb8489e036fcac451831f66 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Tue, 7 Oct 2025 16:36:43 +1100
Subject: [PATCH 08/22] removed unused functions
---
ui/desktop/src/recipe/recipeStorage.ts | 110 +-----
ui/desktop/src/recipe/validation.test.ts | 443 +----------------------
ui/desktop/src/recipe/validation.ts | 209 +----------
3 files changed, 3 insertions(+), 759 deletions(-)
diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts
index c012c5dc9bd1..4483967f64d8 100644
--- a/ui/desktop/src/recipe/recipeStorage.ts
+++ b/ui/desktop/src/recipe/recipeStorage.ts
@@ -1,14 +1,7 @@
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/)
-}
+// TODO: Lifei Remove this
export interface SavedRecipe {
name: string;
recipe: Recipe;
@@ -18,13 +11,6 @@ export interface SavedRecipe {
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
*/
@@ -45,84 +31,6 @@ export function getStorageDirectory(isGlobal: boolean): string {
}
}
-/**
- * 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 {
- 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 {
- 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 {
try {
const listRecipeResponse = await listRecipes();
@@ -139,19 +47,3 @@ export function convertToLocaleDateString(lastModified: string): string {
}
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';
-}
diff --git a/ui/desktop/src/recipe/validation.test.ts b/ui/desktop/src/recipe/validation.test.ts
index 1d6824c654c3..04bb0f3ccb63 100644
--- a/ui/desktop/src/recipe/validation.test.ts
+++ b/ui/desktop/src/recipe/validation.test.ts
@@ -1,272 +1,7 @@
import { describe, it, expect } from 'vitest';
-import {
- validateRecipe,
- validateJsonSchema,
- getValidationErrorMessages,
- getRecipeJsonSchema,
-} from './validation';
-import type { Recipe } from '../api/types.gen';
+import { validateJsonSchema, 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', () => {
@@ -356,24 +91,6 @@ describe('Recipe Validation', () => {
});
});
- 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 +118,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 = { title: 'Test' };
- (circularObj as Record).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 = {
- version: '1.0.0',
- title: 'Deep',
- description: 'Test',
- };
- let current: Record = deepObj;
-
- // Create a deeply nested structure
- for (let i = 0; i < 100; i++) {
- const nested = { level: i };
- current.nested = nested;
- current = nested as Record;
- }
-
- 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);
- });
- });
});
diff --git a/ui/desktop/src/recipe/validation.ts b/ui/desktop/src/recipe/validation.ts
index 065e2c2f3e41..7a507e716b4e 100644
--- a/ui/desktop/src/recipe/validation.ts
+++ b/ui/desktop/src/recipe/validation.ts
@@ -1,4 +1,3 @@
-import { z } from 'zod';
import type { Recipe } from '../api/types.gen';
/**
@@ -121,206 +120,7 @@ export type RecipeValidationResult = {
data?: Recipe | unknown;
};
-/**
- * Converts an OpenAPI schema to a Zod schema dynamically
- */
-function openApiSchemaToZod(schema: Record): 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)
- : 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 = {};
- for (const [propName, propSchema] of Object.entries(schema.properties)) {
- shape[propName] = openApiSchemaToZod(propSchema as Record);
- }
-
- // Make optional properties optional based on required array
- const optionalShape: Record = {};
- 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);
- // 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));
- }, 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)
- );
- 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[] {
- if (!schema) {
- return ['Schema not found'];
- }
-
- try {
- // Resolve $refs in the schema before converting to Zod
- const resolvedSchema = resolveRefs(schema, openApiSpec as Record);
- 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);
-
- // Additional business logic validation
- if (typeof recipe === 'object' && recipe !== null) {
- const recipeObj = recipe as Partial;
- 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,
- };
- }
-}
-
+// TODO: Lifei Remove this
/**
* JSON schema validation for the response.json_schema field.
* Uses basic structural validation instead of AJV to avoid CSP eval security issues.
@@ -387,13 +187,6 @@ export function validateJsonSchema(schema: unknown): RecipeValidationResult {
}
}
-/**
- * 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.
From ab07266887a191e2bface6912dd19924712e2355 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Tue, 7 Oct 2025 16:50:38 +1100
Subject: [PATCH 09/22] removed recipe name and is global on the server side
when listing and save recipe
---
crates/goose-server/src/routes/recipe.rs | 9 +---
.../goose-server/src/routes/recipe_utils.rs | 54 -------------------
crates/goose/src/recipe/local_recipes.rs | 10 +---
.../src/components/recipes/RecipesView.tsx | 4 +-
4 files changed, 5 insertions(+), 72 deletions(-)
diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs
index 5e793b770910..53e823650613 100644
--- a/crates/goose-server/src/routes/recipe.rs
+++ b/crates/goose-server/src/routes/recipe.rs
@@ -21,7 +21,6 @@ use crate::state::AppState;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateRecipeRequest {
session_id: String,
- // Optional fields
#[serde(default)]
author: Option,
}
@@ -74,7 +73,6 @@ pub struct ScanRecipeResponse {
pub struct SaveRecipeRequest {
recipe: Recipe,
id: Option,
- is_global: Option,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ParseRecipeRequest {
@@ -88,7 +86,6 @@ pub struct ParseRecipeResponse {
#[derive(Debug, Serialize, ToSchema)]
pub struct RecipeManifestResponse {
- name: String,
recipe: Recipe,
#[serde(rename = "lastModified")]
last_modified: String,
@@ -117,7 +114,6 @@ pub struct ListRecipeResponse {
),
tag = "Recipe Management"
)]
-/// Create a Recipe configuration from the current session
async fn create_recipe(
State(state): State>,
Json(request): Json,
@@ -127,7 +123,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) => {
@@ -150,7 +145,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 {
@@ -263,7 +257,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(),
@@ -326,7 +319,7 @@ async fn save_recipe(
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(),
diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs
index 1a76d947a661..49181a246986 100644
--- a/crates/goose-server/src/routes/recipe_utils.rs
+++ b/crates/goose-server/src/routes/recipe_utils.rs
@@ -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> {
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> {
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 {
- 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::(&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");
- }
-}
diff --git a/crates/goose/src/recipe/local_recipes.rs b/crates/goose/src/recipe/local_recipes.rs
index 3a905ede8366..63ad5d56f3df 100644
--- a/crates/goose/src/recipe/local_recipes.rs
+++ b/crates/goose/src/recipe/local_recipes.rs
@@ -165,14 +165,8 @@ fn generate_recipe_filename(title: &str, recipe_library_dir: &Path) -> PathBuf {
}
}
-pub fn save_recipe_to_file(
- recipe: Recipe,
- is_global: Option,
- file_path: Option,
-) -> anyhow::Result {
- let is_global_value = is_global.unwrap_or(true);
-
- let recipe_library_dir = get_recipe_library_dir(is_global_value);
+pub fn save_recipe_to_file(recipe: Recipe, file_path: Option) -> anyhow::Result {
+ let recipe_library_dir = get_recipe_library_dir(true);
let file_path_value = match file_path {
Some(path) => path,
diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx
index a1bb7472ee55..f1b052486266 100644
--- a/ui/desktop/src/components/recipes/RecipesView.tsx
+++ b/ui/desktop/src/components/recipes/RecipesView.tsx
@@ -99,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.',
});
@@ -111,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) {
From 5ab4c55ecb364422900d975e18658bb5f26178c4 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Tue, 7 Oct 2025 17:04:14 +1100
Subject: [PATCH 10/22] update ui matching server side change
---
ui/desktop/openapi.json | 9 ---------
ui/desktop/src/api/sdk.gen.ts | 3 ---
ui/desktop/src/api/types.gen.ts | 2 --
.../src/components/recipes/CreateEditRecipeModal.tsx | 4 ++--
.../components/recipes/CreateRecipeFromSessionModal.tsx | 2 +-
ui/desktop/src/components/recipes/ImportRecipeForm.tsx | 2 +-
ui/desktop/src/recipe/recipe_management.ts | 7 +------
7 files changed, 5 insertions(+), 24 deletions(-)
diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json
index a8398f713d3d..39705dd109e7 100644
--- a/ui/desktop/openapi.json
+++ b/ui/desktop/openapi.json
@@ -931,7 +931,6 @@
"tags": [
"Recipe Management"
],
- "summary": "Create a Recipe configuration from the current session",
"operationId": "create_recipe",
"requestBody": {
"content": {
@@ -3519,7 +3518,6 @@
"RecipeManifestResponse": {
"type": "object",
"required": [
- "name",
"recipe",
"lastModified",
"id"
@@ -3531,9 +3529,6 @@
"lastModified": {
"type": "string"
},
- "name": {
- "type": "string"
- },
"recipe": {
"$ref": "#/components/schemas/Recipe"
}
@@ -3743,10 +3738,6 @@
"type": "string",
"nullable": true
},
- "is_global": {
- "type": "boolean",
- "nullable": true
- },
"recipe": {
"$ref": "#/components/schemas/Recipe"
}
diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts
index 60dc95ced607..e33e91f5ea60 100644
--- a/ui/desktop/src/api/sdk.gen.ts
+++ b/ui/desktop/src/api/sdk.gen.ts
@@ -274,9 +274,6 @@ export const startTetrateSetup = (options?
});
};
-/**
- * Create a Recipe configuration from the current session
- */
export const createRecipe = (options: Options) => {
return (options.client ?? _heyApiClient).post({
url: '/recipes/create',
diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts
index bf69f3e86789..dadaf3b6bd85 100644
--- a/ui/desktop/src/api/types.gen.ts
+++ b/ui/desktop/src/api/types.gen.ts
@@ -567,7 +567,6 @@ export type Recipe = {
export type RecipeManifestResponse = {
id: string;
lastModified: string;
- name: string;
recipe: Recipe;
};
@@ -646,7 +645,6 @@ export type RunNowResponse = {
export type SaveRecipeRequest = {
id?: string | null;
- is_global?: boolean | null;
recipe: Recipe;
};
diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx
index 70a24309db5b..3a02381be0bf 100644
--- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx
+++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx
@@ -304,7 +304,7 @@ export default function CreateEditRecipeModal({
try {
const recipe = getCurrentRecipe();
- await saveRecipe(recipe, true, recipeId);
+ await saveRecipe(recipe, recipeId);
onClose(true);
@@ -338,7 +338,7 @@ export default function CreateEditRecipeModal({
try {
const recipe = getCurrentRecipe();
- await saveRecipe(recipe, true, recipeId);
+ await saveRecipe(recipe, recipeId);
// Close modal first
onClose(true);
diff --git a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx
index fb44fc5d948b..2c6c2b916d94 100644
--- a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx
+++ b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx
@@ -183,7 +183,7 @@ export default function CreateRecipeFromSessionModal({
extensions: [], // Will be populated based on current extensions
};
- await saveRecipe(recipe, true, null);
+ await saveRecipe(recipe, null);
onRecipeCreated?.(recipe);
onClose();
diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx
index 72d23c713f20..0adba1a9830c 100644
--- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx
+++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx
@@ -110,7 +110,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
recipe = await parseRecipeFromFile(fileContent);
}
- await saveRecipe(recipe, true, null);
+ await saveRecipe(recipe, null);
// Reset dialog state
importRecipeForm.reset({
diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts
index 2b9e9f247af3..b4aeef30ffac 100644
--- a/ui/desktop/src/recipe/recipe_management.ts
+++ b/ui/desktop/src/recipe/recipe_management.ts
@@ -1,16 +1,11 @@
import { Recipe, saveRecipe as saveRecipeApi } from '../api';
-export async function saveRecipe(
- recipe: Recipe,
- isGlobal: boolean | null,
- recipeId?: string | null
-): Promise {
+export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise {
try {
await saveRecipeApi({
body: {
recipe,
id: recipeId,
- is_global: isGlobal,
},
throwOnError: true,
});
From 780b84dcefc51c3251f3ff5e19a1db2c841c54b6 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Tue, 7 Oct 2025 20:23:27 +1100
Subject: [PATCH 11/22] refactored recipe validation
---
crates/goose-cli/src/cli.rs | 2 +-
crates/goose-cli/src/commands/recipe.rs | 10 +-
crates/goose-cli/src/recipes/github_recipe.rs | 2 +-
crates/goose-cli/src/recipes/recipe.rs | 35 +----
crates/goose-server/src/routes/schedule.rs | 12 +-
crates/goose/src/recipe/build_recipe/mod.rs | 101 +------------
crates/goose/src/recipe/mod.rs | 3 +-
crates/goose/src/recipe/template_recipe.rs | 37 ++---
crates/goose/src/recipe/validate_recipe.rs | 138 ++++++++++++++++++
9 files changed, 185 insertions(+), 155 deletions(-)
create mode 100644 crates/goose/src/recipe/validate_recipe.rs
diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs
index cc83fe0271fa..2e38b0e9d787 100644
--- a/crates/goose-cli/src/cli.rs
+++ b/crates/goose-cli/src/cli.rs
@@ -1011,7 +1011,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)
diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs
index 0e71dcae31c6..c5767038646d 100644
--- a/crates/goose-cli/src/commands/recipe.rs
+++ b/crates/goose-cli/src/commands/recipe.rs
@@ -1,9 +1,9 @@
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
@@ -17,7 +17,8 @@ use goose::recipe_deeplink;
/// 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) {
+ let recipe_file = load_recipe_file(recipe_name)?;
+ match validate_recipe_template_from_file(&recipe_file) {
Ok(_) => {
println!("{} recipe file is valid", style("✓").green().bold());
Ok(())
@@ -39,8 +40,9 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> {
///
/// Result indicating success or failure
pub fn handle_deeplink(recipe_name: &str) -> Result {
+ let recipe_file = load_recipe_file(recipe_name)?;
// Load the recipe file first to validate it
- match load_recipe_for_validation(recipe_name) {
+ match validate_recipe_template_from_file(&recipe_file) {
Ok(recipe) => match recipe_deeplink::encode(&recipe) {
Ok(encoded) => {
println!(
diff --git a/crates/goose-cli/src/recipes/github_recipe.rs b/crates/goose-cli/src/recipes/github_recipe.rs
index c7f88b823fe4..bfe297d8dd89 100644
--- a/crates/goose-cli/src/recipes/github_recipe.rs
+++ b/crates/goose-cli/src/recipes/github_recipe.rs
@@ -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(),
diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs
index 32b05f551561..8b1fe3a6ab18 100644
--- a/crates/goose-cli/src/recipes/recipe.rs
+++ b/crates/goose-cli/src/recipes/recipe.rs
@@ -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 {
|key: &str, description: &str| -> Result {
@@ -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 {
- 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(
¶ms,
@@ -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()),
¶ms_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};
diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs
index 5203e8be703b..a9ce8f399bec 100644
--- a/crates/goose-server/src/routes/schedule.rs
+++ b/crates/goose-server/src/routes/schedule.rs
@@ -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)
diff --git a/crates/goose/src/recipe/build_recipe/mod.rs b/crates/goose/src/recipe/build_recipe/mod.rs
index 220148742a37..8fa26fab0d84 100644
--- a/crates/goose/src/recipe/build_recipe/mod.rs
+++ b/crates/goose/src/recipe/build_recipe/mod.rs
@@ -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_parameters;
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,8 @@ 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_parameters(&recipe_file_content, Some(recipe_dir_str.to_string()))?;
let (params_for_template, missing_params) =
apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, user_prompt_fn)?;
@@ -48,18 +50,6 @@ where
Ok((rendered_content, missing_params))
}
-pub fn validate_recipe_parameters(
- recipe_file_content: &str,
- recipe_dir_str: &str,
-) -> Result