691 lines
29 KiB
JavaScript
691 lines
29 KiB
JavaScript
import { strict as assert } from 'node:assert'
|
|
import { readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { test } from 'node:test'
|
|
|
|
import { apply } 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,
|
|
extensions: [],
|
|
exclude: [],
|
|
maxFiles: 500,
|
|
maxFileBytes: 1_048_576,
|
|
chunkLines: 48,
|
|
maxChunkLines: 80,
|
|
overlapLines: 8,
|
|
defaultLimit: 8,
|
|
maxLimit: 25,
|
|
perFileLimit: 2,
|
|
excerptLines: 40,
|
|
minSimilarity: 0.2,
|
|
syncBudgetMs: 20_000,
|
|
// These mirror the Config schema exactly. Cordis fills schema defaults
|
|
// before `apply`, and this object stands in for that validated config — so
|
|
// any key the code reads and this object omits arrives as `undefined`, which
|
|
// is how `AbortSignal.timeout(NaN)` came to throw inside the expander.
|
|
focus: '',
|
|
focusMarkers: [],
|
|
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 = {}) {
|
|
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()
|
|
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('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/)
|
|
})
|
|
|
|
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')
|
|
})
|