The configuration page registered into seats that 0.1.7 removed, so it could
not appear at all — and the schema carried none of the metadata the new
settings document requires.
Host half. `ctx.settings.installSection` no longer exists: a plugin exports a
`Config` schema, the Harness keys one form per composed entry by that row's
`id`, and `ctx.settings.describe` skips an entry with no `volatile()` field
outright. Every field is now volatile, which is also what makes a saved edit
apply to the running instance instead of remounting it — so `apply` reads the
section through `settingsSnapshot`, which unwraps the live references a
volatile field arrives as. Writes go through `ctx.settings.update` fenced by
the revision `describe` reports. The `.min`/`.max` bounds the schema can state
are stated there, so the form refuses an impossible draft in front of the
person typing it; `validateSettings` keeps the cross-field rules no single
field can express, and now runs on the section a call is about to act on
rather than at write time, because 0.1.7 has no write-time validator hook.
Browser half. `settingsScope` and the `settings.plugin.item` slot are gone. The
page renders through `plugins.row.config`, keyed `<bundle package>#<row id>`
(`dsh-recall#dsh-recall`), which is what puts a Configure control on the
plugin's row in the Plugins panel. It uses the framework's own staged form —
`SettingsFormModel` over `ctx.configForms`, drawn by `SettingsForm` and
`SettingsValueField`/`SettingsSecretField` — instead of reproducing card chrome
by hand, so the unsaved, overridden, invalid, and save states come from the
same code as every other plugin's page. All 33 settings are editable now; the
old card exposed 23. The right-sidebar tab also needed the stage-one tab TYPE
(`ctx.sidebarRightTabs.register`) that its keyed body slot is dispatched under,
so a tab can finally be opened. `--dsh-border`, defined nowhere, becomes the
product's `--dsw-alias-border-l2`.
Both halves are now pinned by tests: every field is volatile, the field list
and the schema cannot drift, the page claims the key the patch declares, and a
blank API key is never written — the secret control reports configured-and-
absent instead.
Verified against the harness rather than only in unit tests: `--dump-config-
schema` emits the volatile and secret metadata, `apply` registers `recall` on
the real tool registry, `settings.describe({redactSecrets:true})` serves
`dsh-recall` with 33 fields and the key reported set-but-redacted, and a
scratch profile boots the web app with the plugin composed and its bundle
served byte-for-byte as this checkout has it.
817 lines
35 KiB
JavaScript
817 lines
35 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, 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: [],
|
|
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('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')
|
|
})
|