/** * Shared test scaffolding. * * The embedding stub is a bag-of-words hash rather than the real model, for two * reasons: the suite must run without a network, and a deterministic vector * makes a ranking assertion a statement about the ranking code rather than a * statement about a model that may change under it. Whether the real model * retrieves the right file is a different question, answered by * `scripts/smoke.mjs` against a live endpoint. */ import { createHash } from 'node:crypto' import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' /** Deterministic unit vector for a piece of text. */ export function hashEmbed(text, width) { const vector = new Float32Array(width) for (const token of String(text).toLowerCase().split(/[^a-z0-9]+/)) { if (token.length < 3) continue const digest = createHash('sha1').update(token).digest() const slot = ((digest[0] << 8) | digest[1]) % width vector[slot] += 1 } let sum = 0 for (let at = 0; at < width; at += 1) sum += vector[at] * vector[at] if (sum === 0) return vector const scale = 1 / Math.sqrt(sum) for (let at = 0; at < width; at += 1) vector[at] *= scale return vector } /** A `fetch` that answers the Ollama embedding API deterministically. */ export function makeStubFetch(options = {}) { const { width = 768, onCall, unsupportedBatch = false, generate = undefined } = options const calls = [] const impl = async (url, init) => { const address = String(url) const body = JSON.parse(init.body) calls.push({ url: address, body }) onCall?.(address, body) // The generation endpoint the expander and the re-ranker share. Left // unanswered unless a test supplies a response, so a caller that expects // the local model to be absent gets that for free. if (address.endsWith('/api/generate')) { if (generate === undefined) return { ok: false, status: 404, text: async () => 'no model', json: async () => ({}) } const response = typeof generate === 'function' ? generate(body) : generate return { ok: true, status: 200, json: async () => ({ response }) } } if (address.endsWith('/api/embed')) { if (unsupportedBatch) { return { ok: false, status: 404, text: async () => 'not found', json: async () => ({}) } } const inputs = body.input return { ok: true, status: 200, json: async () => ({ embeddings: inputs.map((text) => Array.from(hashEmbed(text, width))) }), } } if (address.endsWith('/api/embeddings')) { return { ok: true, status: 200, json: async () => ({ embedding: Array.from(hashEmbed(body.prompt, width)) }), } } return { ok: false, status: 404, text: async () => 'unknown endpoint', json: async () => ({}) } } impl.calls = calls return impl } /** * The smallest context shape `apply` actually touches. * * `services` mounts optional dependencies: `ctx.inject` invokes its callback * only when every named dependency is present, which is how the plugin decides * whether it can offer a settings namespace or a projection. Passing none * exercises the composition-config path every minimal profile takes. */ export function makeContext(options = {}) { const { services = {} } = options const tools = [] const cleanups = [] const ctx = { logger: () => ({ warn() {}, info() {}, error() {}, debug() {} }), effect: (fn) => { const dispose = fn() if (typeof dispose === 'function') cleanups.push(dispose) }, get: (name) => services[name], inject: (deps, callback) => { const missing = deps.filter((dep) => services[dep] === undefined) if (missing.length > 0) return callback({ ...ctx, ...Object.fromEntries(deps.map((dep) => [dep, services[dep]])) }) }, tools: { register: (tool) => tools.push(tool) }, } return { ctx, tools, dispose: () => cleanups.forEach((fn) => fn()) } } /** An `exec` argument for one tool call. */ export function makeExec(cwd, signal) { return { signal: signal ?? new AbortController().signal, agent: { session: { header: { cwd } } } } } /** Write a map of relative path to content into a fresh temporary directory. */ export async function makeWorkspace(files) { const root = await mkdtemp(join(tmpdir(), 'recall-test-')) for (const [path, content] of Object.entries(files)) { const absolute = join(root, path) await mkdir(dirname(absolute), { recursive: true }) await writeFile(absolute, content, 'utf8') } return root } /** A fresh temporary directory, for index state. */ export async function makeStateDir() { return mkdtemp(join(tmpdir(), 'recall-state-')) }