Files
dsh-recall/test/plugin.test.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

972 lines
43 KiB
JavaScript

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<void>}>}
*/
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/)
})