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
-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.