diff --git a/src/mcp/index.ts b/src/mcp/index.ts index b023b5b..f412582 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -100,6 +100,20 @@ export type { SpeakOptions, } from '../plugins/system' +// Recipes Pack — durable named playbooks the agent learns once + replays. +export { + createRecipesPlugin, + RecipeStore, + RECIPES_TOOLS, +} from '../plugins/recipes' +export type { + RecipesPluginConfig, + Recipe, + RecipeStep, + RecipeParameter, + RecipeVerification, +} from '../plugins/recipes' + // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the // default plugin set. Pass it explicitly via `createMcpServer({ plugins })`. export { diff --git a/src/plugins/recipes/index.ts b/src/plugins/recipes/index.ts new file mode 100644 index 0000000..e79a431 --- /dev/null +++ b/src/plugins/recipes/index.ts @@ -0,0 +1,157 @@ +/** + * Recipes Pack — durable named playbooks the agent learns once + replays. + * + * Recipes are stored as JSON; `agentmark_recipe_get` returns a resolved + * plan (parameter substitutions applied) and the AI dispatches each + * step itself. The plugin deliberately does NOT auto-execute server-side + * — keeps the steps visible to the AI's reasoning chain and avoids the + * recursive-dispatch coupling between the recipe plugin and the + * surrounding Dispatcher. + * + * Pairs naturally with agentmark_desktop_diff: each step in a recipe + * can carry a `verify` block describing what the diff should look like + * after the step lands, and the agent decides what "verified" means. + */ +import { RecipeStore } from './store' +import { resolveRecipe, substitute, applyParameterSchema } from './substitute' +import { RECIPES_TOOLS } from './tool-defs' +import type { Recipe, RecipeStep, RecipeParameter } from './types' +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../../mcp/plugin' + +export interface RecipesPluginConfig { + /** Override the recipes-file path (defaults to ~/.thinkfleet/agentmark/recipes.json). */ + storePath?: string +} + +export function createRecipesPlugin(config: RecipesPluginConfig = {}): AgentMarkPlugin { + const store = new RecipeStore({ path: config.storePath }) + + const handlers: Record = { + agentmark_recipe_save: async (args): Promise => { + const name = requireString(args, 'name') + const steps = requireArray(args, 'steps') + for (let i = 0; i < steps.length; i++) { + const s = steps[i] as unknown as Record + if (typeof s.tool !== 'string') { + return { text: `steps[${i}].tool must be a string.`, isError: true } + } + if (!s.args || typeof s.args !== 'object') { + return { text: `steps[${i}].args must be an object.`, isError: true } + } + } + + const now = new Date().toISOString() + const recipe: Recipe = { + name, + description: typeof args.description === 'string' ? args.description : undefined, + target_app: typeof args.target_app === 'string' ? args.target_app : undefined, + parameters: Array.isArray(args.parameters) ? (args.parameters as RecipeParameter[]) : undefined, + steps, + version: 0, // store bumps this + created_at: now, + updated_at: now, + } + + const onConflict = args.on_conflict === 'replace' ? 'replace' : 'fail' + const saved = await store.save(recipe, { on_conflict: onConflict }) + return { text: JSON.stringify({ saved: true, ...recipeSummary(saved) }, null, 2) } + }, + + agentmark_recipe_list: async (args): Promise => { + const target = typeof args.target_app === 'string' ? args.target_app : undefined + const recipes = await store.list({ target_app: target }) + return { + text: JSON.stringify({ + count: recipes.length, + recipes: recipes.map(recipeSummary), + }, null, 2), + } + }, + + agentmark_recipe_get: async (args): Promise => { + const name = requireString(args, 'name') + const recipe = await store.get(name) + if (!recipe) { + return { text: `Unknown recipe: ${name}`, isError: true } + } + + const paramsSupplied = args.params && typeof args.params === 'object' && !Array.isArray(args.params) + if (!paramsSupplied) { + return { text: JSON.stringify({ resolved: false, recipe }, null, 2) } + } + + try { + const supplied = args.params as Record + const applied = applyParameterSchema(recipe.parameters, supplied) + const resolvedSteps = recipe.steps.map((step) => ({ + ...step, + args: substitute(step.args, applied) as Record, + })) + return { + text: JSON.stringify({ + resolved: true, + recipe: { + ...recipeSummary(recipe), + description: recipe.description, + target_app: recipe.target_app, + }, + resolved_with: applied, + steps: resolvedSteps, + }, null, 2), + } + } catch (err) { + return { text: (err as Error).message, isError: true } + } + }, + + agentmark_recipe_delete: async (args): Promise => { + const name = requireString(args, 'name') + const existed = await store.delete(name) + return { text: JSON.stringify({ name, existed }, null, 2) } + }, + } + + return { + name: 'recipes', + version: '0.1.0', + tools: RECIPES_TOOLS, + handlers, + describeSessions: () => ({ + recipes: { store_path: store.filePath }, + }), + } +} + +function recipeSummary(r: Recipe): Record { + return { + name: r.name, + description: r.description, + target_app: r.target_app, + parameter_count: r.parameters?.length ?? 0, + step_count: r.steps.length, + version: r.version, + created_at: r.created_at, + updated_at: r.updated_at, + } +} + +export { RecipeStore } from './store' +export { resolveRecipe, substitute, applyParameterSchema } from './substitute' +export { RECIPES_TOOLS } from './tool-defs' +export type { Recipe, RecipeStep, RecipeParameter, RecipeVerification, ResolvedRecipe, ResolvedRecipeStep } from './types' + +function requireString(args: Record, key: string): string { + const v = args[key] + if (typeof v !== 'string' || v.length === 0) { + throw new Error(`Missing required argument: ${key}`) + } + return v +} + +function requireArray(args: Record, key: string): T[] { + const v = args[key] + if (!Array.isArray(v) || v.length === 0) { + throw new Error(`Argument ${key} must be a non-empty array.`) + } + return v as T[] +} diff --git a/src/plugins/recipes/store.ts b/src/plugins/recipes/store.ts new file mode 100644 index 0000000..6ce8ee0 --- /dev/null +++ b/src/plugins/recipes/store.ts @@ -0,0 +1,113 @@ +/** + * Recipe storage — single JSON file containing a `{ name → Recipe }` map. + * + * Default path: `~/.thinkfleet/agentmark/recipes.json` (mode 0600). + * Separate from the Foundations StateStore so recipes don't bloat the + * general K/V file and so they can be backed up / synced independently. + * + * Writes use the same temp-file + rename pattern as StateStore. + */ +import { mkdir, readFile, writeFile, rename, chmod, unlink } from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import type { Recipe } from './types' + +export interface RecipeStoreConfig { + /** Override the on-disk path (mostly for tests). */ + path?: string +} + +interface RecipeFile { + version: 1 + recipes: Record +} + +export class RecipeStore { + readonly filePath: string + private cached: RecipeFile | null = null + + constructor(config: RecipeStoreConfig = {}) { + this.filePath = config.path + ?? path.join(os.homedir(), '.thinkfleet', 'agentmark', 'recipes.json') + } + + async get(name: string): Promise { + const file = await this.load() + return file.recipes[name] ?? null + } + + async list(filter?: { target_app?: string }): Promise { + const file = await this.load() + let recipes = Object.values(file.recipes) + if (filter?.target_app) { + recipes = recipes.filter((r) => r.target_app === filter.target_app) + } + // Stable order by name to make listings predictable. + recipes.sort((a, b) => a.name.localeCompare(b.name)) + return recipes + } + + async save(recipe: Recipe, options: { on_conflict?: 'replace' | 'fail' } = {}): Promise { + const file = await this.load() + const existing = file.recipes[recipe.name] + if (existing && options.on_conflict !== 'replace') { + throw new Error( + `Recipe "${recipe.name}" already exists. ` + + `Pass on_conflict="replace" to overwrite or pick a different name.`, + ) + } + const now = new Date().toISOString() + const stored: Recipe = { + ...recipe, + version: (existing?.version ?? 0) + 1, + created_at: existing?.created_at ?? recipe.created_at ?? now, + updated_at: now, + } + file.recipes[recipe.name] = stored + await this.persist(file) + return stored + } + + async delete(name: string): Promise { + const file = await this.load() + if (!(name in file.recipes)) return false + delete file.recipes[name] + await this.persist(file) + return true + } + + async clear(): Promise { + this.cached = { version: 1, recipes: {} } + await unlink(this.filePath).catch(() => {}) + } + + private async load(): Promise { + if (this.cached) return this.cached + try { + const raw = await readFile(this.filePath, 'utf8') + const parsed = JSON.parse(raw) as RecipeFile + if (parsed.version !== 1 || !parsed.recipes || typeof parsed.recipes !== 'object') { + throw new Error(`Malformed recipe file at ${this.filePath}: unexpected schema.`) + } + this.cached = parsed + return parsed + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + this.cached = { version: 1, recipes: {} } + return this.cached + } + throw err + } + } + + private async persist(file: RecipeFile): Promise { + this.cached = file + await mkdir(path.dirname(this.filePath), { recursive: true }) + const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}` + await writeFile(tmp, JSON.stringify(file, null, 2), { encoding: 'utf8' }) + await chmod(tmp, 0o600).catch(() => { + // Windows ACL semantics swallow chmod; not fatal. + }) + await rename(tmp, this.filePath) + } +} diff --git a/src/plugins/recipes/substitute.ts b/src/plugins/recipes/substitute.ts new file mode 100644 index 0000000..58c69a2 --- /dev/null +++ b/src/plugins/recipes/substitute.ts @@ -0,0 +1,150 @@ +/** + * Parameter substitution for recipe args. + * + * Two syntaxes: + * - Bare token `{{param.name}}` → returns the param value unchanged + * (preserves number / boolean / array types). + * - Embedded `prefix {{param.name}} suffix` → string interpolation; + * non-string values are coerced via String(...). + * + * Walks the args object recursively so nested objects + arrays also + * substitute. Throws on unknown param references when `strict: true`; + * leaves the placeholder verbatim when `strict: false` (default). + */ + +import type { Recipe, RecipeParameter } from './types' + +export interface SubstituteOptions { + strict?: boolean +} + +const BARE_TOKEN = /^\{\{\s*param\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}$/ +const EMBEDDED_TOKEN = /\{\{\s*param\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g + +/** + * Apply parameter values to a recipe's step args. Returns a new + * deep-copied args structure; never mutates the input. `params` should + * already be type-validated against the recipe's parameter schema. + */ +export function substitute( + value: unknown, + params: Record, + options: SubstituteOptions = {}, +): unknown { + if (typeof value === 'string') { + return substituteString(value, params, options) + } + if (Array.isArray(value)) { + return value.map((v) => substitute(v, params, options)) + } + if (value && typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + out[k] = substitute(v, params, options) + } + return out + } + return value +} + +function substituteString(s: string, params: Record, options: SubstituteOptions): unknown { + const bare = s.match(BARE_TOKEN) + if (bare) { + const name = bare[1] + if (!(name in params)) { + if (options.strict) throw new Error(`Unknown parameter: ${name}`) + return s + } + return params[name] + } + // Embedded substitutions — always produce a string. + EMBEDDED_TOKEN.lastIndex = 0 + return s.replace(EMBEDDED_TOKEN, (_match, name: string) => { + if (!(name in params)) { + if (options.strict) throw new Error(`Unknown parameter: ${name}`) + return _match + } + const v = params[name] + return v === null || v === undefined ? '' : String(v) + }) +} + +/** + * Validate that the caller-supplied params match the recipe's parameter + * schema. Fills in defaults; errors on missing required params; coerces + * basic types where it's lossless (number string → number). + */ +export function applyParameterSchema( + schema: RecipeParameter[] | undefined, + supplied: Record, +): Record { + if (!schema || schema.length === 0) return { ...supplied } + + const out: Record = {} + for (const param of schema) { + const provided = supplied[param.name] + if (provided === undefined) { + if (param.default !== undefined) { + out[param.name] = param.default + continue + } + if (param.required) { + throw new Error(`Recipe parameter "${param.name}" is required.`) + } + continue + } + + const coerced = coerceType(provided, param.type, param.name) + out[param.name] = coerced + } + + // Pass through any extra keys the recipe author didn't declare. Strict + // mode could reject these later; for now keep it forgiving so authors + // can add new params without breaking old callers. + for (const [k, v] of Object.entries(supplied)) { + if (!(k in out)) out[k] = v + } + + return out +} + +function coerceType(value: unknown, type: RecipeParameter['type'], name: string): unknown { + if (type === 'string') { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + throw new Error(`Recipe parameter "${name}" must be a string; got ${typeof value}`) + } + if (type === 'number') { + if (typeof value === 'number') return value + if (typeof value === 'string') { + const n = Number(value) + if (!Number.isFinite(n)) { + throw new Error(`Recipe parameter "${name}" must be a number; got "${value}"`) + } + return n + } + throw new Error(`Recipe parameter "${name}" must be a number; got ${typeof value}`) + } + if (type === 'boolean') { + if (typeof value === 'boolean') return value + if (value === 'true' || value === 'false') return value === 'true' + throw new Error(`Recipe parameter "${name}" must be a boolean; got ${typeof value}`) + } + return value +} + +/** + * Resolve a whole recipe — apply parameter schema, substitute into each + * step's args, return a `ResolvedRecipe` ready for the AI to execute. + */ +export function resolveRecipe( + recipe: Recipe, + params: Record, +): Recipe['steps'] extends Array ? { steps: Array; resolved_with: Record } : never { + const applied = applyParameterSchema(recipe.parameters, params) + const steps = recipe.steps.map((step) => ({ + ...step, + args: substitute(step.args, applied) as Record, + })) + return { steps, resolved_with: applied } as never +} diff --git a/src/plugins/recipes/tool-defs.ts b/src/plugins/recipes/tool-defs.ts new file mode 100644 index 0000000..85b7f87 --- /dev/null +++ b/src/plugins/recipes/tool-defs.ts @@ -0,0 +1,120 @@ +/** + * Recipes Pack — tool definitions. + * + * CRUD + resolution (playbook). The plugin doesn't auto-execute recipes + * server-side; agentmark_recipe_get returns the resolved plan and the + * agent dispatches each step itself. + */ +import type { McpToolDef } from '../../mcp/tool-defs' + +export const RECIPES_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_recipe_save', + description: + 'Persist a recipe — a named sequence of MCP tool calls with ' + + 'optional parameter substitution. Recipes are durable across ' + + 'MCP sessions, so once the agent figures out "to add a ' + + 'customer in NowCerts, do X then Y then Z" it never needs to ' + + 'rediscover the sequence.\n' + + '\nFor `args` strings, embed `{{param.name}}` to reference ' + + 'parameter values defined in `parameters`. Bare-string tokens ' + + 'preserve the param\'s native type; embedded tokens are ' + + 'string-interpolated.\n' + + '\nReturns the saved recipe with assigned version + timestamps.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Unique recipe id.' }, + description: { type: 'string' }, + target_app: { type: 'string', description: 'App or surface this drives ("excel", "nowcerts", etc.). Used by list filter.' }, + parameters: { + type: 'array', + description: 'Parameter schema.', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + type: { type: 'string', enum: ['string', 'number', 'boolean'] }, + description: { type: 'string' }, + default: { description: 'Value used when the caller omits the param.' }, + required: { type: 'boolean' }, + }, + required: ['name', 'type'], + }, + }, + steps: { + type: 'array', + description: 'Ordered list of MCP tool calls to execute.', + items: { + type: 'object', + properties: { + tool: { type: 'string', description: 'MCP tool name to call.' }, + args: { type: 'object', description: 'Arguments (string values may contain `{{param.name}}` placeholders).' }, + description: { type: 'string' }, + verify: { + type: 'object', + description: 'Optional verification hint for the AI to check via diff after this step.', + }, + on_failure: { type: 'string', enum: ['abort', 'continue', 'retry'] }, + }, + required: ['tool', 'args'], + }, + }, + on_conflict: { + type: 'string', + enum: ['replace', 'fail'], + description: 'What to do if a recipe with this name already exists. Default: fail.', + }, + }, + required: ['name', 'steps'], + }, + }, + { + name: 'agentmark_recipe_list', + description: + 'List saved recipes. Optionally filter by `target_app`. Each ' + + 'entry includes name, description, target_app, parameter count, ' + + 'step count, version, created_at, updated_at.', + inputSchema: { + type: 'object', + properties: { + target_app: { type: 'string', description: 'Only return recipes with this target_app.' }, + }, + }, + }, + { + name: 'agentmark_recipe_get', + description: + 'Fetch a recipe by name. When `params` is supplied, the recipe ' + + 'is resolved (parameter substitution applied + defaults filled ' + + 'in) and the steps come back ready to dispatch. Without `params`, ' + + 'returns the raw recipe with placeholders intact.\n' + + '\nThe agent should iterate the returned `steps` and call ' + + 'each one\'s `tool` with its `args` via the regular MCP ' + + 'dispatch path. After each step that has a `verify` block, ' + + 'consider calling agentmark_desktop_diff to confirm the ' + + 'expected change occurred.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + params: { + type: 'object', + description: 'Caller-supplied parameter values. Triggers resolution.', + }, + }, + required: ['name'], + }, + }, + { + name: 'agentmark_recipe_delete', + description: 'Remove a recipe by name. Returns whether it existed.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + required: ['name'], + }, + }, +] diff --git a/src/plugins/recipes/types.ts b/src/plugins/recipes/types.ts new file mode 100644 index 0000000..3ffe1c7 --- /dev/null +++ b/src/plugins/recipes/types.ts @@ -0,0 +1,84 @@ +/** + * Recipe type system. + * + * A Recipe is a saved sequence of MCP tool calls with optional parameter + * substitution. Stored as JSON; retrieved as a "playbook" the agent + * executes step-by-step itself (rather than auto-running inside the + * server, which would couple the plugin to the Dispatcher and hide the + * actions from the AI's reasoning chain). + * + * Substitution syntax: `{{param.name}}` in any string arg. The token + * can stand alone ("{{param.name}}" → the raw param value, preserving + * non-string types) or be embedded ("prefix {{param.name}} suffix" → + * always coerced to string). + * + * Verification fields on each step describe what a subsequent + * agentmark_desktop_diff call SHOULD return — the agent uses them to + * check the step landed before proceeding. They aren't enforced + * server-side; the agent decides what "verified" means. + */ + +export interface Recipe { + /** Stable unique identifier. Used as the storage key. */ + name: string + /** Human-friendly description of what this recipe accomplishes. */ + description?: string + /** App or surface this recipe drives ("excel", "nowcerts", etc.). + * Used for filtering in `agentmark_recipe_list`. */ + target_app?: string + /** Parameter schema. Recipe callers supply values matching these. */ + parameters?: RecipeParameter[] + /** Ordered list of steps to execute. */ + steps: RecipeStep[] + /** Bumped each time the recipe is saved. */ + version: number + /** ISO timestamps. */ + created_at: string + updated_at: string +} + +export interface RecipeParameter { + name: string + description?: string + type: 'string' | 'number' | 'boolean' + /** Default value used when the caller doesn't supply one. */ + default?: unknown + /** When true, `agentmark_recipe_get` errors if the caller omits the param. */ + required?: boolean +} + +export interface RecipeStep { + /** MCP tool name to call (e.g. `agentmark_desktop_execute`). */ + tool: string + /** Tool arguments; string values may contain `{{param.name}}` placeholders. */ + args: Record + /** Optional human-friendly description of what this step does. + * Surfaced in the resolved plan to help the AI understand intent. */ + description?: string + /** Optional verification hint — what the AI should check via diff. */ + verify?: RecipeVerification + /** What to do when this step fails. Default: 'abort'. */ + on_failure?: 'abort' | 'continue' | 'retry' +} + +export interface RecipeVerification { + /** Specific element values the next diff should report. */ + expect_value_changes?: Array<{ element_id: string; to: unknown }> + /** Window-title check after this step. */ + expect_window_title?: string | { contains: string } + /** Number of new elements that should appear (e.g. a dialog opening). */ + expect_added_elements?: number + /** True when the agent expects no observable change (e.g. background save). */ + expect_no_changes?: boolean +} + +export interface ResolvedRecipe extends Omit { + /** Steps with `{{param.X}}` placeholders substituted. */ + steps: ResolvedRecipeStep[] + /** The parameter values that were applied. */ + resolved_with: Record +} + +export interface ResolvedRecipeStep extends Omit { + args: Record +} diff --git a/test/recipes/recipes-plugin.test.ts b/test/recipes/recipes-plugin.test.ts new file mode 100644 index 0000000..847c88b --- /dev/null +++ b/test/recipes/recipes-plugin.test.ts @@ -0,0 +1,316 @@ +/** + * Tests for the Recipes Pack — substitution semantics, store CRUD, + * dispatcher integration. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as os from 'node:os' +import * as path from 'node:path' +import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { + createRecipesPlugin, + RecipeStore, + RECIPES_TOOLS, + substitute, + applyParameterSchema, +} from '../../src/plugins/recipes' +import { Dispatcher } from '../../src/mcp/plugin' + +let tmp: string + +beforeEach(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), 'agentmark-recipes-')) +}) + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }) +}) + +function pluginAt(): ReturnType { + return createRecipesPlugin({ storePath: path.join(tmp, 'recipes.json') }) +} + +describe('Recipes plugin — registration', () => { + it('registers every tool with a matching handler', () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + expect(dispatcher.toolNames.sort()).toEqual(RECIPES_TOOLS.map((t) => t.name).sort()) + }) + + it('exposes the v0 tool set', () => { + expect(RECIPES_TOOLS.map((t) => t.name).sort()).toEqual([ + 'agentmark_recipe_delete', + 'agentmark_recipe_get', + 'agentmark_recipe_list', + 'agentmark_recipe_save', + ]) + }) +}) + +describe('substitute — placeholder semantics', () => { + it('replaces a bare token, preserving the param\'s native type', () => { + const out = substitute('{{param.count}}', { count: 42 }) + expect(out).toBe(42) + }) + + it('embedded tokens always coerce to string', () => { + const out = substitute('Hello, {{param.name}}! You have {{param.count}} items.', { + name: 'Ryan', + count: 3, + }) + expect(out).toBe('Hello, Ryan! You have 3 items.') + }) + + it('recurses into nested objects + arrays', () => { + const out = substitute( + { to: ['{{param.email}}'], subject: 'Welcome, {{param.name}}', count: '{{param.count}}' }, + { email: 'a@b.com', name: 'Ryan', count: 7 }, + ) + expect(out).toEqual({ + to: ['a@b.com'], + subject: 'Welcome, Ryan', + count: 7, + }) + }) + + it('leaves unknown tokens verbatim by default', () => { + const out = substitute('{{param.missing}}', {}) + expect(out).toBe('{{param.missing}}') + }) + + it('throws on unknown tokens when strict=true', () => { + expect(() => substitute('{{param.missing}}', {}, { strict: true })).toThrow(/Unknown parameter/) + }) +}) + +describe('applyParameterSchema — validation + defaults', () => { + it('fills in default values for omitted params', () => { + const applied = applyParameterSchema( + [{ name: 'rate', type: 'number', default: 200 }], + {}, + ) + expect(applied.rate).toBe(200) + }) + + it('errors when a required param is missing', () => { + expect(() => + applyParameterSchema( + [{ name: 'name', type: 'string', required: true }], + {}, + ), + ).toThrow(/required/) + }) + + it('coerces "42" → 42 for number params', () => { + const applied = applyParameterSchema( + [{ name: 'rate', type: 'number' }], + { rate: '42' }, + ) + expect(applied.rate).toBe(42) + }) + + it('rejects non-numeric strings for number params', () => { + expect(() => + applyParameterSchema( + [{ name: 'rate', type: 'number' }], + { rate: 'banana' }, + ), + ).toThrow(/must be a number/) + }) +}) + +describe('RecipeStore — durable CRUD', () => { + it('save + get round-trip', async () => { + const store = new RecipeStore({ path: path.join(tmp, 'recipes.json') }) + const saved = await store.save({ + name: 'add-customer', + steps: [{ tool: 'agentmark_desktop_execute', args: { action_id: 'a' } }], + version: 0, + created_at: '', + updated_at: '', + }) + expect(saved.version).toBe(1) + + const got = await store.get('add-customer') + expect(got?.name).toBe('add-customer') + expect(got?.version).toBe(1) + }) + + it('save with on_conflict="fail" rejects duplicates', async () => { + const store = new RecipeStore({ path: path.join(tmp, 'recipes.json') }) + const recipe = { + name: 'dup', + steps: [{ tool: 'x', args: {} }], + version: 0, + created_at: '', + updated_at: '', + } + await store.save(recipe) + await expect(store.save(recipe)).rejects.toThrow(/already exists/) + }) + + it('save with on_conflict="replace" bumps version', async () => { + const store = new RecipeStore({ path: path.join(tmp, 'recipes.json') }) + const recipe = { + name: 'evolving', + steps: [{ tool: 'x', args: {} }], + version: 0, + created_at: '', + updated_at: '', + } + const v1 = await store.save(recipe) + const v2 = await store.save(recipe, { on_conflict: 'replace' }) + expect(v1.version).toBe(1) + expect(v2.version).toBe(2) + }) + + it('list filters by target_app', async () => { + const store = new RecipeStore({ path: path.join(tmp, 'recipes.json') }) + await store.save({ name: 'a', target_app: 'excel', steps: [{ tool: 't', args: {} }], version: 0, created_at: '', updated_at: '' }) + await store.save({ name: 'b', target_app: 'word', steps: [{ tool: 't', args: {} }], version: 0, created_at: '', updated_at: '' }) + await store.save({ name: 'c', target_app: 'excel', steps: [{ tool: 't', args: {} }], version: 0, created_at: '', updated_at: '' }) + + const excelOnly = await store.list({ target_app: 'excel' }) + expect(excelOnly.map((r) => r.name).sort()).toEqual(['a', 'c']) + }) + + it('delete returns whether the recipe existed', async () => { + const store = new RecipeStore({ path: path.join(tmp, 'recipes.json') }) + await store.save({ name: 'tmp', steps: [{ tool: 't', args: {} }], version: 0, created_at: '', updated_at: '' }) + expect(await store.delete('tmp')).toBe(true) + expect(await store.delete('tmp')).toBe(false) + }) + + it('persists to disk so a fresh instance sees prior writes', async () => { + const p = path.join(tmp, 'recipes.json') + const a = new RecipeStore({ path: p }) + await a.save({ name: 'persistent', steps: [{ tool: 't', args: {} }], version: 0, created_at: '', updated_at: '' }) + + const b = new RecipeStore({ path: p }) + expect((await b.get('persistent'))?.name).toBe('persistent') + + const onDisk = JSON.parse(await readFile(p, 'utf8')) + expect(onDisk.version).toBe(1) + expect(onDisk.recipes.persistent.name).toBe('persistent') + }) +}) + +describe('Recipes plugin — dispatched through the plugin', () => { + it('save + get without params returns the raw recipe', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + const save = await dispatcher.dispatch('agentmark_recipe_save', { + name: 'fill-form', + description: 'Fill a single text field with a name', + target_app: 'nowcerts', + parameters: [{ name: 'customer_name', type: 'string', required: true }], + steps: [ + { + tool: 'agentmark_desktop_execute', + args: { action_id: 'act_in_company', value: '{{param.customer_name}}' }, + }, + ], + }) + expect(save.isError).toBeFalsy() + const saved = JSON.parse(save.text) + expect(saved.saved).toBe(true) + expect(saved.version).toBe(1) + + const got = await dispatcher.dispatch('agentmark_recipe_get', { name: 'fill-form' }) + const body = JSON.parse(got.text) + expect(body.resolved).toBe(false) + // Placeholder still present. + expect(body.recipe.steps[0].args.value).toBe('{{param.customer_name}}') + }) + + it('get with params resolves placeholders', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_recipe_save', { + name: 'fill-form', + parameters: [{ name: 'customer_name', type: 'string', required: true }], + steps: [ + { tool: 'agentmark_desktop_execute', args: { action_id: 'act_in_company', value: '{{param.customer_name}}' } }, + ], + }) + + const got = await dispatcher.dispatch('agentmark_recipe_get', { + name: 'fill-form', + params: { customer_name: 'Globex Corp' }, + }) + const body = JSON.parse(got.text) + expect(body.resolved).toBe(true) + expect(body.steps[0].args.value).toBe('Globex Corp') + expect(body.resolved_with).toEqual({ customer_name: 'Globex Corp' }) + }) + + it('get errors when a required param is missing', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_recipe_save', { + name: 'needs-name', + parameters: [{ name: 'customer_name', type: 'string', required: true }], + steps: [{ tool: 'x', args: {} }], + }) + + const got = await dispatcher.dispatch('agentmark_recipe_get', { + name: 'needs-name', + params: {}, + }) + expect(got.isError).toBe(true) + expect(got.text).toMatch(/required/) + }) + + it('get errors on unknown recipe', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + const got = await dispatcher.dispatch('agentmark_recipe_get', { name: 'nope' }) + expect(got.isError).toBe(true) + expect(got.text).toMatch(/Unknown recipe/) + }) + + it('list filters by target_app', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_recipe_save', { + name: 'excel-a', + target_app: 'excel', + steps: [{ tool: 't', args: {} }], + }) + await dispatcher.dispatch('agentmark_recipe_save', { + name: 'word-a', + target_app: 'word', + steps: [{ tool: 't', args: {} }], + }) + + const listed = await dispatcher.dispatch('agentmark_recipe_list', { target_app: 'excel' }) + const body = JSON.parse(listed.text) + expect(body.count).toBe(1) + expect(body.recipes[0].name).toBe('excel-a') + }) + + it('delete + get-after-delete', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_recipe_save', { name: 'gone', steps: [{ tool: 't', args: {} }] }) + const del = await dispatcher.dispatch('agentmark_recipe_delete', { name: 'gone' }) + expect(JSON.parse(del.text).existed).toBe(true) + + const got = await dispatcher.dispatch('agentmark_recipe_get', { name: 'gone' }) + expect(got.isError).toBe(true) + }) + + it('save defaults to on_conflict="fail"', async () => { + const plugin = pluginAt() + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_recipe_save', { name: 'dup', steps: [{ tool: 't', args: {} }] }) + const second = await dispatcher.dispatch('agentmark_recipe_save', { name: 'dup', steps: [{ tool: 't', args: {} }] }) + expect(second.isError).toBe(true) + expect(second.text).toMatch(/already exists/) + }) +})