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') - }) -})