From 7b8a09d29aaf7f776ef2d70d8b10d09c569d0aaa Mon Sep 17 00:00:00 2001 From: rrader26 Date: Tue, 12 May 2026 11:30:31 -0400 Subject: [PATCH] fix(plugins): swap speculative Remote backends for real ActivepiecesMemoryBackend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #35 shipped Remote{Memory,Recipe}Backend classes that targeted REST endpoints we never built — pure stubs. They were marked "v1 proposal" in their headers, which is the kind of speculative interface we don't want in production code. This PR removes the stubs and replaces them with a REAL ActivepiecesMemoryBackend that hits live endpoints in the user's Activepieces deployment: /v1/projects/:projectId/memory (project-scoped) /v1/projects/:projectId/chatbots/:chatbotId/memory (chatbot-scoped) /v1/projects/:projectId/chatbots/:chatbotId/memory/search (hybrid search) Auth: `Authorization: Bearer sk-` against the existing Activepieces Service-principal flow. The same Claude Code / Cursor / Codex agent connecting to agentmark now writes to **real Activepieces memory** instead of a speculative service — and gets hybrid vector + BM25 semantic search for free since agentmark_memory_search routes to /memory/search. Mapping (agentmark K/V → Activepieces rich shape): - agentmark `key` → metadata.agentmark_key - agentmark `value` → content + metadata.raw_value (preserves type) - agentmark `scope` → Activepieces scope (same five-level enum) - agentmark `scope.id` → metadata.scope_id - agentmark `tags` → metadata.tags - source stamp = "agentmark" (filterable; backends only see records they wrote) Recipes: NO ActivepiecesRecipeBackend ships in this PR because Activepieces has no recipes endpoints yet. RecipeBackend interface + LocalFileRecipeBackend remain; when the recipes service ships, a real implementation lands then. Removed (the stubs): - src/plugins/memory/remote-backend.ts - src/plugins/recipes/remote-backend.ts - test/memory/remote-backend.test.ts - test/recipes/remote-backend.test.ts Added: - src/plugins/memory/activepieces-backend.ts (real impl) - test/memory/activepieces-backend.test.ts (20 tests) Tests: 491 pass / 10 skip. Build clean. All prior tests pass unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 15 +- src/plugins/memory/activepieces-backend.ts | 361 +++++++++++++++++++++ src/plugins/memory/index.ts | 4 +- src/plugins/memory/remote-backend.ts | 170 ---------- src/plugins/recipes/index.ts | 2 - src/plugins/recipes/remote-backend.ts | 153 --------- test/memory/activepieces-backend.test.ts | 314 ++++++++++++++++++ test/memory/remote-backend.test.ts | 150 --------- test/recipes/remote-backend.test.ts | 134 -------- 9 files changed, 685 insertions(+), 618 deletions(-) create mode 100644 src/plugins/memory/activepieces-backend.ts delete mode 100644 src/plugins/memory/remote-backend.ts delete mode 100644 src/plugins/recipes/remote-backend.ts create mode 100644 test/memory/activepieces-backend.test.ts delete mode 100644 test/memory/remote-backend.test.ts delete mode 100644 test/recipes/remote-backend.test.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index c3b85c6..ddf31bd 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -102,11 +102,11 @@ export type { } from '../plugins/system' // Recipes Pack — durable named playbooks the agent learns once + replays. +// Local-file backend only for now; Activepieces recipes endpoints don't +// exist yet so we don't ship a speculative HTTP backend. export { createRecipesPlugin, LocalFileRecipeBackend, - RemoteRecipeBackend, - RemoteRecipeError, RecipeStore, RECIPES_TOOLS, } from '../plugins/recipes' @@ -119,17 +119,18 @@ export type { RecipeBackend, RecipeBackendDescription, LocalFileRecipeBackendConfig, - RemoteRecipeBackendConfig, } from '../plugins/recipes' // Memory Pack — hierarchical persistent memory for AI agents. Opt-in; // targeted at IDE coding-assistant integrations where per-session -// amnesia is the dominant UX limitation. +// amnesia is the dominant UX limitation. Two production-ready backends: +// LocalFile (default, single-user) and Activepieces (multi-user, hits +// the live ThinkFleet agent-memory API). export { createMemoryPlugin, LocalFileMemoryBackend, - RemoteMemoryBackend, - RemoteMemoryError, + ActivepiecesMemoryBackend, + ActivepiecesMemoryError, MemoryStore, MEMORY_TOOLS, } from '../plugins/memory' @@ -143,7 +144,7 @@ export type { MemoryBackendDescription, MemorySetInput, LocalFileMemoryBackendConfig, - RemoteMemoryBackendConfig, + ActivepiecesMemoryBackendConfig, } from '../plugins/memory' // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the diff --git a/src/plugins/memory/activepieces-backend.ts b/src/plugins/memory/activepieces-backend.ts new file mode 100644 index 0000000..c16c6fa --- /dev/null +++ b/src/plugins/memory/activepieces-backend.ts @@ -0,0 +1,361 @@ +/** + * Activepieces-backed memory backend. + * + * Wires the agentmark Memory plugin to the production Activepieces + * agent-memory API. Same `MemoryBackend` interface as the disk-backed + * store — the plugin layer + tool handlers don't know or care which + * backend they're talking to. + * + * The user's ThinkFleet stack already has a rich memory system + * (bi-temporal facts, hybrid vector+BM25 search, scope-aware + * confirmation workflow, knowledge graph). The simple key/value + * interface agentmark exposes to AI agents maps onto a subset of + * that system; this class is the bridge. + * + * Mapping (agentmark K/V → Activepieces rich shape): + * - agentmark `key` → Activepieces `metadata.agentmark_key` + * (Activepieces stores free-form `content`, not key/value pairs; + * we round-trip the key through metadata so get/deleteByKey can + * locate records.) + * - agentmark `value` → Activepieces `content` (string-coerced) + * + `metadata.raw_value` (preserves type) + * - agentmark `scope` → Activepieces `scope` (same five-level enum) + * - agentmark `scope.id` → `metadata.scope_id` + * - agentmark `tags` → `metadata.tags` (string[]) + * + * Auth: `Authorization: Bearer sk-` against the Activepieces + * Service-principal flow. The backend is scoped to one project; pass + * a `chatbotId` to target chatbot-scoped routes (richer create/search + * semantics), otherwise omit and the project-scoped routes are used. + */ +import type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' +import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' + +export interface ActivepiecesMemoryBackendConfig { + /** Base URL of the Activepieces API (no trailing slash). */ + baseUrl: string + /** API key (must start with `sk-`). Service principal. */ + apiKey: string + /** Project id this backend operates against. Required. */ + projectId: string + /** Optional chatbot id for chatbot-scoped routes. */ + chatbotId?: string + /** Injectable fetch (for tests). Default: globalThis.fetch. */ + fetch?: typeof fetch + /** Per-request timeout in ms. Default: 15000. */ + timeoutMs?: number + /** Activepieces `source` value to stamp on writes. Defaults to + * 'agentmark' so records this backend creates are filterable. */ + source?: string +} + +export class ActivepiecesMemoryError extends Error { + constructor( + message: string, + readonly status: number, + readonly body?: unknown, + ) { + super(message) + this.name = 'ActivepiecesMemoryError' + } +} + +/** Shape of one item in the Activepieces clawdbot_memory_item table. */ +interface ApMemoryItem { + id: string + platformId: string + projectId: string | null + chatbotId: string | null + type: string + content: string + category: string | null + importance: number + source: string | null + sessionKey: string | null + chatIdentityId: string | null + metadata: Record | null + scope: string + status: string + confidence: number + impact: string | null + confirmedAt: string | null + validAt: string + invalidAt: string | null + created: string + updated: string + similarity?: number +} + +const AP_KEY_FIELD = 'agentmark_key' +const AP_RAW_VALUE_FIELD = 'raw_value' +const AP_TAGS_FIELD = 'tags' + +export class ActivepiecesMemoryBackend implements MemoryBackend { + readonly baseUrl: string + readonly projectId: string + readonly chatbotId?: string + readonly source: string + private readonly apiKey: string + private readonly fetcher: typeof fetch + private readonly timeoutMs: number + + constructor(config: ActivepiecesMemoryBackendConfig) { + if (!config.baseUrl) throw new Error('ActivepiecesMemoryBackend: baseUrl is required.') + if (!config.apiKey) throw new Error('ActivepiecesMemoryBackend: apiKey is required.') + if (!config.apiKey.startsWith('sk-')) { + throw new Error('ActivepiecesMemoryBackend: apiKey must start with "sk-".') + } + if (!config.projectId) throw new Error('ActivepiecesMemoryBackend: projectId is required.') + + this.baseUrl = config.baseUrl.replace(/\/+$/, '') + this.apiKey = config.apiKey + this.projectId = config.projectId + this.chatbotId = config.chatbotId + this.source = config.source ?? 'agentmark' + this.fetcher = config.fetch ?? globalThis.fetch + this.timeoutMs = config.timeoutMs ?? 15_000 + } + + async set(input: MemorySetInput): Promise { + const scope = input.scope ?? { type: 'platform' as const } + const body = { + type: 'fact', + content: stringifyValue(input.value), + scope: scope.type, + source: this.source, + metadata: { + [AP_KEY_FIELD]: input.key, + [AP_RAW_VALUE_FIELD]: input.value, + ...(input.tags ? { [AP_TAGS_FIELD]: input.tags } : {}), + ...(scope.id ? { scope_id: scope.id } : {}), + }, + } + + // Match agentmark's "set replaces same key+scope" semantics by + // looking up the existing record and deleting it before create. + const existing = await this.findByKey(input.key, scope) + if (existing) await this.deleteById(existing.id) + + const created = await this.request('POST', this.memoryRoot(), body) + return apToAgentmark(created) + } + + async get(input: { key: string; scopes?: MemoryScope[] }): Promise { + const scopes = input.scopes ?? [{ type: 'platform' }] + for (const scope of scopes) { + const found = await this.findByKey(input.key, scope) + if (found) return apToAgentmark(found) + } + return null + } + + async deleteById(recordId: string): Promise { + try { + await this.request('DELETE', `${this.memoryRoot()}/${encodeURIComponent(recordId)}`) + return true + } catch (err) { + if (err instanceof ActivepiecesMemoryError && err.status === 404) return false + throw err + } + } + + async deleteByKey(key: string, scope: MemoryScope): Promise { + const found = await this.findByKey(key, scope) + if (!found) return false + return this.deleteById(found.id) + } + + async search(query: MemorySearchQuery): Promise { + // Activepieces' /memory/search requires a non-empty query for + // hybrid (vector + BM25) ranking. When agentmark callers didn't + // pass a query, fall back to a scope-filtered list so the + // MemoryBackend contract is preserved. + if (!query.query) return this.listFallback(query) + + const body: Record = { query: query.query, limit: query.limit ?? 10 } + if (query.scope) body.scope = query.scope.type + + const results = await this.request( + 'POST', + `${this.memoryRoot()}/search`, + body, + ) + + let records = results.map(apToAgentmark) + if (query.tags && query.tags.length > 0) { + records = records.filter((r) => + r.tags && r.tags.some((t) => query.tags!.includes(t)), + ) + } + // Activepieces' hybrid search already orders by similarity; let + // explicit sort_by override. + if (query.sort_by === 'recency') { + records.sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + } else if (query.sort_by === 'access_count') { + records.sort((a, b) => b.access_count - a.access_count) + } else if (query.sort_by === 'created') { + records.sort((a, b) => b.created_at.localeCompare(a.created_at)) + } + return records + } + + async list(scope?: MemoryScope, prefix?: string, limit = 200): Promise { + const params = new URLSearchParams() + if (scope?.type) params.set('scope', scope.type) + params.set('source', this.source) + params.set('limit', String(Math.min(limit, 100))) // server caps at 100/page + + const items = await this.request( + 'GET', + `${this.memoryRoot()}?${params.toString()}`, + ) + let records = items.map(apToAgentmark) + if (prefix) records = records.filter((r) => r.key.startsWith(prefix)) + return records.slice(0, limit) + } + + async clear(): Promise { + // No bulk-delete endpoint; iterate with source filter so we only + // touch records this backend wrote. Bounded at 10 pages × 100 = + // 1000 records to avoid runaway loops on misconfigured servers. + const params = new URLSearchParams({ source: this.source, limit: '100' }) + for (let page = 0; page < 10; page++) { + const items = await this.request( + 'GET', + `${this.memoryRoot()}?${params.toString()}`, + ) + if (items.length === 0) break + for (const item of items) await this.deleteById(item.id) + if (items.length < 100) break + } + } + + async describe(): Promise { + return { + kind: 'activepieces', + base_url: this.baseUrl, + project_id: this.projectId, + chatbot_id: this.chatbotId, + source: this.source, + } + } + + // ────────────────────────────────────────────────────────────────── + // Internals + // ────────────────────────────────────────────────────────────────── + + private memoryRoot(): string { + if (this.chatbotId) { + return `/v1/projects/${encodeURIComponent(this.projectId)}/chatbots/${encodeURIComponent(this.chatbotId)}/memory` + } + return `/v1/projects/${encodeURIComponent(this.projectId)}/memory` + } + + private async findByKey(key: string, scope: MemoryScope): Promise { + // List by scope + source (cheap server-side filter), then match + // key client-side. source=agentmark scopes us to records THIS + // backend wrote so we don't collide with manually-created + // Activepieces memories. + const params = new URLSearchParams({ + scope: scope.type, + source: this.source, + limit: '100', + }) + const items = await this.request( + 'GET', + `${this.memoryRoot()}?${params.toString()}`, + ) + for (const item of items) { + const md = (item.metadata ?? {}) as Record + if (md[AP_KEY_FIELD] !== key) continue + // Strict scope_id match: distinguishes two project-scoped + // memories with the same key from different repos. + if (scope.id !== undefined && md.scope_id !== scope.id) continue + return item + } + return null + } + + private async listFallback(query: MemorySearchQuery): Promise { + const records = await this.list(query.scope, undefined, query.limit ?? 50) + if (query.tags && query.tags.length > 0) { + return records.filter((r) => + r.tags && r.tags.some((t) => query.tags!.includes(t)), + ) + } + return records + } + + private async request(method: string, p: string, body?: unknown): Promise { + const headers: Record = { + authorization: `Bearer ${this.apiKey}`, + } + if (body !== undefined) headers['content-type'] = 'application/json' + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeoutMs) + + let response: Response + try { + response = await this.fetcher(`${this.baseUrl}${p}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } + + const text = await response.text() + let parsed: unknown = undefined + if (text.length > 0) { + try { parsed = JSON.parse(text) } catch { parsed = text } + } + + if (!response.ok) { + throw new ActivepiecesMemoryError( + `Activepieces memory ${method} ${p} failed: ${response.status} ${response.statusText}`, + response.status, + parsed, + ) + } + return parsed as T + } +} + +function stringifyValue(value: unknown): string { + if (typeof value === 'string') return value + if (value === undefined || value === null) return '' + return JSON.stringify(value) +} + +function apToAgentmark(item: ApMemoryItem): MemoryRecord { + const metadata = (item.metadata ?? {}) as Record + const storedKey = typeof metadata[AP_KEY_FIELD] === 'string' + ? metadata[AP_KEY_FIELD] as string + : item.id + const rawValue = AP_RAW_VALUE_FIELD in metadata + ? metadata[AP_RAW_VALUE_FIELD] + : item.content + const tags = Array.isArray(metadata[AP_TAGS_FIELD]) + ? metadata[AP_TAGS_FIELD] as string[] + : undefined + const scopeId = typeof metadata.scope_id === 'string' ? metadata.scope_id as string : undefined + + return { + record_id: item.id, + key: storedKey, + value: rawValue, + scope: { + type: item.scope as MemoryScope['type'], + id: scopeId, + }, + tags, + expires_at: item.invalidAt ? Date.parse(item.invalidAt) : undefined, + created_at: item.created, + updated_at: item.updated, + access_count: 0, + last_accessed_at: undefined, + } +} diff --git a/src/plugins/memory/index.ts b/src/plugins/memory/index.ts index 2775c76..e5d42d3 100644 --- a/src/plugins/memory/index.ts +++ b/src/plugins/memory/index.ts @@ -169,8 +169,8 @@ function optionalStringArray(v: unknown): string[] | undefined { export { LocalFileMemoryBackend, MemoryStore } from './store' export type { LocalFileMemoryBackendConfig, MemoryStoreConfig } from './store' -export { RemoteMemoryBackend, RemoteMemoryError } from './remote-backend' -export type { RemoteMemoryBackendConfig } from './remote-backend' +export { ActivepiecesMemoryBackend, ActivepiecesMemoryError } from './activepieces-backend' +export type { ActivepiecesMemoryBackendConfig } from './activepieces-backend' export type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' export { MEMORY_TOOLS } from './tool-defs' export type { diff --git a/src/plugins/memory/remote-backend.ts b/src/plugins/memory/remote-backend.ts deleted file mode 100644 index a389592..0000000 --- a/src/plugins/memory/remote-backend.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Remote (HTTP) memory backend. - * - * Hits a ThinkFleet memory service over HTTPS. Same agentmark binary - * works against on-prem deployments (internal URL, internal auth) and - * cloud deployments (api.thinkfleet.ai) — only the `baseUrl` + `token` - * differ. - * - * Auth: Bearer token. Optional `X-Workspace-Id` header scopes calls to - * a specific team/org so a single user can be a member of multiple - * workspaces. - * - * Endpoint contract (v1 proposal — subject to change before the - * service ships): - * - * POST /v1/memory/records { key, value, scope, tags?, ttl_seconds? } → MemoryRecord - * POST /v1/memory/records/get { key, scopes? } → MemoryRecord | null - * DELETE /v1/memory/records/:record_id → { deleted: boolean } - * DELETE /v1/memory/records { key, scope } → { deleted: boolean } - * POST /v1/memory/search MemorySearchQuery → MemoryRecord[] - * GET /v1/memory/records?scope_type=&scope_id=&prefix=&limit= → MemoryRecord[] - * DELETE /v1/memory/records/all → { cleared: true } - * GET /v1/memory/describe → MemoryBackendDescription - * - * Errors: non-2xx responses are surfaced as RemoteMemoryError with the - * status code + parsed JSON body (when JSON). Auth-related 401s also - * include the workspace header for easier debugging. - */ -import type { MemoryBackend, MemoryBackendDescription, MemorySetInput } from './backend' -import type { MemoryRecord, MemoryScope, MemorySearchQuery } from './types' - -export interface RemoteMemoryBackendConfig { - /** Base URL of the memory service (no trailing slash). */ - baseUrl: string - /** Bearer token. Required for all non-anonymous deployments. */ - token?: string - /** Optional workspace / team id sent as `X-Workspace-Id`. */ - workspaceId?: string - /** Injectable fetch (defaults to globalThis.fetch). Used by tests. */ - fetch?: typeof fetch - /** Per-request timeout in ms. Default: 15_000. */ - timeoutMs?: number -} - -export class RemoteMemoryError extends Error { - constructor( - message: string, - readonly status: number, - readonly body?: unknown, - ) { - super(message) - this.name = 'RemoteMemoryError' - } -} - -export class RemoteMemoryBackend implements MemoryBackend { - readonly baseUrl: string - readonly workspaceId?: string - private readonly token?: string - private readonly fetcher: typeof fetch - private readonly timeoutMs: number - - constructor(config: RemoteMemoryBackendConfig) { - if (!config.baseUrl) throw new Error('RemoteMemoryBackend: baseUrl is required.') - this.baseUrl = config.baseUrl.replace(/\/+$/, '') - this.token = config.token - this.workspaceId = config.workspaceId - this.fetcher = config.fetch ?? globalThis.fetch - this.timeoutMs = config.timeoutMs ?? 15_000 - } - - async set(input: MemorySetInput): Promise { - return await this.request('POST', '/v1/memory/records', { - key: input.key, - value: input.value, - scope: input.scope ?? { type: 'platform' }, - tags: input.tags, - ttl_seconds: input.ttlSeconds, - }) - } - - async get(input: { key: string; scopes?: MemoryScope[] }): Promise { - const result = await this.request('POST', '/v1/memory/records/get', input) - return result ?? null - } - - async deleteById(recordId: string): Promise { - const r = await this.request<{ deleted: boolean }>( - 'DELETE', - `/v1/memory/records/${encodeURIComponent(recordId)}`, - ) - return r.deleted === true - } - - async deleteByKey(key: string, scope: MemoryScope): Promise { - const r = await this.request<{ deleted: boolean }>('DELETE', '/v1/memory/records', { key, scope }) - return r.deleted === true - } - - async search(query: MemorySearchQuery): Promise { - return await this.request('POST', '/v1/memory/search', query) - } - - async list(scope?: MemoryScope, prefix?: string, limit?: number): Promise { - const params = new URLSearchParams() - if (scope?.type) params.set('scope_type', scope.type) - if (scope?.id) params.set('scope_id', scope.id) - if (prefix) params.set('prefix', prefix) - if (typeof limit === 'number') params.set('limit', String(limit)) - const qs = params.toString() - return await this.request( - 'GET', - `/v1/memory/records${qs ? '?' + qs : ''}`, - ) - } - - async clear(): Promise { - await this.request<{ cleared: true }>('DELETE', '/v1/memory/records/all') - } - - async describe(): Promise { - const remote = await this.request( - 'GET', - '/v1/memory/describe', - ).catch((): undefined => undefined) - return { - kind: 'remote-http', - base_url: this.baseUrl, - workspace_id: this.workspaceId, - ...(remote ?? {}), - } - } - - private async request(method: string, path: string, body?: unknown): Promise { - const headers: Record = {} - if (this.token) headers.authorization = `Bearer ${this.token}` - if (this.workspaceId) headers['x-workspace-id'] = this.workspaceId - if (body !== undefined) headers['content-type'] = 'application/json' - - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), this.timeoutMs) - - let response: Response - try { - response = await this.fetcher(`${this.baseUrl}${path}`, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - signal: controller.signal, - }) - } finally { - clearTimeout(timer) - } - - const text = await response.text() - let parsed: unknown = undefined - if (text.length > 0) { - try { parsed = JSON.parse(text) } catch { parsed = text } - } - - if (!response.ok) { - throw new RemoteMemoryError( - `Memory service ${method} ${path} failed: ${response.status} ${response.statusText}`, - response.status, - parsed, - ) - } - return parsed as T - } -} diff --git a/src/plugins/recipes/index.ts b/src/plugins/recipes/index.ts index 6c43bb3..5f7ca9d 100644 --- a/src/plugins/recipes/index.ts +++ b/src/plugins/recipes/index.ts @@ -149,8 +149,6 @@ function recipeSummary(r: Recipe): Record { export { LocalFileRecipeBackend, RecipeStore } from './store' export type { LocalFileRecipeBackendConfig, RecipeStoreConfig } from './store' -export { RemoteRecipeBackend, RemoteRecipeError } from './remote-backend' -export type { RemoteRecipeBackendConfig } from './remote-backend' export type { RecipeBackend, RecipeBackendDescription } from './backend' export { resolveRecipe, substitute, applyParameterSchema } from './substitute' export { RECIPES_TOOLS } from './tool-defs' diff --git a/src/plugins/recipes/remote-backend.ts b/src/plugins/recipes/remote-backend.ts deleted file mode 100644 index 5c29fc8..0000000 --- a/src/plugins/recipes/remote-backend.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Remote (HTTP) recipe backend. - * - * Same auth + workspace pattern as RemoteMemoryBackend. The desktop - * app instantiates this when the user/org is configured for on-prem - * or cloud sync — recipes saved by one teammate become available to - * everyone in the workspace. - * - * Endpoint contract (v1 proposal): - * - * POST /v1/recipes Recipe → Recipe - * GET /v1/recipes/:name → Recipe | null - * GET /v1/recipes?target_app=... → Recipe[] - * DELETE /v1/recipes/:name → { deleted: boolean } - * DELETE /v1/recipes/all → { cleared: true } - * GET /v1/recipes/describe → RecipeBackendDescription - * - * Save semantics: server controls version + timestamps; the on_conflict - * flag is passed as a query param. - */ -import type { RecipeBackend, RecipeBackendDescription } from './backend' -import type { Recipe } from './types' - -export interface RemoteRecipeBackendConfig { - /** Base URL of the recipe service (no trailing slash). */ - baseUrl: string - /** Bearer token. */ - token?: string - /** Optional workspace / team id sent as `X-Workspace-Id`. */ - workspaceId?: string - /** Injectable fetch (defaults to globalThis.fetch). */ - fetch?: typeof fetch - /** Per-request timeout in ms. Default: 15_000. */ - timeoutMs?: number -} - -export class RemoteRecipeError extends Error { - constructor( - message: string, - readonly status: number, - readonly body?: unknown, - ) { - super(message) - this.name = 'RemoteRecipeError' - } -} - -export class RemoteRecipeBackend implements RecipeBackend { - readonly baseUrl: string - readonly workspaceId?: string - private readonly token?: string - private readonly fetcher: typeof fetch - private readonly timeoutMs: number - - constructor(config: RemoteRecipeBackendConfig) { - if (!config.baseUrl) throw new Error('RemoteRecipeBackend: baseUrl is required.') - this.baseUrl = config.baseUrl.replace(/\/+$/, '') - this.token = config.token - this.workspaceId = config.workspaceId - this.fetcher = config.fetch ?? globalThis.fetch - this.timeoutMs = config.timeoutMs ?? 15_000 - } - - async get(name: string): Promise { - try { - return await this.request('GET', `/v1/recipes/${encodeURIComponent(name)}`) - } catch (err) { - if (err instanceof RemoteRecipeError && err.status === 404) return null - throw err - } - } - - async list(filter?: { target_app?: string }): Promise { - const qs = filter?.target_app ? `?target_app=${encodeURIComponent(filter.target_app)}` : '' - return await this.request('GET', `/v1/recipes${qs}`) - } - - async save(recipe: Recipe, options: { on_conflict?: 'replace' | 'fail' } = {}): Promise { - const onConflict = options.on_conflict ?? 'fail' - return await this.request( - 'POST', - `/v1/recipes?on_conflict=${onConflict}`, - recipe, - ) - } - - async delete(name: string): Promise { - try { - const r = await this.request<{ deleted: boolean }>( - 'DELETE', - `/v1/recipes/${encodeURIComponent(name)}`, - ) - return r.deleted === true - } catch (err) { - if (err instanceof RemoteRecipeError && err.status === 404) return false - throw err - } - } - - async clear(): Promise { - await this.request<{ cleared: true }>('DELETE', '/v1/recipes/all') - } - - async describe(): Promise { - const remote = await this.request( - 'GET', - '/v1/recipes/describe', - ).catch((): undefined => undefined) - return { - kind: 'remote-http', - base_url: this.baseUrl, - workspace_id: this.workspaceId, - ...(remote ?? {}), - } - } - - private async request(method: string, p: string, body?: unknown): Promise { - const headers: Record = {} - if (this.token) headers.authorization = `Bearer ${this.token}` - if (this.workspaceId) headers['x-workspace-id'] = this.workspaceId - if (body !== undefined) headers['content-type'] = 'application/json' - - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), this.timeoutMs) - - let response: Response - try { - response = await this.fetcher(`${this.baseUrl}${p}`, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - signal: controller.signal, - }) - } finally { - clearTimeout(timer) - } - - const text = await response.text() - let parsed: unknown = undefined - if (text.length > 0) { - try { parsed = JSON.parse(text) } catch { parsed = text } - } - - if (!response.ok) { - throw new RemoteRecipeError( - `Recipe service ${method} ${p} failed: ${response.status} ${response.statusText}`, - response.status, - parsed, - ) - } - return parsed as T - } -} diff --git a/test/memory/activepieces-backend.test.ts b/test/memory/activepieces-backend.test.ts new file mode 100644 index 0000000..92a2131 --- /dev/null +++ b/test/memory/activepieces-backend.test.ts @@ -0,0 +1,314 @@ +/** + * Tests for ActivepiecesMemoryBackend — request shape + response mapping. + * Mocks globalThis.fetch; no real network calls. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { ActivepiecesMemoryBackend, ActivepiecesMemoryError } from '../../src/plugins/memory' + +let originalFetch: typeof globalThis.fetch +let requests: Array<{ url: string; init?: RequestInit }> + +beforeEach(() => { + originalFetch = globalThis.fetch + requests = [] +}) + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL | Request).toString() + requests.push({ url, init }) + return Promise.resolve(responder(url, init)) + }) as typeof globalThis.fetch +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function apItem(overrides: Record = {}): Record { + return { + id: 'mem_default', + platformId: 'plat_1', + projectId: 'proj_1', + chatbotId: null, + type: 'fact', + content: 'pnpm build', + category: null, + importance: 5, + source: 'agentmark', + sessionKey: null, + chatIdentityId: null, + metadata: { + agentmark_key: 'build_command', + raw_value: 'pnpm build', + scope_id: 'proj_1', + }, + scope: 'project', + status: 'confirmed', + confidence: 1.0, + impact: null, + confirmedAt: null, + validAt: '2026-05-12T00:00:00Z', + invalidAt: null, + created: '2026-05-12T00:00:00Z', + updated: '2026-05-12T00:00:00Z', + ...overrides, + } +} + +const baseConfig = { + baseUrl: 'https://app.example.com', + apiKey: 'sk-test-1234', + projectId: 'proj_1', +} + +describe('ActivepiecesMemoryBackend — construction', () => { + it('requires baseUrl, sk-prefixed apiKey, and projectId', () => { + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, baseUrl: '' })).toThrow(/baseUrl/) + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, apiKey: '' })).toThrow(/apiKey/) + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, apiKey: 'not-sk-key' })).toThrow(/sk-/) + expect(() => new ActivepiecesMemoryBackend({ ...baseConfig, projectId: '' })).toThrow(/projectId/) + }) + + it('strips trailing slashes from baseUrl', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend({ ...baseConfig, baseUrl: 'https://x.com///' }) + await backend.list({ type: 'platform' }) + expect(requests[0].url.startsWith('https://x.com/v1/projects/')).toBe(true) + }) +}) + +describe('ActivepiecesMemoryBackend — auth + path shape', () => { + it('attaches Authorization Bearer header on every request', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.list() + const headers = requests[0].init?.headers as Record + expect(headers.authorization).toBe('Bearer sk-test-1234') + }) + + it('targets project-scoped routes when chatbotId is omitted', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.list() + expect(requests[0].url).toContain('/v1/projects/proj_1/memory') + expect(requests[0].url).not.toContain('/chatbots/') + }) + + it('targets chatbot-scoped routes when chatbotId is supplied', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend({ ...baseConfig, chatbotId: 'cb_42' }) + await backend.list() + expect(requests[0].url).toContain('/v1/projects/proj_1/chatbots/cb_42/memory') + }) +}) + +describe('ActivepiecesMemoryBackend — set', () => { + it('looks for existing record + deletes it before creating new one', async () => { + mockFetch((url, init) => { + if (init?.method === 'GET') return jsonResponse([apItem({ id: 'mem_existing' })]) + if (init?.method === 'DELETE') return jsonResponse({}) + return jsonResponse(apItem({ + id: 'mem_new', + content: 'pnpm test', + metadata: { agentmark_key: 'build_command', raw_value: 'pnpm test', scope_id: 'proj_1' }, + })) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.set({ + key: 'build_command', + value: 'pnpm test', + scope: { type: 'project', id: 'proj_1' }, + }) + + expect(requests.map((r) => r.init?.method)).toEqual(['GET', 'DELETE', 'POST']) + const postBody = JSON.parse(requests[2].init?.body as string) + expect(postBody.type).toBe('fact') + expect(postBody.scope).toBe('project') + expect(postBody.source).toBe('agentmark') + expect(postBody.metadata.agentmark_key).toBe('build_command') + expect(postBody.metadata.raw_value).toBe('pnpm test') + expect(postBody.metadata.scope_id).toBe('proj_1') + expect(record.record_id).toBe('mem_new') + expect(record.value).toBe('pnpm test') + }) + + it('JSON-stringifies non-string values; preserves raw value in metadata', async () => { + const captured: Array<{ body: unknown }> = [] + mockFetch((_url, init) => { + if (init?.method === 'GET') return jsonResponse([]) + if (init?.method === 'POST') { + captured.push({ body: JSON.parse(init.body as string) }) + return jsonResponse(apItem({ content: '{"x":1}' })) + } + return jsonResponse({}) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.set({ key: 'cfg', value: { x: 1 } }) + + const sent = captured[0]?.body as { content: string; metadata: Record } + expect(sent.content).toBe('{"x":1}') + expect(sent.metadata.raw_value).toEqual({ x: 1 }) + }) +}) + +describe('ActivepiecesMemoryBackend — get + deleteByKey', () => { + it('walks scopes in order; returns first match', async () => { + let call = 0 + mockFetch(() => { + call += 1 + if (call === 1) return jsonResponse([]) + return jsonResponse([apItem({ id: 'mem_platform' })]) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.get({ + key: 'build_command', + scopes: [{ type: 'project', id: 'proj_1' }, { type: 'platform' }], + }) + expect(record?.record_id).toBe('mem_platform') + }) + + it('returns null when no scope has the key', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.get({ key: 'missing', scopes: [{ type: 'platform' }] }) + expect(record).toBeNull() + }) + + it('disambiguates by scope_id when scope.id is supplied', async () => { + mockFetch(() => jsonResponse([ + apItem({ id: 'mem_other', metadata: { agentmark_key: 'build_command', scope_id: '/repo/b' } }), + apItem({ id: 'mem_mine', metadata: { agentmark_key: 'build_command', scope_id: '/repo/a' } }), + ])) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const record = await backend.get({ + key: 'build_command', + scopes: [{ type: 'project', id: '/repo/a' }], + }) + expect(record?.record_id).toBe('mem_mine') + }) + + it('deleteByKey looks up + deletes', async () => { + mockFetch((_url, init) => { + if (init?.method === 'GET') return jsonResponse([apItem({ id: 'mem_to_delete' })]) + return jsonResponse({}) + }) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const ok = await backend.deleteByKey('build_command', { type: 'platform' }) + expect(ok).toBe(true) + expect(requests[1].init?.method).toBe('DELETE') + expect(requests[1].url).toContain('/memory/mem_to_delete') + }) + + it('deleteByKey returns false when no match', async () => { + mockFetch(() => jsonResponse([])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + expect(await backend.deleteByKey('missing', { type: 'platform' })).toBe(false) + }) +}) + +describe('ActivepiecesMemoryBackend — search', () => { + it('routes text queries to /memory/search with scope filter', async () => { + let captured: { body: unknown } | null = null + mockFetch((_url, init) => { + captured = { body: JSON.parse(init?.body as string) } + return jsonResponse([apItem({ id: 'mem_hit', similarity: 0.91 })]) + }) + + const backend = new ActivepiecesMemoryBackend(baseConfig) + const results = await backend.search({ + query: 'how do I build', + scope: { type: 'project' }, + limit: 25, + }) + expect(requests[0].url).toContain('/memory/search') + expect(captured?.body).toEqual({ query: 'how do I build', scope: 'project', limit: 25 }) + expect(results[0].record_id).toBe('mem_hit') + }) + + it('falls back to listing when no query is supplied', async () => { + mockFetch(() => jsonResponse([apItem()])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.search({ scope: { type: 'platform' }, limit: 10 }) + expect(requests[0].init?.method).toBe('GET') + expect(requests[0].url).toContain('/memory?') + }) + + it('filters search results client-side by tags', async () => { + mockFetch(() => jsonResponse([ + apItem({ id: 'mem_with', metadata: { agentmark_key: 'k1', tags: ['build', 'fast'] } }), + apItem({ id: 'mem_without', metadata: { agentmark_key: 'k2' } }), + ])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const results = await backend.search({ query: 'x', tags: ['build'] }) + expect(results).toHaveLength(1) + expect(results[0].record_id).toBe('mem_with') + }) +}) + +describe('ActivepiecesMemoryBackend — list', () => { + it('passes scope + source + limit; caps limit at 100', async () => { + mockFetch(() => jsonResponse([apItem()])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + await backend.list({ type: 'project', id: '/repo' }, undefined, 999) + const url = requests[0].url + expect(url).toContain('scope=project') + expect(url).toContain('source=agentmark') + expect(url).toContain('limit=100') + }) + + it('filters by prefix client-side', async () => { + mockFetch(() => jsonResponse([ + apItem({ id: 'a', metadata: { agentmark_key: 'build_a' } }), + apItem({ id: 'b', metadata: { agentmark_key: 'test_b' } }), + apItem({ id: 'c', metadata: { agentmark_key: 'build_c' } }), + ])) + const backend = new ActivepiecesMemoryBackend(baseConfig) + const results = await backend.list(undefined, 'build_') + expect(results.map((r) => r.key).sort()).toEqual(['build_a', 'build_c']) + }) +}) + +describe('ActivepiecesMemoryBackend — errors + describe', () => { + it('throws ActivepiecesMemoryError on non-2xx with status + body', async () => { + mockFetch(() => jsonResponse({ error: 'forbidden' }, 403)) + const backend = new ActivepiecesMemoryBackend(baseConfig) + try { + await backend.list() + throw new Error('should not reach') + } catch (err) { + expect(err).toBeInstanceOf(ActivepiecesMemoryError) + expect((err as ActivepiecesMemoryError).status).toBe(403) + expect((err as ActivepiecesMemoryError).body).toEqual({ error: 'forbidden' }) + } + }) + + it('deleteById returns false on 404 instead of throwing', async () => { + mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) + const backend = new ActivepiecesMemoryBackend(baseConfig) + expect(await backend.deleteById('mem_missing')).toBe(false) + }) + + it('describe reports kind=activepieces + project + chatbot + source', async () => { + const backend = new ActivepiecesMemoryBackend({ ...baseConfig, chatbotId: 'cb_x' }) + const desc = await backend.describe() + expect(desc).toEqual({ + kind: 'activepieces', + base_url: 'https://app.example.com', + project_id: 'proj_1', + chatbot_id: 'cb_x', + source: 'agentmark', + }) + }) +}) diff --git a/test/memory/remote-backend.test.ts b/test/memory/remote-backend.test.ts deleted file mode 100644 index 2799437..0000000 --- a/test/memory/remote-backend.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Tests for RemoteMemoryBackend — HTTP shape + auth + error handling. - * fetch is mocked; no real network calls. - */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { RemoteMemoryBackend, RemoteMemoryError } from '../../src/plugins/memory' - -let originalFetch: typeof globalThis.fetch -let requests: Array<{ url: string; init?: RequestInit }> - -beforeEach(() => { - originalFetch = globalThis.fetch - requests = [] -}) - -afterEach(() => { - globalThis.fetch = originalFetch - vi.restoreAllMocks() -}) - -function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : (input as URL | Request).toString() - requests.push({ url, init }) - return Promise.resolve(responder(url, init)) - }) as typeof globalThis.fetch -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -describe('RemoteMemoryBackend — request shape', () => { - it('attaches Bearer token + X-Workspace-Id when configured', async () => { - mockFetch(() => jsonResponse({ record_id: 'mem_x', key: 'k', value: 'v', scope: { type: 'platform' }, version: 1, created_at: '', updated_at: '', access_count: 0 })) - - const backend = new RemoteMemoryBackend({ - baseUrl: 'https://memory.example.com', - token: 'secret', - workspaceId: 'ws_123', - }) - await backend.set({ key: 'k', value: 'v' }) - - expect(requests).toHaveLength(1) - const headers = requests[0].init?.headers as Record - expect(headers.authorization).toBe('Bearer secret') - expect(headers['x-workspace-id']).toBe('ws_123') - expect(headers['content-type']).toBe('application/json') - }) - - it('strips trailing slashes from baseUrl', async () => { - mockFetch(() => jsonResponse(null)) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://memory.example.com///' }) - await backend.get({ key: 'k' }) - expect(requests[0].url).toBe('https://memory.example.com/v1/memory/records/get') - }) - - it('throws if baseUrl is missing', () => { - expect(() => new RemoteMemoryBackend({ baseUrl: '' })).toThrow(/baseUrl is required/) - }) -}) - -describe('RemoteMemoryBackend — verb mapping', () => { - it('set → POST /v1/memory/records', async () => { - mockFetch(() => jsonResponse({ record_id: 'r', key: 'k', value: 'v', scope: { type: 'platform' }, created_at: '', updated_at: '', access_count: 0 })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - await backend.set({ key: 'k', value: 'v', tags: ['t'], ttlSeconds: 60 }) - - expect(requests[0].url).toBe('https://x.com/v1/memory/records') - expect(requests[0].init?.method).toBe('POST') - const body = JSON.parse(requests[0].init?.body as string) - expect(body).toEqual({ - key: 'k', - value: 'v', - scope: { type: 'platform' }, - tags: ['t'], - ttl_seconds: 60, - }) - }) - - it('get → POST /v1/memory/records/get', async () => { - mockFetch(() => jsonResponse(null)) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - const result = await backend.get({ key: 'k' }) - expect(result).toBeNull() - expect(requests[0].url).toBe('https://x.com/v1/memory/records/get') - expect(requests[0].init?.method).toBe('POST') - }) - - it('deleteById → DELETE /v1/memory/records/:id', async () => { - mockFetch(() => jsonResponse({ deleted: true })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - const ok = await backend.deleteById('mem_abc') - expect(ok).toBe(true) - expect(requests[0].url).toBe('https://x.com/v1/memory/records/mem_abc') - expect(requests[0].init?.method).toBe('DELETE') - }) - - it('list builds the correct query string', async () => { - mockFetch(() => jsonResponse([])) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - await backend.list({ type: 'project', id: '/repo' }, 'build_', 25) - expect(requests[0].url).toBe('https://x.com/v1/memory/records?scope_type=project&scope_id=%2Frepo&prefix=build_&limit=25') - expect(requests[0].init?.method).toBe('GET') - }) - - it('search → POST /v1/memory/search', async () => { - mockFetch(() => jsonResponse([])) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - await backend.search({ query: 'foo', tags: ['build'] }) - expect(requests[0].url).toBe('https://x.com/v1/memory/search') - expect(JSON.parse(requests[0].init?.body as string)).toEqual({ query: 'foo', tags: ['build'] }) - }) -}) - -describe('RemoteMemoryBackend — error handling', () => { - it('throws RemoteMemoryError with status + body on non-2xx', async () => { - mockFetch(() => jsonResponse({ error: 'unauthorized' }, 401)) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com', token: 'bad' }) - try { - await backend.set({ key: 'k', value: 'v' }) - throw new Error('should not reach') - } catch (err) { - expect(err).toBeInstanceOf(RemoteMemoryError) - expect((err as RemoteMemoryError).status).toBe(401) - expect((err as RemoteMemoryError).body).toEqual({ error: 'unauthorized' }) - } - }) - - it('describe() returns "remote-http" kind even when the service has no /describe endpoint', async () => { - mockFetch(() => new Response('', { status: 404 })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com', workspaceId: 'ws_1' }) - const desc = await backend.describe() - expect(desc.kind).toBe('remote-http') - expect(desc.base_url).toBe('https://x.com') - expect(desc.workspace_id).toBe('ws_1') - }) - - it('describe() merges remote server-supplied fields when available', async () => { - mockFetch(() => jsonResponse({ total_records: 42, region: 'us-east-1' })) - const backend = new RemoteMemoryBackend({ baseUrl: 'https://x.com' }) - const desc = await backend.describe() - expect(desc.kind).toBe('remote-http') - expect(desc.total_records).toBe(42) - expect(desc.region).toBe('us-east-1') - }) -}) diff --git a/test/recipes/remote-backend.test.ts b/test/recipes/remote-backend.test.ts deleted file mode 100644 index 9db5761..0000000 --- a/test/recipes/remote-backend.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Tests for RemoteRecipeBackend — HTTP shape + 404 handling. - */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { RemoteRecipeBackend, RemoteRecipeError } from '../../src/plugins/recipes' -import type { Recipe } from '../../src/plugins/recipes' - -let originalFetch: typeof globalThis.fetch -let requests: Array<{ url: string; init?: RequestInit }> - -beforeEach(() => { - originalFetch = globalThis.fetch - requests = [] -}) - -afterEach(() => { - globalThis.fetch = originalFetch -}) - -function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : (input as URL | Request).toString() - requests.push({ url, init }) - return Promise.resolve(responder(url, init)) - }) as typeof globalThis.fetch -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -const SAMPLE: Recipe = { - name: 'test-recipe', - steps: [{ tool: 'agentmark_desktop_execute', args: {} }], - version: 1, - created_at: '', - updated_at: '', -} - -describe('RemoteRecipeBackend — verb mapping', () => { - it('save → POST /v1/recipes with on_conflict query param', async () => { - mockFetch(() => jsonResponse(SAMPLE)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - await backend.save(SAMPLE, { on_conflict: 'replace' }) - expect(requests[0].url).toBe('https://r.example.com/v1/recipes?on_conflict=replace') - expect(requests[0].init?.method).toBe('POST') - }) - - it('save without on_conflict defaults to "fail"', async () => { - mockFetch(() => jsonResponse(SAMPLE)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - await backend.save(SAMPLE) - expect(requests[0].url).toBe('https://r.example.com/v1/recipes?on_conflict=fail') - }) - - it('get → GET /v1/recipes/:name', async () => { - mockFetch(() => jsonResponse(SAMPLE)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const r = await backend.get('test-recipe') - expect(r?.name).toBe('test-recipe') - expect(requests[0].url).toBe('https://r.example.com/v1/recipes/test-recipe') - expect(requests[0].init?.method).toBe('GET') - }) - - it('list with target_app builds the query string', async () => { - mockFetch(() => jsonResponse([SAMPLE])) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - await backend.list({ target_app: 'excel' }) - expect(requests[0].url).toBe('https://r.example.com/v1/recipes?target_app=excel') - }) - - it('delete → DELETE /v1/recipes/:name', async () => { - mockFetch(() => jsonResponse({ deleted: true })) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const ok = await backend.delete('to-go') - expect(ok).toBe(true) - expect(requests[0].init?.method).toBe('DELETE') - }) -}) - -describe('RemoteRecipeBackend — 404 handling', () => { - it('get returns null on 404 (instead of throwing)', async () => { - mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const r = await backend.get('missing') - expect(r).toBeNull() - }) - - it('delete returns false on 404 (instead of throwing)', async () => { - mockFetch(() => jsonResponse({ error: 'not_found' }, 404)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - const ok = await backend.delete('missing') - expect(ok).toBe(false) - }) - - it('other 4xx/5xx throw RemoteRecipeError', async () => { - mockFetch(() => jsonResponse({ error: 'server_error' }, 500)) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }) - try { - await backend.get('boom') - throw new Error('should not reach') - } catch (err) { - expect(err).toBeInstanceOf(RemoteRecipeError) - expect((err as RemoteRecipeError).status).toBe(500) - } - }) -}) - -describe('RemoteRecipeBackend — describe', () => { - it('returns kind=remote-http with base_url + workspace_id', async () => { - mockFetch(() => new Response('', { status: 404 })) - const backend = new RemoteRecipeBackend({ baseUrl: 'https://r.example.com', workspaceId: 'ws_1' }) - const desc = await backend.describe() - expect(desc.kind).toBe('remote-http') - expect(desc.base_url).toBe('https://r.example.com') - expect(desc.workspace_id).toBe('ws_1') - }) -}) - -describe('Backend injection — plugin acceptance', () => { - it('createRecipesPlugin accepts a custom backend', async () => { - const { createRecipesPlugin } = await import('../../src/plugins/recipes') - const plugin = createRecipesPlugin({ - backend: new RemoteRecipeBackend({ baseUrl: 'https://r.example.com' }), - }) - expect(plugin.name).toBe('recipes') - // describeSessions reports "custom" when a non-LocalFile backend is used. - const info = plugin.describeSessions?.() - expect((info as { recipes: { kind: string } }).recipes.kind).toBe('custom') - }) -})