import { strict as assert } from 'node:assert' import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { test } from 'node:test' import { apply, Config, SETTING_KEYS, settingsSnapshot } from '../index.js' import { makeContext, makeExec, makeStateDir, makeStubFetch, makeWorkspace } from './helpers.js' /** * Validate a value against the tool's own declared output schema. * * This exists because the schema is `additionalProperties: false` and the host * enforces it before the model ever sees a result — so an internal field leaking * into the returned object is not a cosmetic extra, it is a failed call. The * check is deliberately written against the declared schema rather than a copy * of it, so the two cannot drift apart. */ function schemaErrors(schema, value, at = '$') { const errors = [] if (schema.oneOf !== undefined) { if (!schema.oneOf.some((branch) => schemaErrors(branch, value, at).length === 0)) { errors.push(`${at}: matched no oneOf branch`) } return errors } const type = schema.type if (type === 'object') { if (value === null || typeof value !== 'object' || Array.isArray(value)) { errors.push(`${at}: expected an object`) return errors } const properties = schema.properties ?? {} for (const [key, sub] of Object.entries(properties)) { if (sub.required === true && value[key] === undefined) errors.push(`${at}.${key}: required but missing`) } if (schema.additionalProperties === false) { for (const key of Object.keys(value)) { if (!(key in properties)) errors.push(`${at}.${key}: not declared, but additionalProperties is false`) } } for (const [key, sub] of Object.entries(properties)) { if (value[key] !== undefined) errors.push(...schemaErrors(sub, value[key], `${at}.${key}`)) } return errors } if (type === 'array') { if (!Array.isArray(value)) { errors.push(`${at}: expected an array`) return errors } value.forEach((item, index) => errors.push(...schemaErrors(schema.items ?? {}, item, `${at}[${index}]`))) return errors } if (type === 'json') return errors if (type === 'string' && typeof value !== 'string') errors.push(`${at}: expected a string`) if (type === 'boolean' && typeof value !== 'boolean') errors.push(`${at}: expected a boolean`) if (type === 'integer' && !Number.isInteger(value)) errors.push(`${at}: expected an integer`) if (type === 'number' && typeof value !== 'number') errors.push(`${at}: expected a number`) if (schema.enum !== undefined && !schema.enum.includes(value)) errors.push(`${at}: not one of ${schema.enum.join(', ')}`) return errors } const SESSION = `import { randomBytes } from 'node:crypto' const SESSION_TTL_MS = 1000 * 60 * 60 * 12 export interface SessionRecord { userId: string issuedAt: number expiresAt: number } export function issueSession(userId: string): SessionRecord { const now = Date.now() return { userId, issuedAt: now, expiresAt: now + SESSION_TTL_MS } } export function isSessionExpired(record: SessionRecord, now = Date.now()): boolean { return record.expiresAt <= now } export function rotateSessionToken(record: SessionRecord): string { if (isSessionExpired(record)) throw new Error('cannot rotate an expired session') return randomBytes(32).toString('hex') } export function sessionTimeRemaining(record: SessionRecord, now = Date.now()): number { return Math.max(0, record.expiresAt - now) } export function describeSession(record: SessionRecord): string { const remaining = sessionTimeRemaining(record) return remaining === 0 ? 'expired' : 'active for ' + remaining + 'ms' } ` const FORECAST = `export interface ForecastDay { date: string minC: number maxC: number rainMm: number } export function averageTemperature(days: ForecastDay[]): number { if (days.length === 0) return 0 return days.reduce((sum, day) => sum + (day.minC + day.maxC) / 2, 0) / days.length } export function totalRainfall(days: ForecastDay[]): number { return days.reduce((sum, day) => sum + day.rainMm, 0) } ` const SQLITE = `import { DatabaseSync } from 'node:sqlite' export function openDatabase(path: string): DatabaseSync { const db = new DatabaseSync(path) db.exec('PRAGMA journal_mode = WAL') return db } ` const FILES = { 'src/auth/session.ts': SESSION, 'src/weather/forecast.ts': FORECAST, 'src/store/sqlite.ts': SQLITE, } function configFor(stateDir, extra = {}) { return { embeddingApiBase: 'http://stub', embeddingModel: 'test-model', embeddingDimensions: 768, embeddingApiKey: '', embeddingBatchSize: 32, embeddingConcurrency: 2, embeddingTimeoutMs: 5_000, embeddingKeepAlive: '5m', extensions: [], exclude: [], maxFiles: 500, maxFileBytes: 1_048_576, chunkLines: 48, maxChunkLines: 80, overlapLines: 8, defaultLimit: 8, maxLimit: 25, perFileLimit: 2, excerptLines: 40, totalExcerptLines: 400, minSimilarity: 0.2, syncBudgetMs: 20_000, // This stands in for the config Cordis hands `apply` after schema // resolution: every declared field present, defaults filled. A key this // object omits arrives as `undefined` in the plugin — which is how // `AbortSignal.timeout(NaN)` once came to throw inside the expander, and how // the use-time `validateSettings` guard below came to reject a whole call. // `configFor covers exactly the declared settings` in plugin.test.js keeps // the two lists from drifting apart again. focus: '', focusMarkers: [], sourceRoots: [], expansionEnabled: false, expansionModel: '', expansionCount: 4, expansionTimeoutMs: 8_000, rerankEnabled: false, rerankModel: '', rerankCandidates: 8, rerankTimeoutMs: 15_000, stateDir, ...extra, } } /** Apply the plugin to a fresh workspace with a stubbed embedding endpoint. */ async function setup(t, files = FILES, extra = {}, stubOptions = {}, services = {}) { const workspace = await makeWorkspace(files) const stateDir = await makeStateDir() const stub = makeStubFetch(stubOptions) const originalFetch = globalThis.fetch globalThis.fetch = stub t.after(async () => { globalThis.fetch = originalFetch await rm(workspace, { recursive: true, force: true }) await rm(stateDir, { recursive: true, force: true }) }) const { ctx, tools, dispose } = makeContext({ services }) t.after(() => dispose()) apply(ctx, configFor(stateDir, extra)) assert.equal(tools.length, 1, 'exactly one tool should be registered') return { tool: tools[0], workspace, stub } } test('the plugin registers one tool whose declared schema is closed', async (t) => { const { tool } = await setup(t) assert.equal(tool.name, 'recall') assert.equal(tool.output.schema.additionalProperties, false) // defineTool normalises the parameter DSL into a JSON Schema object. assert.deepEqual(tool.parameters.properties.action.enum, ['search', 'index', 'status', 'configure']) assert.equal(tool.parameters.properties.query.type, 'string') // A long index must not run in parallel with sibling tool calls. assert.equal(tool.isConcurrencySafe({ action: 'search' }), true) assert.equal(tool.isConcurrencySafe({ action: 'index' }), false) }) test('configFor covers exactly the declared settings', () => { // `configFor` stands in for the schema-resolved config Cordis hands `apply`. // If it drifts from the schema, the plugin reads `undefined` where a default // belongs — and `validateSettings`, which now runs on every call, turns that // into a refused call rather than a confusing one. Deriving the list from the // real schema is what makes that impossible to miss. const resolved = settingsSnapshot(Config({})) const fixture = configFor('/tmp/state') assert.deepEqual( Object.keys(fixture).sort(), SETTING_KEYS.slice().sort(), 'configFor must name every declared setting and nothing else', ) assert.deepEqual( Object.keys(resolved).sort(), SETTING_KEYS.slice().sort(), 'SETTING_KEYS must name every field the schema declares', ) }) test('a configured section that cannot be acted on is refused with a remedy', async (t) => { // The Host validates each field against Config; the cross-field rules have no // schema to live in, so `execute` checks them before putting the section to // work. The alternative — acting on it — is an unexplained empty search. const { tool, workspace } = await setup(t, FILES, { defaultLimit: 50, maxLimit: 10 }) const value = await tool.execute({ query: 'session' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, false) assert.equal(value.errorCode, 'INVALID_SETTINGS') assert.match(value.note, /defaultLimit \(50\) cannot exceed maxLimit \(10\)/) assert.match(value.remedy, /Settings/) }) test('a search returns the right file, as an excerpt rather than a whole file', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute( { query: 'how long until a user session token expires' }, makeExec(workspace), ) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, true) assert.equal(value.results.length > 0, true) assert.equal(value.results[0].path, 'src/auth/session.ts') // The excerpt must be a real slice of the file on disk, not paraphrase. const onDisk = await readFile(join(workspace, 'src/auth/session.ts'), 'utf8') assert.ok(onDisk.includes(value.results[0].excerpt), 'the excerpt must come from the file') assert.ok( value.results[0].excerpt.split('\n').length <= 40, 'the excerpt must respect the configured line cap', ) assert.ok( value.results[0].excerpt.length < onDisk.length, 'the point of the tool is to return less than the whole file', ) }) test('a second search re-embeds nothing, because the index is incremental', async (t) => { const { tool, workspace, stub } = await setup(t) await tool.execute({ query: 'session expiry' }, makeExec(workspace)) const afterFirst = stub.calls.length assert.ok(afterFirst > 1, 'the first search should have embedded the workspace') await tool.execute({ query: 'rainfall totals' }, makeExec(workspace)) const second = stub.calls.slice(afterFirst) // Exactly one request: the query vector. A warm index embeds no files. assert.equal(second.length, 1, `expected only a query embed, saw ${second.length} requests`) }) test('an edited file is re-embedded on the next search', async (t) => { const { tool, workspace, stub } = await setup(t) await tool.execute({ query: 'session expiry' }, makeExec(workspace)) const afterFirst = stub.calls.length await writeFile( join(workspace, 'src/weather/forecast.ts'), `${FORECAST}\nexport function humidityPercent(days: ForecastDay[]): number {\n return days.length\n}\n`, 'utf8', ) await tool.execute({ query: 'humidityPercent' }, makeExec(workspace)) const second = stub.calls.slice(afterFirst) assert.ok(second.length >= 2, 'the changed file plus the query should both be embedded') assert.deepEqual(second[second.length - 1].body.input, ['humidityPercent']) }) test('a spent sync budget leaves the rest pending rather than lying about it', async (t) => { const { tool, workspace } = await setup(t, FILES, { syncBudgetMs: 0 }) // First search indexes one file, then the budget stops it. const first = await tool.execute({ query: 'session expiry' }, makeExec(workspace)) assert.ok(first.index.pending >= 1, `expected pending work, saw ${first.index.pending}`) // A full index action catches up. const full = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, full), []) assert.equal(full.index.pending, 0) const third = await tool.execute({ query: 'session expiry' }, makeExec(workspace)) assert.equal(third.index.pending, 0) }) test('the index action builds the whole workspace', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, true) assert.equal(value.index.files, 3) assert.ok(value.index.chunks >= 3) }) test('status probes the endpoint once and reports it healthy', async (t) => { const { tool, workspace, stub } = await setup(t) const value = await tool.execute({ action: 'status' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, true) // Exactly one request: the probe. Status must not index or search. assert.equal(stub.calls.length, 1, `status should probe once, saw ${stub.calls.length}`) assert.equal(value.endpoint.ok, true) assert.match(value.note, /endpoint answered/) }) test('status reports an endpoint that accepts the connection and never answers', async (t) => { // The failure mode that a plain reachability check misses, and the one that // actually happened in development: the socket opens, no bytes ever come back. const workspace = await makeWorkspace(FILES) const stateDir = await makeStateDir() const originalFetch = globalThis.fetch globalThis.fetch = (url, init) => new Promise((_resolve, reject) => { init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }) }) t.after(async () => { globalThis.fetch = originalFetch await rm(workspace, { recursive: true, force: true }) await rm(stateDir, { recursive: true, force: true }) }) const { ctx, tools, dispose } = makeContext() t.after(() => dispose()) // A short timeout keeps the probe honest about how long it will wait. apply(ctx, configFor(stateDir, { embeddingTimeoutMs: 1_000 })) const value = await tools[0].execute({ action: 'status' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tools[0].output.schema, value), []) assert.equal(value.ok, true, 'status itself succeeded; it is reporting a fault, not failing') assert.equal(value.endpoint.ok, false) assert.equal(value.endpoint.errorCode, 'UNREACHABLE') assert.match(value.note, /did NOT answer/) }) test('a search without a query is refused with advice', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute({}, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, false) assert.equal(value.errorCode, 'NO_QUERY') assert.ok(value.remedy !== undefined) }) test('a path filter restricts results to that directory', async (t) => { const { tool, workspace } = await setup(t) await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) const value = await tool.execute( { query: 'function', path: 'src/weather' }, makeExec(workspace), ) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.ok(value.results.length > 0) for (const result of value.results) assert.match(result.path, /^src\/weather\//) }) test('configure reports the missing settings provider instead of pretending to save', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute( { action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } }, makeExec(workspace), ) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, false) assert.equal(value.errorCode, 'NO_SETTINGS_PROVIDER') assert.match(value.remedy, /cordis layer/) }) test('configure rejects an unknown setting by name', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute( { action: 'configure', config: { nonsense: 1 } }, makeExec(workspace), ) assert.equal(value.errorCode, 'UNKNOWN_SETTING') assert.match(value.note, /nonsense/) }) /** * Mount the plugin with a settings provider, and record what it is asked to do. * * In 0.1.7 the namespace is the profile ENTRY id, and the revision the write is * fenced with comes from `describe()` — so both are asserted rather than * assumed, because getting either wrong writes to a row that does not exist or * silently overwrites a concurrent edit. */ async function setupWithSettings(t, extra = {}, behaviour = {}) { const workspace = await makeWorkspace(FILES) const stateDir = await makeStateDir() const calls = { update: [], describe: 0 } globalThis.fetch = makeStubFetch({}) t.after(async () => { await rm(workspace, { recursive: true, force: true }) await rm(stateDir, { recursive: true, force: true }) }) const settings = { describe: () => { calls.describe += 1 return [ { ns: 'some-other-plugin', revision: 3 }, { ns: 'dsh-recall', revision: 7 }, ] }, update: async (ns, patch, revision) => { calls.update.push({ ns, patch, revision }) if (behaviour.reject === true) throw new Error('settings/rejected') }, } const { ctx, tools, dispose } = makeContext({ services: { settings } }) t.after(() => dispose()) apply(ctx, configFor(stateDir, extra)) return { tool: tools[0], workspace, calls } } test('configure writes to the profile entry id, fenced by the revision describe reported', async (t) => { const { tool, workspace, calls } = await setupWithSettings(t) const value = await tool.execute( { action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } }, makeExec(workspace), ) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, true) assert.match(value.note, /embeddingApiBase/) assert.deepEqual(calls.update, [ { ns: 'dsh-recall', patch: { embeddingApiBase: 'http://elsewhere:11434' }, revision: 7 }, ]) }) test('configure judges the section a patch would leave, and writes nothing it refuses', async (t) => { // Raising `defaultLimit` alone is valid; raising it past `maxLimit` is not, // and the contradiction is invisible to either field's schema. const { tool, workspace, calls } = await setupWithSettings(t) const value = await tool.execute( { action: 'configure', config: { defaultLimit: 99 } }, makeExec(workspace), ) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, false) assert.equal(value.errorCode, 'INVALID_SETTING') assert.match(value.note, /defaultLimit \(99\) cannot exceed maxLimit \(25\)/) assert.deepEqual(calls.update, [], 'a refused patch must not reach the document') }) test('a settings write the document refuses is reported, not thrown', async (t) => { const { tool, workspace } = await setupWithSettings(t, {}, { reject: true }) const value = await tool.execute( { action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } }, makeExec(workspace), ) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, false) assert.equal(value.errorCode, 'SETTINGS_WRITE_FAILED') assert.match(value.remedy, /concurrent/) }) test('reading settings does not need a settings provider, because volatile fields are live', async (t) => { // The 0.1.5 shape attached a reader to the settings provider; 0.1.7 does not, // so `apply` must not discover configured values only when one is mounted. const { tool, workspace } = await setupWithSettings(t, { defaultLimit: 3 }) const value = await tool.execute({ query: 'session token' }, makeExec(workspace)) assert.equal(value.ok, true) assert.ok(value.results.length <= 3, 'the configured limit must be honoured without a provider') }) test('an unreachable endpoint fails with a remedy, not a stack trace', async (t) => { const workspace = await makeWorkspace(FILES) const stateDir = await makeStateDir() const originalFetch = globalThis.fetch // A persistent 5xx, which the client retries and then reports as unreachable. globalThis.fetch = async () => ({ ok: false, status: 500, text: async () => 'boom', json: async () => ({}) }) t.after(async () => { globalThis.fetch = originalFetch await rm(workspace, { recursive: true, force: true }) await rm(stateDir, { recursive: true, force: true }) }) const { ctx, tools, dispose } = makeContext() t.after(() => dispose()) apply(ctx, configFor(stateDir)) const value = await tools[0].execute({ query: 'anything at all' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tools[0].output.schema, value), []) assert.equal(value.ok, false) assert.equal(value.errorCode, 'UNREACHABLE') assert.match(value.remedy, /embeddingApiBase/) }) test('a workspace with no indexable code says so plainly', async (t) => { const { tool, workspace } = await setup(t, { 'README.md': '# prose only\n' }) const value = await tool.execute({ query: 'anything' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.ok, true) assert.deepEqual(value.results, []) assert.match(value.note, /Nothing is indexed/) }) test('the rendered result is compact and names the file and line range', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute({ query: 'session token rotation' }, makeExec(workspace)) const blocks = tool.output.render({ query: 'session token rotation' }, value) assert.equal(blocks.length, 1) assert.equal(blocks[0].type, 'text') assert.match(blocks[0].text, /src\/auth\/session\.ts:\d+-\d+/) // It must not dump the whole file into the conversation. assert.ok(blocks[0].text.length < SESSION.length * 3) }) test('an empty result tells the model to prefer grep', async (t) => { const { tool, workspace } = await setup(t) await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) const value = await tool.execute( { query: 'zzzz-nothing-matches-this-zzzz', path: 'src/store' }, makeExec(workspace), ) const blocks = tool.output.render({ query: 'zzzz' }, value) if (value.results.length === 0) { assert.match(blocks[0].text, /grep/) } else { assert.equal(value.weak, true) assert.match(blocks[0].text, /No strong match/) } }) test('focus narrows the index to one directory, and paths stay workspace-relative', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute( { action: 'index', focus: 'src/auth', run_in_background: false }, makeExec(workspace), ) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.focus.path, 'src/auth') assert.equal(value.focus.source, 'explicit') assert.equal(value.index.files, 1, 'only src/auth should be indexed') const search = await tool.execute({ query: 'session expiry', focus: 'src/auth' }, makeExec(workspace)) assert.ok(search.results.length > 0) for (const result of search.results) { // The critical invariant: a result path must be usable with `read`, which // resolves against the workspace. A focus-relative path here would read the // wrong file. assert.match(result.path, /^src\/auth\//, `path was ${result.path}`) const onDisk = await readFile(join(workspace, result.path), 'utf8') assert.ok(onDisk.includes(result.excerpt), `${result.path} should resolve from the workspace root`) } }) test('each focus keeps its own index', async (t) => { const { tool, workspace } = await setup(t) const auth = await tool.execute({ action: 'index', focus: 'src/auth', run_in_background: false }, makeExec(workspace)) const weather = await tool.execute({ action: 'index', focus: 'src/weather', run_in_background: false }, makeExec(workspace)) assert.equal(auth.index.files, 1) assert.equal(weather.index.files, 1) assert.notEqual(auth.index.database, weather.index.database, 'focuses must not share an index file') assert.notEqual(auth.index.database, (await tool.execute({ action: 'status' }, makeExec(workspace))).index.database) }) test('a search in one focus cannot see another focus', async (t) => { const { tool, workspace } = await setup(t) await tool.execute({ action: 'index', focus: 'src/weather', run_in_background: false }, makeExec(workspace)) // A tolerant query that would match the auth module if it were in scope. const value = await tool.execute({ query: 'issueSession session expiry', focus: 'src/weather' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) for (const result of value.results) assert.match(result.path, /^src\/weather\//) }) test('an unusable focus is reported and falls back, never silently obeyed', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute( { action: 'index', focus: 'src/does-not-exist', run_in_background: false }, makeExec(workspace), ) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.focus.path, '.', 'must fall back to the workspace') assert.match(value.focus.problem, /not a directory/) assert.equal(value.index.files, 3, 'the fallback index covers the whole workspace') }) test('status reports which focus is in effect, and why', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute({ action: 'status' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.focus.path, '.') // This fixture has no AGENTS.md, so resolution reaches the final fallback. assert.equal(value.focus.source, 'workspace') assert.match(value.note, /scoped to the whole workspace/) }) test('a session at the workspace root keeps the whole workspace, even with nested scopes', async (t) => { // Inheritance and discovery are different questions. The root's own AGENTS.md // is what contains this session; the controller below is merely available. // Auto-adopting it would hide the other two thirds of the workspace. const { tool, workspace } = await setup(t, { 'AGENTS.md': '# repo instructions\n', 'src/auth/CONTROLLER.md': '# the auth controller\n', ...FILES, }) const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.focus.path, '.') assert.equal(value.focus.source, 'marker') assert.equal(value.focus.marker, 'AGENTS.md') assert.equal(value.index.files, 3) }) test('discovered scopes are listed, so a caller can name one', async (t) => { const { tool, workspace } = await setup(t, { 'src/auth/CONTROLLER.md': '# the auth controller\n', 'src/weather/CONTROLLER.md': '# the weather controller\n', ...FILES, }) const value = await tool.execute({ action: 'status' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.deepEqual(value.scopes, [ { path: 'src/auth', marker: 'CONTROLLER.md' }, { path: 'src/weather', marker: 'CONTROLLER.md' }, ]) assert.match(value.note, /Available scopes: src\/auth, src\/weather/) }) test('a workspace with no markers says so rather than listing nothing', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute({ action: 'status' }, makeExec(workspace)) assert.deepEqual(value.scopes, []) assert.match(value.note, /No focusable scopes/) }) test('a scope found by discovery indexes only itself when named', async (t) => { const { tool, workspace } = await setup(t, { 'src/auth/CONTROLLER.md': '# the auth controller\n', ...FILES, }) const listed = await tool.execute({ action: 'status' }, makeExec(workspace)) const [scope] = listed.scopes const value = await tool.execute({ action: 'index', focus: scope.path, run_in_background: false }, makeExec(workspace)) assert.equal(value.focus.source, 'explicit') assert.equal(value.index.files, 1, 'naming a discovered scope must confine the index to it') }) test('the rendered search header names the scope when there is one', async (t) => { const { tool, workspace } = await setup(t) await tool.execute({ action: 'index', focus: 'src/auth', run_in_background: false }, makeExec(workspace)) const value = await tool.execute({ query: 'session', focus: 'src/auth' }, makeExec(workspace)) const blocks = tool.output.render({ query: 'session' }, value) assert.match(blocks[0].text, /in src\/auth/) }) test('the tool records what it saved, for the session log to carry', async (t) => { const { tool, workspace } = await setup(t) await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) const value = await tool.execute({ query: 'session expiry' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) // The comparison is against the whole of each file the excerpts came from. assert.ok(value.measure.sourceBytes > 0) assert.ok( value.measure.sourceBytes > value.measure.returnedBytes, `expected a saving, got ${value.measure.returnedBytes} of ${value.measure.sourceBytes}`, ) assert.ok(value.measure.files >= 1) const meta = tool.output.presentationMeta({ query: 'session expiry' }, value) assert.equal(meta.recall.action, 'search') assert.equal(meta.recall.excerpts, value.results.length) assert.equal(meta.recall.sourceBytes, value.measure.sourceBytes) assert.equal(meta.recall.returnedBytes, value.measure.returnedBytes) // Pure: replayed on projection rebuilds, so it may not vary between calls. assert.deepEqual(tool.output.presentationMeta({ query: 'session expiry' }, value), meta) // And JSON-safe, because this is what actually lands in the log. assert.deepEqual(JSON.parse(JSON.stringify(meta)), meta) }) test('an index call records differently from a search', async (t) => { const { tool, workspace } = await setup(t) const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) const meta = tool.output.presentationMeta({ action: 'index' }, value) assert.equal(meta.recall.action, 'index') assert.equal(meta.recall.excerpts, undefined, 'indexing returns no excerpts to account for') }) test('a failed search still leaves a record worth folding', async (t) => { const workspace = await makeWorkspace(FILES) const stateDir = await makeStateDir() const originalFetch = globalThis.fetch globalThis.fetch = async () => ({ ok: false, status: 500, text: async () => 'boom', json: async () => ({}) }) t.after(async () => { globalThis.fetch = originalFetch await rm(workspace, { recursive: true, force: true }) await rm(stateDir, { recursive: true, force: true }) }) const { ctx, tools, dispose } = makeContext() t.after(() => dispose()) apply(ctx, configFor(stateDir)) const value = await tools[0].execute({ query: 'anything' }, makeExec(workspace)) const meta = tools[0].output.presentationMeta({ query: 'anything' }, value) assert.equal(meta.recall.failed, 1, 'a failure should be counted, not silently dropped') }) test('the usage projection is registered when the service is mounted', async (t) => { const registered = [] const { ctx, tools, dispose } = makeContext({ services: { sessionProjections: { register: (unit) => registered.push(unit) } }, }) t.after(() => dispose()) apply(ctx, configFor('/tmp/dsh-recall-projection-test')) assert.equal(tools.length, 1, 'the tool registers regardless') assert.equal(registered.length, 1, 'exactly one projection unit') assert.equal(registered[0].key, 'recallUsage', 'the key the client reads') assert.equal(typeof registered[0].apply, 'function') assert.equal(typeof registered[0].wire.view, 'function', 'the unit must be client-visible') assert.equal(typeof registered[0].wire.viewSchema.parse, 'function') }) test('a profile without projections still gets a working tool', async (t) => { // The projection is an optional surface, not a dependency of retrieval: a // composition that mounts no projection service must lose the reporting and // nothing else. const { ctx, tools, dispose } = makeContext() t.after(() => dispose()) apply(ctx, configFor('/tmp/dsh-recall-no-projection')) assert.equal(tools.length, 1) assert.equal(tools[0].name, 'recall') }) test('a projection that refuses to register is reported, not fatal', async (t) => { const { ctx, tools, dispose } = makeContext({ services: { sessionProjections: { register: () => { throw new Error('duplicate key') }, }, }, }) t.after(() => dispose()) // The warning is logged rather than thrown: a conflicting unit must not take // the whole plugin, and therefore retrieval, down with it. assert.doesNotThrow(() => apply(ctx, configFor('/tmp/dsh-recall-bad-projection'))) assert.equal(tools.length, 1) }) test('expansion is off by default and changes nothing', async (t) => { const { tool, workspace, stub } = await setup(t) await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) const value = await tool.execute({ query: 'session expiry' }, makeExec(workspace)) assert.ok(value.results.length > 0) assert.equal( stub.calls.filter((call) => call.url.endsWith('/api/generate')).length, 0, 'the local model must not be called unless the setting is on', ) }) test('with expansion on and no model answering, the search still runs', async (t) => { // The stub only answers /api/generate when a test supplies a response, so this // is the wedged-endpoint case: expansion must degrade, not fail the search. const { tool, workspace } = await setup(t, FILES, { expansionEnabled: true, expansionModel: 'local-small' }) await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) const value = await tool.execute({ query: 'session expiry' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.ok(value.results.length > 0, 'a failed expansion must not fail the search') }) test('expansion asks the model once and still returns results', async (t) => { const { tool, workspace, stub } = await setup( t, FILES, { expansionEnabled: true, expansionModel: 'local-small', expansionCount: 3 }, { generate: 'issueSession\nsession expiry check' }, ) await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) const value = await tool.execute({ query: 'session expiry' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.ok(value.results.length > 0) const generations = stub.calls.filter((call) => call.url.endsWith('/api/generate')) assert.equal(generations.length, 1, 'one generation for the query, not one per variant') assert.match(generations[0].body.prompt, /session expiry/) assert.equal(generations[0].body.model, 'local-small', 'the configured expansion model is used') }) test('re-ranking degrades to the fused order when the model has no opinion', async (t) => { const { tool, workspace } = await setup(t, FILES, { rerankEnabled: true, rerankModel: 'local-small' }) await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) const plain = await tool.execute({ query: 'session expiry' }, makeExec(workspace)) const reranked = await tool.execute({ query: 'session expiry' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, reranked), []) // No usable scores means the ranking is untouched rather than emptied. assert.deepEqual( reranked.results.map((result) => result.path), plain.results.map((result) => result.path), ) assert.ok(reranked.results.length > 0, 'no opinion must not mean no results') }) test('a background index hands the jobs registry a session id, not the agent handle', async (t) => { // The jobs registry resolves `spec.owner` through `agents.get(owner)` and // throws `session "[object Object]" has no live agent` for anything that is not // the session id string. This test is a stand-in for that check: the bug it // catches was invisible in-process and made the DEFAULT `index` call fail on a // real deployment, where a throwing starter leaves the caller no job at all. const started = [] const services = { jobs: { start(spec) { started.push(spec) return `recall-index-${started.length}` }, }, } const { tool, workspace } = await setup(t, FILES, {}, {}, services) const value = await tool.execute({ action: 'index' }, makeExec(workspace, undefined, 'session-42')) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.jobId, 'recall-index-1') assert.equal(started.length, 1) assert.equal( started[0].owner, 'session-42', 'owner must be the session id: the registry refuses an Agent handle outright', ) assert.equal(typeof started[0].run, 'function') // And the job the registry was handed actually indexes the workspace. const outcome = await started[0].run().done assert.equal(outcome.status, 'completed') assert.match(outcome.output, /Indexed/) const search = await tool.execute({ query: 'session expiry' }, makeExec(workspace)) assert.ok(search.results.length > 0, 'the background job must leave a searchable index behind') }) /** * A workspace with a library tree beside it, laid out the way a real one is. * * The library is a *scoped packages directory* — its own path runs through * `node_modules` — because that is the shape the workspace walk exists to skip * and the shape this feature has to enter. `root` is the parent of both trees, so * the configured root can be given as a relative path and stays portable. * * @returns {Promise<{root: string, cleanup: () => Promise}>} */ async function makeRootPair() { const root = await makeWorkspace({}) const workspace = join(root, 'My Project') const library = join(root, 'packages', 'node_modules', '@vendor') await mkdir(join(library, 'widget', 'lib'), { recursive: true }) await mkdir(join(library, 'widget', 'node_modules', 'dep'), { recursive: true }) await mkdir(workspace, { recursive: true }) await writeFile(join(workspace, 'app.ts'), 'export const app = 1\n') await writeFile( join(library, 'widget', 'lib', 'index.js'), 'export function mountWidget(root, options) { return { root, options } }\n', ) await writeFile( join(library, 'widget', 'node_modules', 'dep', 'index.js'), 'export const vendored = "should never be indexed"\n', ) return { root, workspace, library, cleanup: () => rm(root, { recursive: true, force: true }) } } /** Apply the plugin to a workspace the caller made itself. */ async function setupAt(t, workspace, extra = {}) { const stateDir = await makeStateDir() const stub = makeStubFetch() const originalFetch = globalThis.fetch globalThis.fetch = stub t.after(async () => { globalThis.fetch = originalFetch await rm(stateDir, { recursive: true, force: true }) }) const { ctx, tools, dispose } = makeContext() t.after(() => dispose()) apply(ctx, configFor(stateDir, extra)) return { tool: tools[0], workspace, stub } } test('an extra root is indexed beside the workspace, and its dependencies are not', async (t) => { const { root, workspace, library } = await makeRootPair() t.after(async () => rm(root, { recursive: true, force: true })) // Relative to the workspace, and named, because the naming is what makes a // result say which tree it came from. const { tool } = await setupAt(t, workspace, { sourceRoots: ['vendor=../packages/node_modules/@vendor'] }) const index = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, index), []) assert.deepEqual(index.index.sourceRoots, [{ name: 'vendor', path: library, files: 1 }]) assert.equal(index.index.files, 2, 'the workspace file and the library file, and nothing vendored') const search = await tool.execute({ query: 'mountWidget root options' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, search), []) const hit = search.results.find((result) => result.path.startsWith('vendor/')) assert.ok(hit, `no library result in ${search.results.map((result) => result.path).join(', ')}`) // The label names the tree; the absolute path is the only thing `read` can use, // because the label is relative to the root rather than the workspace. assert.equal(hit.path, 'vendor/widget/lib/index.js') assert.equal(hit.sourcePath, join(library, 'widget', 'lib', 'index.js')) // A workspace result is unchanged: its own path already resolves. const own = search.results.find((result) => result.path === 'app.ts') if (own !== undefined) assert.equal(own.sourcePath, undefined) // The nested dependency is absent from the index entirely, not merely unranked. assert.equal( search.results.some((result) => result.path.includes('node_modules')), false, ) }) test('an unusable source root is reported and never costs the workspace index', async (t) => { const { root, workspace } = await makeRootPair() t.after(async () => rm(root, { recursive: true, force: true })) const { tool } = await setupAt(t, workspace, { sourceRoots: ['ghost=../nowhere', 'vendor=../packages/node_modules/@vendor'] }) const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) // The good root still indexed, and the bad one is named rather than swallowed. assert.deepEqual(value.index.sourceRoots.map((entry) => entry.name), ['vendor']) assert.equal(value.sourceRootProblems.length, 1) assert.match(value.sourceRootProblems[0], /nowhere/) assert.ok(value.index.files >= 2, 'the workspace and the surviving root must both be indexed') const status = await tool.execute({ action: 'status' }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, status), []) assert.equal(status.sourceRootProblems.length, 1) assert.ok(status.results === undefined, 'status returns no results') }) test('a source root whose path is a file, not a directory, is refused', async (t) => { const { root, workspace } = await makeRootPair() t.after(async () => rm(root, { recursive: true, force: true })) const { tool } = await setupAt(t, workspace, { sourceRoots: ['app=./app.ts'] }) const value = await tool.execute({ action: 'index', run_in_background: false }, makeExec(workspace)) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.index.sourceRoots.length, 0) assert.match(value.sourceRootProblems[0], /not a directory/) }) test('a source root named after the focus path is refused as a collision', async (t) => { // Two different files would be indexed under one key, the second write // replacing the first — so this cannot be left to the walk to get right. const root = await makeWorkspace({ 'vendor/app.ts': 'export const a = 1\n', 'lib/app.ts': 'export const b = 2\n' }) t.after(() => rm(root, { recursive: true, force: true })) const { tool } = await setupAt(t, root, { sourceRoots: ['vendor=vendor'] }) const value = await tool.execute( { action: 'index', run_in_background: false, focus: 'vendor' }, makeExec(root), ) assert.deepEqual(schemaErrors(tool.output.schema, value), []) assert.equal(value.index.sourceRoots.length, 0) assert.match(value.sourceRootProblems[0], /collides with the focus path/) })