Files
dsh-recall/test/helpers.js
T
NotExpectedYet de4d020dcb feat: index extra source roots beside the workspace, for the code you write against
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.
2026-09-27 07:16:21 +01:00

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