The workspace index cannot answer questions about an installed framework or SDK, and in plugin development that is where most of the reading goes: 64 of 80 tool calls in one real session went to the harness checkout under /usr/local/lib/node_modules to look up the API being implemented. A root is entered whatever it is called, and its dependencies are not: a path ending in node_modules/... is the normal way to name a package tree, while a node_modules below the root is somebody else's code. A result carries the label that says which tree it came from and an absolute sourcePath, the only form a follow-up read can resolve. The tool description now also claims the move this replaces - searching before reading files to find out what a module contains.
130 lines
4.9 KiB
JavaScript
130 lines
4.9 KiB
JavaScript
/**
|
|
* 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, sessionId = 'session-under-test') {
|
|
// `agent.id` is the SESSION id — the jobs registry resolves a background job's
|
|
// owner through `agents.get(id)` and refuses anything that is not that string.
|
|
// A fixture without it cannot catch a call that passes the whole handle.
|
|
return {
|
|
signal: signal ?? new AbortController().signal,
|
|
agent: { id: sessionId, 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-'))
|
|
}
|