init
This commit is contained in:
commit
0964819cdc
34 files changed
+7912
No files matched your search
@@ -0,0 +1,690 @@
|
||||
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')
|
||||
})
|
||||
Reference in new issue
Block a user