init
This commit is contained in:
commit
0964819cdc
34 files changed
+7912
No files matched your search
@@ -0,0 +1,150 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
chunkFile,
|
||||
embedText,
|
||||
isLikelyMinified,
|
||||
languageOf,
|
||||
symbolLabel,
|
||||
symbolOf,
|
||||
} from '../lib/chunk.js'
|
||||
|
||||
const TS = `import { readFile } from 'node:fs/promises'
|
||||
|
||||
const CACHE = new Map()
|
||||
|
||||
export function loadConfig(path) {
|
||||
if (CACHE.has(path)) return CACHE.get(path)
|
||||
const value = { path, loaded: true }
|
||||
CACHE.set(path, value)
|
||||
return value
|
||||
}
|
||||
|
||||
export function clearCache() {
|
||||
CACHE.clear()
|
||||
}
|
||||
|
||||
export class Store {
|
||||
constructor() {
|
||||
this.rows = []
|
||||
}
|
||||
|
||||
add(row) {
|
||||
this.rows.push(row)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
/** A module long enough that its declarations cannot all merge into one chunk. */
|
||||
function bigModule(count = 6, bodyLines = 14) {
|
||||
const parts = []
|
||||
for (let at = 0; at < count; at += 1) {
|
||||
const body = Array.from({ length: bodyLines }, (_, line) => ` const step_${at}_${line} = compute(${line})`).join('\n')
|
||||
parts.push(`export function handler_${at}(input) {\n${body}\n return input\n}`)
|
||||
}
|
||||
return `${parts.join('\n\n')}\n`
|
||||
}
|
||||
|
||||
test('languageOf maps known extensions and ignores unknown ones', () => {
|
||||
assert.equal(languageOf('a/b.ts'), 'javascript')
|
||||
assert.equal(languageOf('a/b.PY'), 'python')
|
||||
assert.equal(languageOf('a/b.rs'), 'rust')
|
||||
assert.equal(languageOf('a/b.unknownext'), undefined)
|
||||
assert.equal(languageOf('Makefile'), undefined)
|
||||
})
|
||||
|
||||
test('symbolOf reads a declaration name', () => {
|
||||
assert.equal(symbolOf('export function loadConfig(path) {'), 'loadConfig')
|
||||
assert.equal(symbolOf('export class Store {'), 'Store')
|
||||
assert.equal(symbolOf('def compute_total(items):'), 'compute_total')
|
||||
assert.equal(symbolOf('// just a comment'), undefined)
|
||||
})
|
||||
|
||||
test('symbolLabel lists merged declarations and caps a long list', () => {
|
||||
assert.equal(symbolLabel([]), undefined)
|
||||
assert.equal(symbolLabel(['a']), 'a')
|
||||
assert.equal(symbolLabel(['a', 'b']), 'a, b')
|
||||
assert.equal(symbolLabel(['a', 'a', 'b']), 'a, b')
|
||||
assert.equal(symbolLabel(['a', 'b', 'c', 'd', 'e', 'f']), 'a, b, c, d +2')
|
||||
})
|
||||
|
||||
test('a small file merges into one chunk labelled with every declaration it holds', () => {
|
||||
const { chunks } = chunkFile({ path: 'src/config.ts', content: TS })
|
||||
assert.equal(chunks.length, 1, 'a 25-line file belongs in a single chunk')
|
||||
// The label must describe the whole chunk, not just its first declaration:
|
||||
// for a small file this label is the module's only description in the index.
|
||||
assert.equal(chunks[0].symbol, 'CACHE, loadConfig, clearCache, Store')
|
||||
assert.equal(chunks[0].startLine, 1)
|
||||
})
|
||||
|
||||
test('a larger file splits at declarations, and each chunk is a real source slice', () => {
|
||||
const content = bigModule()
|
||||
const { chunks } = chunkFile({ path: 'src/handlers.ts', content })
|
||||
assert.ok(chunks.length >= 3, `expected several chunks, got ${chunks.length}`)
|
||||
const lines = content.split('\n')
|
||||
for (const chunk of chunks) {
|
||||
assert.equal(chunk.text, lines.slice(chunk.startLine - 1, chunk.endLine).join('\n'))
|
||||
}
|
||||
assert.equal(chunks[0].startLine, 1)
|
||||
assert.match(chunks[0].symbol, /handler_0/)
|
||||
})
|
||||
|
||||
test('chunkFile merges adjacent small declarations instead of emitting fragments', () => {
|
||||
const source = ['export function a() { return 1 }', '', 'export function b() { return 2 }', '', 'export function c() { return 3 }', ''].join('\n')
|
||||
const { chunks } = chunkFile({ path: 'x.ts', content: source, chunkLines: 48 })
|
||||
assert.equal(chunks.length, 1, 'three one-line functions should merge into one chunk')
|
||||
assert.equal(chunks[0].symbol, 'a, b, c')
|
||||
})
|
||||
|
||||
test('chunkFile splits an oversized declaration with overlap', () => {
|
||||
const body = Array.from({ length: 300 }, (_, at) => ` const value_${at} = compute(${at})`).join('\n')
|
||||
const source = `export function huge() {\n${body}\n}\n`
|
||||
const { chunks } = chunkFile({ path: 'x.ts', content: source, maxChunkLines: 80, overlapLines: 8 })
|
||||
assert.ok(chunks.length > 1, 'a 300-line function must be split')
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.endLine - chunk.startLine + 1 <= 80, `chunk was ${chunk.endLine - chunk.startLine + 1} lines`)
|
||||
}
|
||||
// Overlap means consecutive chunks share lines, so no boundary is lost.
|
||||
for (let at = 1; at < chunks.length; at += 1) {
|
||||
assert.ok(chunks[at].startLine <= chunks[at - 1].endLine, 'consecutive chunks should overlap')
|
||||
}
|
||||
// Only the piece holding the signature is labelled with the declaration.
|
||||
assert.equal(chunks[0].symbol, 'huge')
|
||||
assert.equal(chunks[1].symbol, undefined)
|
||||
})
|
||||
|
||||
test('chunkFile falls back to windows when the language has no anchor set', () => {
|
||||
const source = Array.from({ length: 200 }, (_, at) => `line ${at} of an unknown dialect`).join('\n')
|
||||
const { chunks } = chunkFile({ path: 'thing.weird', content: source, maxChunkLines: 80, overlapLines: 8 })
|
||||
assert.ok(chunks.length > 1)
|
||||
assert.equal(chunks[0].startLine, 1)
|
||||
assert.equal(chunks[0].symbol, undefined)
|
||||
})
|
||||
|
||||
test('chunkFile refuses generated files rather than indexing noise', () => {
|
||||
const minified = `var a=${'x'.repeat(50_000)};`
|
||||
assert.equal(isLikelyMinified(minified), true)
|
||||
const result = chunkFile({ path: 'bundle.js', content: minified })
|
||||
assert.deepEqual(result.chunks, [])
|
||||
assert.equal(result.skipped, 'generated-or-minified')
|
||||
})
|
||||
|
||||
test('chunkFile reports an empty file as empty', () => {
|
||||
const result = chunkFile({ path: 'a.ts', content: ' \n\n' })
|
||||
assert.deepEqual(result.chunks, [])
|
||||
assert.equal(result.skipped, 'empty')
|
||||
})
|
||||
|
||||
test('chunkFile caps chunks per file', () => {
|
||||
const source = Array.from({ length: 400 }, (_, at) => `export function fn_${at}() { return ${at} }`).join('\n')
|
||||
const { chunks, skipped } = chunkFile({ path: 'many.ts', content: source, chunkLines: 2, maxChunkLines: 2, maxChunks: 10 })
|
||||
assert.equal(chunks.length, 10)
|
||||
assert.equal(skipped, 'chunk-cap-reached')
|
||||
})
|
||||
|
||||
test('embedText carries the path, so a question about a name can find it', () => {
|
||||
const text = embedText('src/auth/session.ts', { symbol: 'issueSession', text: 'export function issueSession() {}' })
|
||||
assert.match(text, /src\/auth\/session\.ts :: issueSession/)
|
||||
assert.match(text, /export function issueSession/)
|
||||
})
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* The browser half, without a browser.
|
||||
*
|
||||
* `client/client.js` is a plain script that hands a factory to the platform's
|
||||
* module loader, so it can be exercised in Node by stubbing `window` and
|
||||
* `require('react')`. That is worth doing rather than leaving the card to be
|
||||
* eyeballed in a running app, because the two things most likely to be wrong are
|
||||
* both structural and both invisible until someone opens Settings:
|
||||
*
|
||||
* - the root element must be an `<li>`, because the tab renders every card
|
||||
* into a `<ul>` and a `<div>` never receives the list styling;
|
||||
* - the card must start collapsed and disclose on click, because that is the
|
||||
* framework's card contract and the thing that was wrong the first time.
|
||||
*/
|
||||
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
/** A React good enough to run one component: element, state, effect, memo. */
|
||||
function makeReact() {
|
||||
const hooks = []
|
||||
let cursor = 0
|
||||
return {
|
||||
createElement(type, props, ...children) {
|
||||
const merged = { ...(props ?? {}) }
|
||||
if (children.length > 0) merged.children = children.length === 1 ? children[0] : children
|
||||
return { type, props: merged }
|
||||
},
|
||||
useState(initial) {
|
||||
const at = cursor
|
||||
cursor += 1
|
||||
if (!(at in hooks)) hooks[at] = typeof initial === 'function' ? initial() : initial
|
||||
return [
|
||||
hooks[at],
|
||||
(next) => {
|
||||
hooks[at] = typeof next === 'function' ? next(hooks[at]) : next
|
||||
},
|
||||
]
|
||||
},
|
||||
useEffect(fn) {
|
||||
fn()
|
||||
},
|
||||
useMemo(fn) {
|
||||
return fn()
|
||||
},
|
||||
/** Prepare for one more render, keeping hook state. */
|
||||
__render() {
|
||||
cursor = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a function element the way React would.
|
||||
*
|
||||
* The card renders each control as `h(Field, …)`, and those elements are only
|
||||
* real DOM once React calls them — so a walk that stops at `type === 'input'`
|
||||
* finds nothing without this.
|
||||
*/
|
||||
function expand(node) {
|
||||
if (node === null || typeof node !== 'object') return node
|
||||
return typeof node.type === 'function' ? expand(node.type(node.props)) : node
|
||||
}
|
||||
|
||||
/** Depth-first search over a rendered tree, expanding function components. */
|
||||
function find(node, predicate, found = []) {
|
||||
const current = expand(node)
|
||||
if (current === null || typeof current !== 'object') return found
|
||||
if (predicate(current)) found.push(current)
|
||||
const children = current.props?.children
|
||||
if (Array.isArray(children)) children.forEach((child) => find(child, predicate, found))
|
||||
else find(children, predicate, found)
|
||||
return found
|
||||
}
|
||||
|
||||
/** The input carrying a given accessible label. */
|
||||
function inputFor(tree, label) {
|
||||
return find(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
|
||||
}
|
||||
|
||||
/** The save button, whatever its current label. */
|
||||
function saveButton(tree) {
|
||||
return find(tree, (node) => node.type === 'button' && String(node.props.children).startsWith('Save'))[0]
|
||||
}
|
||||
|
||||
/** A settings scope that records writes instead of performing them. */
|
||||
function makeScope(options = {}) {
|
||||
const { value = {}, user = null, status = 'ready', writable = true } = options
|
||||
const calls = []
|
||||
const snapshot = { status, value, user, writable }
|
||||
return {
|
||||
calls,
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: () => () => {},
|
||||
set: async (key, next) => {
|
||||
calls.push({ op: 'set', key, value: next })
|
||||
},
|
||||
unset: async (key) => {
|
||||
calls.push({ op: 'unset', key })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load the browser bundle once, the way the platform loader would.
|
||||
const loaded = []
|
||||
globalThis.window = { __ModuleLoader__: { load: (entry) => loaded.push(entry) } }
|
||||
await import('../client/client.js')
|
||||
|
||||
const entry = loaded[0]
|
||||
|
||||
/** Instantiate the module with a stubbed `react`. */
|
||||
function makeModule() {
|
||||
const react = makeReact()
|
||||
const factory = entry.factory((name) => {
|
||||
if (name === 'react') return react
|
||||
throw new Error(`unexpected import: ${name}`)
|
||||
})
|
||||
return { react, module: factory }
|
||||
}
|
||||
|
||||
/** Register every slot the browser half contributes, and render any of them. */
|
||||
function mount(scope) {
|
||||
const { react, module } = makeModule()
|
||||
const registered = []
|
||||
const slots = {
|
||||
inject: (name, fn) => fn(),
|
||||
register: (meta, component) => {
|
||||
registered.push({ meta, component })
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
const ctx = {
|
||||
slots,
|
||||
inject: (deps, cb) =>
|
||||
cb({
|
||||
settingsScope: {
|
||||
bind: (options) => {
|
||||
assert.equal(options.namespace, 'dsh-recall')
|
||||
return scope
|
||||
},
|
||||
},
|
||||
slots,
|
||||
}),
|
||||
}
|
||||
module.apply(ctx)
|
||||
|
||||
const byName = new Map(registered.map((entry) => [entry.meta.name, entry]))
|
||||
|
||||
/** Render one registered slot with the props its occupant would receive. */
|
||||
const renderSlot = (name, props = {}) => {
|
||||
const entry = byName.get(name)
|
||||
assert.ok(entry !== undefined, `nothing registered for ${name}`)
|
||||
react.__render()
|
||||
const element = entry.component(props)
|
||||
if (element === null) return null
|
||||
return element.type(element.props)
|
||||
}
|
||||
|
||||
/** Render the settings card, at its current state. */
|
||||
const render = () => {
|
||||
react.__render()
|
||||
const element = byName.get('settings.plugin.item').component({ scope })
|
||||
return element.type(element.props)
|
||||
}
|
||||
|
||||
return { react, module, meta: byName.get('settings.plugin.item').meta, registered, render, renderSlot }
|
||||
}
|
||||
|
||||
/** Open the card, and hand back a function returning the CURRENT body. */
|
||||
function openCard(render) {
|
||||
render().props.children[0].props.onClick()
|
||||
// Call this after any edit: React re-renders then, and the Save button the
|
||||
// user actually clicks is the one created by that later render.
|
||||
return () => render().props.children[1]
|
||||
}
|
||||
|
||||
test('the bundle registers under the loader with the plugin id', () => {
|
||||
assert.equal(loaded.length, 1)
|
||||
assert.equal(entry.id, 'dsh-recall')
|
||||
assert.equal(typeof entry.factory, 'function')
|
||||
})
|
||||
|
||||
test('the client module claims slots and reports the host namespace', () => {
|
||||
const { module } = makeModule()
|
||||
assert.equal(module.name, 'dsh-recall')
|
||||
assert.deepEqual(module.inject, ['slots'])
|
||||
})
|
||||
|
||||
test('the card is registered under the same key the host half serves', () => {
|
||||
const { meta } = mount(makeScope())
|
||||
assert.equal(meta.name, 'settings.plugin.item')
|
||||
assert.equal(meta.key, 'dsh-recall', 'the host namespace and the card key must match, or nothing renders')
|
||||
assert.equal(typeof meta.inject, 'function')
|
||||
})
|
||||
|
||||
test('the root is an <li>, because the tab renders cards into a <ul>', () => {
|
||||
const { render } = mount(makeScope())
|
||||
assert.equal(render().type, 'li')
|
||||
})
|
||||
|
||||
test('the card starts collapsed and discloses on click', () => {
|
||||
const { render } = mount(makeScope())
|
||||
const closed = render()
|
||||
const [closedHeader, closedBody] = closed.props.children
|
||||
assert.equal(closedHeader.type, 'button')
|
||||
assert.equal(closedHeader.props['aria-expanded'], false, 'the card must start collapsed')
|
||||
assert.equal(closedBody, null, 'a collapsed card renders no body')
|
||||
|
||||
// Click the header, then render again — hook state persists across renders.
|
||||
closedHeader.props.onClick()
|
||||
const [openHeader, openBodyNode] = render().props.children
|
||||
assert.equal(openHeader.props['aria-expanded'], true)
|
||||
assert.notEqual(openBodyNode, null, 'an expanded card renders its controls')
|
||||
assert.equal(openBodyNode.type, 'div')
|
||||
})
|
||||
|
||||
test('every host setting appears as a control', () => {
|
||||
const { render } = mount(makeScope())
|
||||
const body = openCard(render)
|
||||
const labels = find(body(), (node) => node.type === 'input').map((node) => node.props['aria-label'])
|
||||
for (const label of [
|
||||
'Embedding endpoint',
|
||||
'Embedding model',
|
||||
'Embedding dimensions',
|
||||
'Embedding API key',
|
||||
'Lines per excerpt',
|
||||
'Index warm-up budget per search (ms)',
|
||||
]) {
|
||||
assert.ok(labels.includes(label), `expected a control labelled ${label}, saw ${labels.join(' | ')}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unavailable namespace renders nothing at all', () => {
|
||||
const { render } = mount(makeScope({ status: 'unavailable' }))
|
||||
assert.equal(render(), null)
|
||||
})
|
||||
|
||||
test('a read-only namespace says so rather than offering a dead form', () => {
|
||||
const { render } = mount(makeScope({ writable: false }))
|
||||
const body = openCard(render)
|
||||
assert.ok(find(body(), (node) => node.props?.role === 'status').length > 0)
|
||||
})
|
||||
|
||||
test('a save writes only the fields the user changed', async () => {
|
||||
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434', excerptLines: 40 } })
|
||||
const { render } = mount(scope)
|
||||
const body = openCard(render)
|
||||
|
||||
inputFor(body(), 'Lines per excerpt').props.onChange({ target: { value: '25' } })
|
||||
const button = saveButton(body())
|
||||
assert.match(String(button.props.children), /Save 1 change/)
|
||||
|
||||
await button.props.onClick()
|
||||
assert.deepEqual(scope.calls, [{ op: 'set', key: 'excerptLines', value: 25 }])
|
||||
})
|
||||
|
||||
test('clearing a non-secret field unsets it rather than writing an empty value', async () => {
|
||||
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434' } })
|
||||
const { render } = mount(scope)
|
||||
const body = openCard(render)
|
||||
|
||||
inputFor(body(), 'Embedding endpoint').props.onChange({ target: { value: '' } })
|
||||
await saveButton(body()).props.onClick()
|
||||
|
||||
assert.deepEqual(scope.calls, [{ op: 'unset', key: 'embeddingApiBase' }])
|
||||
})
|
||||
|
||||
test('a list field round-trips as a comma separated string', async () => {
|
||||
const scope = makeScope({ value: { exclude: ['dist/', 'vendor/'] } })
|
||||
const { render } = mount(scope)
|
||||
const body = openCard(render)
|
||||
|
||||
const input = inputFor(body(), 'Extra exclude patterns')
|
||||
assert.equal(input.props.value, 'dist/, vendor/')
|
||||
input.props.onChange({ target: { value: 'dist/, vendor/, tmp/' } })
|
||||
await saveButton(body()).props.onClick()
|
||||
|
||||
assert.deepEqual(scope.calls, [{ op: 'set', key: 'exclude', value: ['dist/', 'vendor/', 'tmp/'] }])
|
||||
})
|
||||
|
||||
test('the API key is never sent back to the page, and blank does not clear it', async () => {
|
||||
// The host marks this field role('secret'), so it arrives absent. A save with
|
||||
// the field untouched must not write anything for it — otherwise opening the
|
||||
// card and pressing Save would delete the key.
|
||||
const scope = makeScope({ value: { embeddingApiBase: 'http://a:11434' }, user: { embeddingApiKey: 'stored' } })
|
||||
const { render } = mount(scope)
|
||||
const body = openCard(render)
|
||||
|
||||
const keyInput = inputFor(body(), 'Embedding API key')
|
||||
assert.ok(keyInput !== undefined)
|
||||
assert.equal(keyInput.props.type, 'password')
|
||||
assert.equal(keyInput.props.value, '', 'a redacted secret must render blank')
|
||||
assert.equal(keyInput.props.placeholder, '(unchanged)')
|
||||
|
||||
// Nothing is dirty, so there is nothing to save and the button is inert.
|
||||
const button = saveButton(body())
|
||||
assert.equal(button.props.disabled, true)
|
||||
await button.props.onClick()
|
||||
assert.deepEqual(scope.calls, [], 'an untouched secret must never be written or unset')
|
||||
})
|
||||
|
||||
test('an unsaved edit is marked on the header while collapsed', () => {
|
||||
const { render } = mount(makeScope({ value: { excerptLines: 40 } }))
|
||||
const body = openCard(render)
|
||||
inputFor(body(), 'Lines per excerpt').props.onChange({ target: { value: '25' } })
|
||||
|
||||
// Collapse again and look at the header.
|
||||
render().props.children[0].props.onClick()
|
||||
const collapsed = render()
|
||||
const marked = find(collapsed.props.children[0], (node) => node.props?.children === 'unsaved')
|
||||
assert.equal(marked.length, 1, 'unsaved edits must be visible while the card is folded away')
|
||||
})
|
||||
|
||||
test('the browser half contributes four slots, one of them the header corner', () => {
|
||||
const { registered } = mount(makeScope())
|
||||
const names = registered.map((entry) => entry.meta.name).sort()
|
||||
assert.deepEqual(names, [
|
||||
'conversation.session.header.corner',
|
||||
'settings.plugin.item',
|
||||
'sidebar.right.pane.tab',
|
||||
'sidebar.right.pane.tab.title',
|
||||
])
|
||||
})
|
||||
|
||||
/** A projection value shaped like the host unit's wire view. */
|
||||
function usageView(overrides = {}) {
|
||||
return {
|
||||
searches: 3,
|
||||
indexes: 0,
|
||||
failed: 0,
|
||||
excerpts: 9,
|
||||
files: 5,
|
||||
weak: 0,
|
||||
returnedBytes: 12_000,
|
||||
sourceBytes: 480_000,
|
||||
avoidedBytes: 468_000,
|
||||
avoidedTokens: 117_000,
|
||||
compression: 40,
|
||||
rereadTokens: 0,
|
||||
avoidedUsd: 0.0008,
|
||||
lastFocus: '.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('the header chip renders nothing when the conversation never used it', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
// The corner is laid out only while its occupant renders something, so this
|
||||
// is the state that must cost no space at all.
|
||||
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => undefined }), null)
|
||||
assert.equal(renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ searches: 0, excerpts: 0 }) }), null)
|
||||
// And a host without the projection service at all.
|
||||
assert.equal(renderSlot('conversation.session.header.corner', {}), null)
|
||||
})
|
||||
|
||||
test('the header chip leads with what was kept out, not what it cost', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView() })
|
||||
assert.ok(chip !== null)
|
||||
const text = String(chip.props.children)
|
||||
assert.match(text, /Recall 3/)
|
||||
assert.match(text, /40/)
|
||||
assert.match(text, /kept out/)
|
||||
// Money is deliberately absent from the chip: a fraction of a penny reads as
|
||||
// the tool being useless rather than as the arithmetic being honest.
|
||||
assert.ok(!text.includes('$'), `chip showed a price: ${text}`)
|
||||
})
|
||||
|
||||
test('the chip tooltip carries the figures the chip has no room for', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ rereadTokens: 500_000 }) })
|
||||
const detail = chip.props.title
|
||||
assert.match(detail, /3 searches/)
|
||||
assert.match(detail, /9 excerpts from 5 files/)
|
||||
assert.match(detail, /would have been read/)
|
||||
assert.match(detail, /estimate/i, 'the comparison must be labelled as one')
|
||||
})
|
||||
|
||||
test('the sidebar panel explains an unused conversation instead of showing zeroes', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
const empty = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ searches: 0, indexes: 0, excerpts: 0 }) })
|
||||
const text = JSON.stringify(empty)
|
||||
assert.match(text, /has not used recall yet/)
|
||||
assert.ok(!/\b0 B\b/.test(text), 'zeroes would read as "this did nothing"')
|
||||
})
|
||||
|
||||
test('the sidebar panel says so when the projection is absent', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
const missing = renderSlot('sidebar.right.pane.tab', {})
|
||||
assert.match(JSON.stringify(missing), /not available for this conversation/)
|
||||
})
|
||||
|
||||
test('the sidebar panel reports the comparison and the compounding', () => {
|
||||
const { renderSlot } = mount(makeScope())
|
||||
const panel = renderSlot('sidebar.right.pane.tab', { useProjection: () => usageView({ rereadTokens: 1_200_000 }) })
|
||||
const text = JSON.stringify(panel)
|
||||
assert.match(text, /Kept out of this conversation/)
|
||||
assert.match(text, /Smaller than reading the files/)
|
||||
assert.match(text, /Re-read since/)
|
||||
assert.match(text, /1,200,000 tokens/)
|
||||
assert.match(text, /upper bound/, 'the panel must say what the figure is not')
|
||||
})
|
||||
|
||||
test('the tab title is registered under the same key as the panel', () => {
|
||||
const { registered } = mount(makeScope())
|
||||
const panel = registered.find((entry) => entry.meta.name === 'sidebar.right.pane.tab')
|
||||
const title = registered.find((entry) => entry.meta.name === 'sidebar.right.pane.tab.title')
|
||||
assert.equal(panel.meta.key, 'dsh-recall')
|
||||
assert.equal(title.meta.key, panel.meta.key, 'the title must bind to the panel it names')
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { Config, SETTING_KEYS, validateSettings } from '../index.js'
|
||||
|
||||
/**
|
||||
* The load-time contract.
|
||||
*
|
||||
* `apply` never runs when Cordis rejects a plugin's `Config`, so these are the
|
||||
* only failures a plugin cannot report from inside itself — the profile simply
|
||||
* refuses to boot. The rest of the suite calls `apply` directly and would never
|
||||
* notice, which is exactly why this file exists separately.
|
||||
*/
|
||||
|
||||
test('Config is a Standard Schema, which is what Cordis requires', () => {
|
||||
assert.ok(Config !== undefined, 'Config must be exported')
|
||||
assert.ok(Config['~standard'] !== undefined, 'Config must implement the Standard Schema interface')
|
||||
})
|
||||
|
||||
test('Config fills every field from an empty input, so apply can rely on defaults', () => {
|
||||
const filled = Config({})
|
||||
for (const key of SETTING_KEYS) {
|
||||
assert.ok(key in filled, `${key} has no default and would be undefined in apply`)
|
||||
}
|
||||
assert.equal(filled.embeddingApiBase, 'http://127.0.0.1:11434')
|
||||
assert.equal(filled.embeddingDimensions, 768)
|
||||
assert.equal(filled.syncBudgetMs, 20_000)
|
||||
assert.deepEqual(filled.exclude, [])
|
||||
assert.deepEqual(filled.extensions, [])
|
||||
})
|
||||
|
||||
test('Config keeps an explicit value and fills the rest', () => {
|
||||
const filled = Config({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
|
||||
assert.equal(filled.embeddingApiBase, 'http://10.50.1.2:11434')
|
||||
assert.equal(filled.embeddingConcurrency, 8)
|
||||
assert.equal(filled.embeddingBatchSize, 16)
|
||||
assert.equal(filled.embeddingTimeoutMs, 30_000)
|
||||
assert.equal(filled.embeddingKeepAlive, '5m')
|
||||
// The per-excerpt cap equals the chunk cap, so chunks arrive whole.
|
||||
assert.equal(filled.excerptLines, 80)
|
||||
assert.equal(filled.totalExcerptLines, 400)
|
||||
assert.equal(filled.maxChunkLines, 80)
|
||||
})
|
||||
|
||||
test('a wrongly typed value fails loudly at load rather than silently in apply', () => {
|
||||
assert.throws(() => Config({ embeddingDimensions: 'seven hundred' }))
|
||||
assert.throws(() => Config({ exclude: 'src/generated/' }))
|
||||
assert.throws(() => Config({ embeddingApiBase: { host: 'localhost' } }))
|
||||
})
|
||||
|
||||
test('every declared setting is a real schema key, so none is dead config', () => {
|
||||
const filled = Config({})
|
||||
for (const key of SETTING_KEYS) {
|
||||
assert.ok(Object.hasOwn(filled, key), `${key} is listed in SETTING_KEYS but is not in the schema`)
|
||||
}
|
||||
// And the reverse: no schema field is missing from the documented list, or
|
||||
// `configure` would reject a setting the schema happily accepts.
|
||||
for (const key of Object.keys(filled)) {
|
||||
assert.ok(SETTING_KEYS.includes(key), `${key} is in the schema but not in SETTING_KEYS`)
|
||||
}
|
||||
})
|
||||
|
||||
/** The defaults plus an override, as the settings provider would resolve them. */
|
||||
function settingsWith(patch) {
|
||||
return { ...Config({}), ...patch }
|
||||
}
|
||||
|
||||
test('validateSettings accepts the defaults', () => {
|
||||
assert.doesNotThrow(() => validateSettings(Config({})))
|
||||
})
|
||||
|
||||
test('validateSettings rejects an endpoint that is not a URL', () => {
|
||||
// A bare host:port parses successfully — `localhost:` is read as the scheme —
|
||||
// so it is the protocol check that actually catches the common mistake.
|
||||
assert.throws(() => validateSettings(settingsWith({ embeddingApiBase: 'localhost:11434' })), /must be http or https/)
|
||||
assert.throws(() => validateSettings(settingsWith({ embeddingApiBase: '' })), /not a URL/)
|
||||
assert.doesNotThrow(() => validateSettings(settingsWith({ embeddingApiBase: 'http://10.50.1.2:11434' })))
|
||||
})
|
||||
|
||||
test('validateSettings rejects a non-http protocol', () => {
|
||||
assert.throws(() => validateSettings(settingsWith({ embeddingApiBase: 'file:///tmp/socket' })), /must be http or https/)
|
||||
})
|
||||
|
||||
test('validateSettings rejects values that would break the pipeline', () => {
|
||||
assert.throws(() => validateSettings(settingsWith({ embeddingDimensions: 0 })), /positive number/)
|
||||
assert.throws(() => validateSettings(settingsWith({ embeddingConcurrency: 0 })), /at least 1/)
|
||||
assert.throws(() => validateSettings(settingsWith({ excerptLines: 0 })), /at least 1/)
|
||||
assert.throws(() => validateSettings(settingsWith({ syncBudgetMs: -1 })), /cannot be negative/)
|
||||
})
|
||||
|
||||
test('validateSettings rejects cross-field contradictions the schema cannot see', () => {
|
||||
assert.throws(() => validateSettings(settingsWith({ defaultLimit: 50, maxLimit: 10 })), /cannot exceed maxLimit/)
|
||||
assert.throws(() => validateSettings(settingsWith({ chunkLines: 100, maxChunkLines: 80 })), /below chunkLines/)
|
||||
// An overlap at or above the cap means a split never advances.
|
||||
assert.throws(() => validateSettings(settingsWith({ chunkLines: 10, maxChunkLines: 10, overlapLines: 10 })), /never advances/)
|
||||
assert.throws(() => validateSettings(settingsWith({ minSimilarity: 1.5 })), /between 0 and 1/)
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { EmbeddingError, clampInput, createEmbedder, dot, normalize } from '../lib/embed.js'
|
||||
import { makeStubFetch } from './helpers.js'
|
||||
|
||||
function embedder(fetchImpl, overrides = {}) {
|
||||
return createEmbedder({
|
||||
apiBase: 'http://stub',
|
||||
model: 'test-model',
|
||||
dimensions: 768,
|
||||
fetchImpl,
|
||||
concurrency: 2,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
test('normalize produces a unit vector and leaves zero alone', () => {
|
||||
const unit = normalize(Float32Array.from([3, 4]))
|
||||
assert.equal(Math.round(Math.hypot(unit[0], unit[1]) * 1000) / 1000, 1)
|
||||
assert.deepEqual(Array.from(normalize(Float32Array.from([0, 0]))), [0, 0])
|
||||
})
|
||||
|
||||
test('dot is the cosine similarity of unit vectors', () => {
|
||||
assert.equal(dot(Float32Array.from([1, 0]), Float32Array.from([1, 0])), 1)
|
||||
assert.equal(dot(Float32Array.from([1, 0]), Float32Array.from([0, 1])), 0)
|
||||
})
|
||||
|
||||
test('clampInput truncates to the character budget', () => {
|
||||
assert.equal(clampInput('abc').length, 3)
|
||||
assert.equal(clampInput('x'.repeat(50_000)).length, 24_000)
|
||||
})
|
||||
|
||||
test('embed uses the batch endpoint and preserves order', async () => {
|
||||
const fetchImpl = makeStubFetch()
|
||||
const result = await embedder(fetchImpl).embed(['alpha alpha', 'beta beta'])
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(fetchImpl.calls.filter((call) => call.url.endsWith('/api/embed')).length, 1)
|
||||
// The two texts share no tokens, so their vectors must be orthogonal-ish.
|
||||
assert.ok(dot(result[0], result[1]) < 0.05)
|
||||
})
|
||||
|
||||
test('embed returns unit vectors', async () => {
|
||||
const [vector] = await embedder(makeStubFetch()).embed(['some real code here'])
|
||||
let sum = 0
|
||||
for (const value of vector) sum += value * value
|
||||
assert.ok(Math.abs(sum - 1) < 1e-5)
|
||||
})
|
||||
|
||||
test('an endpoint without the batch API falls back to single requests', async () => {
|
||||
const fetchImpl = makeStubFetch({ unsupportedBatch: true })
|
||||
const subject = embedder(fetchImpl)
|
||||
const result = await subject.embed(['one two', 'three four'])
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(subject.batchEnabled, false)
|
||||
assert.equal(fetchImpl.calls.filter((call) => call.url.endsWith('/api/embeddings')).length, 2)
|
||||
})
|
||||
|
||||
test('a dimension mismatch is fatal and explains itself', async () => {
|
||||
const fetchImpl = makeStubFetch({ width: 128 })
|
||||
await assert.rejects(
|
||||
() => embedder(fetchImpl).embed(['x']),
|
||||
(error) => {
|
||||
assert.ok(error instanceof EmbeddingError)
|
||||
assert.equal(error.code, 'DIMENSION_MISMATCH')
|
||||
assert.match(error.message, /128-dimensional .* 768 are configured/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('a persistent server error surfaces as UNREACHABLE after retrying', async () => {
|
||||
let attempts = 0
|
||||
const fetchImpl = async () => {
|
||||
attempts += 1
|
||||
return { ok: false, status: 500, text: async () => 'boom', json: async () => ({}) }
|
||||
}
|
||||
await assert.rejects(
|
||||
() => embedder(fetchImpl).embed(['x']),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'UNREACHABLE')
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(attempts, 3, 'the request should be attempted MAX_ATTEMPTS times')
|
||||
})
|
||||
|
||||
test('a transient failure is retried and then succeeds', async () => {
|
||||
const good = makeStubFetch()
|
||||
let calls = 0
|
||||
const fetchImpl = async (url, init) => {
|
||||
calls += 1
|
||||
if (calls === 1) throw new Error('socket hang up')
|
||||
return good(url, init)
|
||||
}
|
||||
const [vector] = await embedder(fetchImpl).embed(['retry me'])
|
||||
assert.equal(vector.length, 768)
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test('a client error is not retried', async () => {
|
||||
let attempts = 0
|
||||
const fetchImpl = async () => {
|
||||
attempts += 1
|
||||
return { ok: false, status: 400, text: async () => 'bad model', json: async () => ({}) }
|
||||
}
|
||||
await assert.rejects(
|
||||
() => embedder(fetchImpl).embed(['x']),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'HTTP_ERROR')
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(attempts, 1, 'a 400 is a configuration fault, not a transient one')
|
||||
})
|
||||
|
||||
test('embedding nothing makes no request', async () => {
|
||||
const fetchImpl = makeStubFetch()
|
||||
const result = await embedder(fetchImpl).embed([])
|
||||
assert.deepEqual(result, [])
|
||||
assert.equal(fetchImpl.calls.length, 0)
|
||||
})
|
||||
|
||||
test('cancellation stops the work', async () => {
|
||||
const fetchImpl = makeStubFetch()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await assert.rejects(() => embedder(fetchImpl).embed(['x'], controller.signal))
|
||||
})
|
||||
|
||||
test('keepAlive and maxAttempts are sent only when configured', async () => {
|
||||
const withKeepAlive = makeStubFetch()
|
||||
await embedder(withKeepAlive, { keepAlive: '10m' }).embed(['x'])
|
||||
assert.equal(withKeepAlive.calls[0].body.keep_alive, '10m')
|
||||
|
||||
const without = makeStubFetch()
|
||||
await embedder(without).embed(['x'])
|
||||
assert.equal('keep_alive' in without.calls[0].body, false, 'an unset keep-alive must not be sent')
|
||||
})
|
||||
|
||||
test('the keep-alive also reaches the single-text endpoint', async () => {
|
||||
const fetchImpl = makeStubFetch({ unsupportedBatch: true })
|
||||
await embedder(fetchImpl, { keepAlive: '2m' }).embed(['x'])
|
||||
const legacy = fetchImpl.calls.find((call) => call.url.endsWith('/api/embeddings'))
|
||||
assert.equal(legacy.body.keep_alive, '2m')
|
||||
})
|
||||
|
||||
test('maxAttempts of one gives a probe a single chance', async () => {
|
||||
let attempts = 0
|
||||
const fetchImpl = async () => {
|
||||
attempts += 1
|
||||
return { ok: false, status: 500, text: async () => 'boom', json: async () => ({}) }
|
||||
}
|
||||
await assert.rejects(() => embedder(fetchImpl, { maxAttempts: 1 }).embed(['x']))
|
||||
assert.equal(attempts, 1)
|
||||
})
|
||||
|
||||
test('a socket that accepts and never answers fails on the timeout, not forever', async () => {
|
||||
// The real failure mode observed in development: connection opens, no bytes.
|
||||
const fetchImpl = (url, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true })
|
||||
})
|
||||
const started = Date.now()
|
||||
await assert.rejects(
|
||||
() => embedder(fetchImpl, { timeoutMs: 300, maxAttempts: 1 }).embed(['x']),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'UNREACHABLE')
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.ok(Date.now() - started < 5_000, 'the timeout must bound the wait')
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { cleanVariant, createExpander, parseVariants } from '../lib/expand.js'
|
||||
|
||||
/** A stub endpoint that answers with a canned model response. */
|
||||
function stubFetch(response, options = {}) {
|
||||
const { status = 200, hang = false } = options
|
||||
const calls = []
|
||||
const impl = async (url, init) => {
|
||||
calls.push({ url, body: JSON.parse(init.body) })
|
||||
if (hang) {
|
||||
return new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true })
|
||||
})
|
||||
}
|
||||
return { ok: status === 200, status, json: async () => ({ response }) }
|
||||
}
|
||||
impl.calls = calls
|
||||
return impl
|
||||
}
|
||||
|
||||
const MODEL = { apiBase: 'http://stub:11434', model: 'small-model', timeoutMs: 2_000, count: 4 }
|
||||
|
||||
test('cleanVariant strips what models add around a list', () => {
|
||||
assert.equal(cleanVariant('1. occupancy state machine'), 'occupancy state machine')
|
||||
assert.equal(cleanVariant('- presence detection'), 'presence detection')
|
||||
assert.equal(cleanVariant('* room occupied handler'), 'room occupied handler')
|
||||
assert.equal(cleanVariant('"roomOccupied"'), 'roomOccupied')
|
||||
assert.equal(cleanVariant('occupancy check — this finds the handler'), 'occupancy check')
|
||||
assert.equal(cleanVariant(' '), undefined)
|
||||
assert.equal(cleanVariant('x'.repeat(300)), undefined, 'a paragraph is not a search phrase')
|
||||
assert.equal(cleanVariant('Here are some phrasings:'), undefined)
|
||||
})
|
||||
|
||||
test('model commentary is never mistaken for a phrasing', () => {
|
||||
// A line like this is not a weak variant, it is not a variant at all:
|
||||
// embedding it would search the codebase for the model's apology.
|
||||
for (const line of ['I cannot help with that.', "I can't do that.", 'Sorry, I need more context.', 'Unfortunately I do not have enough information.', 'Here are some phrasings:']) {
|
||||
assert.equal(cleanVariant(line), undefined, `accepted ${line}`)
|
||||
}
|
||||
// But a question-shaped phrasing is entirely legitimate, and must survive.
|
||||
assert.equal(cleanVariant('how does a room decide it is occupied'), 'how does a room decide it is occupied')
|
||||
assert.equal(cleanVariant('where is the occupancy handler'), 'where is the occupancy handler')
|
||||
})
|
||||
|
||||
test('parseVariants dedupes, drops the original, and caps the count', () => {
|
||||
const body = ['occupancy state machine', 'room occupied detection', 'OCCUPANCY STATE MACHINE', 'presence tracking', 'extra one', 'extra two'].join('\n')
|
||||
const variants = parseVariants(body, 'how does a room decide it is occupied', 3)
|
||||
assert.deepEqual(variants, ['occupancy state machine', 'room occupied detection', 'presence tracking'])
|
||||
})
|
||||
|
||||
test('expansion returns the original first, then the variants', async () => {
|
||||
const fetchImpl = stubFetch('occupancy state machine\nroom occupied handler')
|
||||
const variants = await createExpander({ ...MODEL, fetchImpl }).expand('how does a room decide it is occupied')
|
||||
assert.equal(variants[0], 'how does a room decide it is occupied')
|
||||
assert.deepEqual(variants.slice(1), ['occupancy state machine', 'room occupied handler'])
|
||||
// The query must actually reach the model.
|
||||
assert.match(fetchImpl.calls[0].body.prompt, /how does a room decide it is occupied/)
|
||||
assert.equal(fetchImpl.calls[0].body.stream, false)
|
||||
})
|
||||
|
||||
test('an unreachable endpoint degrades to the original query, never an error', async () => {
|
||||
const fetchImpl = async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
}
|
||||
const variants = await createExpander({ ...MODEL, fetchImpl }).expand('some query')
|
||||
assert.deepEqual(variants, ['some query'])
|
||||
})
|
||||
|
||||
test('a WEDGED endpoint degrades to the original query, and does not hang', async () => {
|
||||
// The failure actually observed on the box: the socket opens and nothing ever
|
||||
// comes back. Expansion must give up on its own deadline rather than pass the
|
||||
// wait on to a search that has nothing to do with the local model.
|
||||
const fetchImpl = stubFetch('', { hang: true })
|
||||
const started = Date.now()
|
||||
const variants = await createExpander({ ...MODEL, fetchImpl, timeoutMs: 120 }).expand('some query')
|
||||
assert.deepEqual(variants, ['some query'])
|
||||
assert.ok(Date.now() - started < 3_000, 'the internal deadline must bound the wait')
|
||||
})
|
||||
|
||||
test('a non-200 response degrades rather than throwing', async () => {
|
||||
const variants = await createExpander({ ...MODEL, fetchImpl: stubFetch('', { status: 500 }) }).expand('q')
|
||||
assert.deepEqual(variants, ['q'])
|
||||
})
|
||||
|
||||
test('a response with nothing usable leaves just the original', async () => {
|
||||
const variants = await createExpander({ ...MODEL, fetchImpl: stubFetch('I cannot help with that.') }).expand('q')
|
||||
assert.deepEqual(variants, ['q'])
|
||||
})
|
||||
|
||||
test('a non-finite timeout is clamped, not turned into a silent fallback', async () => {
|
||||
// Without the clamp, AbortSignal.timeout(NaN) throws and the catch below
|
||||
// reports it as "the model had nothing to say" — a misconfiguration wearing
|
||||
// the disguise of a slow model.
|
||||
const fetchImpl = stubFetch('occupancy handler')
|
||||
const variants = await createExpander({ ...MODEL, fetchImpl, timeoutMs: Number.NaN }).expand('q')
|
||||
assert.deepEqual(variants, ['q', 'occupancy handler'])
|
||||
})
|
||||
|
||||
test('a malformed payload degrades rather than throwing', async () => {
|
||||
const fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({}) })
|
||||
assert.deepEqual(await createExpander({ ...MODEL, fetchImpl }).expand('q'), ['q'])
|
||||
})
|
||||
|
||||
test('an empty or oversized query is not sent at all', async () => {
|
||||
const fetchImpl = stubFetch('variant one')
|
||||
const expander = createExpander({ ...MODEL, fetchImpl })
|
||||
assert.deepEqual(await expander.expand(' '), [''])
|
||||
assert.deepEqual(await expander.expand('x'.repeat(600)), ['x'.repeat(600)])
|
||||
assert.equal(fetchImpl.calls.length, 0, 'nothing worth asking about')
|
||||
})
|
||||
|
||||
test('caller cancellation stops expansion and returns the original', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const fetchImpl = stubFetch('variant one')
|
||||
assert.deepEqual(await createExpander({ ...MODEL, fetchImpl }).expand('q', controller.signal), ['q'])
|
||||
assert.equal(fetchImpl.calls.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
DEFAULT_FOCUS_MARKERS,
|
||||
ancestorsTo,
|
||||
discoverScopes,
|
||||
focusKey,
|
||||
isInside,
|
||||
resolveFocus,
|
||||
} from '../lib/focus.js'
|
||||
|
||||
const WORKSPACE = '/repo'
|
||||
|
||||
/**
|
||||
* The two filesystem injection points, faked.
|
||||
*
|
||||
* `exists` and `isDirectory` are separate because the real code distinguishes
|
||||
* them: a focus path must not merely exist, it must be a directory. A single
|
||||
* predicate would let a test pass a file where a directory is required.
|
||||
*
|
||||
* @param {string[]} files paths that exist
|
||||
* @param {string[]} [directories] paths that are directories (defaults to `files`)
|
||||
*/
|
||||
function fakeFs(files, directories = files) {
|
||||
const fileSet = new Set(files)
|
||||
const dirSet = new Set(directories)
|
||||
return { exists: (path) => fileSet.has(path), isDirectory: (path) => dirSet.has(path) }
|
||||
}
|
||||
|
||||
test('isInside treats a directory as inside itself and rejects siblings', () => {
|
||||
assert.equal(isInside('/repo', '/repo'), true)
|
||||
assert.equal(isInside('/repo', '/repo/src'), true)
|
||||
assert.equal(isInside('/repo', '/repository'), false, 'a shared prefix is not a child')
|
||||
assert.equal(isInside('/repo', '/other'), false)
|
||||
})
|
||||
|
||||
test('ancestorsTo walks up to the stop directory and no further', () => {
|
||||
assert.deepEqual(ancestorsTo('/repo/src/a/b', '/repo'), ['/repo/src/a/b', '/repo/src/a', '/repo/src', '/repo'])
|
||||
assert.deepEqual(ancestorsTo('/repo', '/repo'), ['/repo'])
|
||||
// A start outside the stop does not run away to the filesystem root.
|
||||
assert.deepEqual(ancestorsTo('/elsewhere/x', '/repo'), ['/elsewhere/x'])
|
||||
})
|
||||
|
||||
test('no markers anywhere means the workspace, which is the old behaviour', () => {
|
||||
const result = resolveFocus({ workspace: WORKSPACE, cwd: WORKSPACE, ...fakeFs([]) })
|
||||
assert.equal(result.root, '/repo')
|
||||
assert.equal(result.source, 'workspace')
|
||||
})
|
||||
|
||||
test('a marker at the workspace root keeps the scope at the workspace', () => {
|
||||
// This is the live case today: AGENTS.md sits at the repository root, so
|
||||
// adopting focus changes nothing until a nested folder carries a marker.
|
||||
const result = resolveFocus({ workspace: WORKSPACE, cwd: WORKSPACE, ...fakeFs(['/repo/AGENTS.md']) })
|
||||
assert.equal(result.root, '/repo')
|
||||
assert.equal(result.source, 'marker')
|
||||
assert.equal(result.marker, 'AGENTS.md')
|
||||
})
|
||||
|
||||
test('a nested controller marker narrows the scope to that controller', () => {
|
||||
const controller = '/repo/src/controllers/room/occupancy'
|
||||
const result = resolveFocus({
|
||||
workspace: WORKSPACE,
|
||||
cwd: controller,
|
||||
...fakeFs(['/repo/AGENTS.md', `${controller}/CONTROLLER.md`]),
|
||||
})
|
||||
assert.equal(result.root, controller)
|
||||
assert.equal(result.source, 'marker')
|
||||
assert.equal(result.marker, 'CONTROLLER.md')
|
||||
})
|
||||
|
||||
test('the nearest marker wins over a further one', () => {
|
||||
const result = resolveFocus({
|
||||
workspace: WORKSPACE,
|
||||
cwd: '/repo/src/controllers/room/occupancy/state_machine',
|
||||
...fakeFs(['/repo/AGENTS.md', '/repo/src/controllers/room/occupancy/CONTROLLER.md']),
|
||||
})
|
||||
assert.equal(result.root, '/repo/src/controllers/room/occupancy')
|
||||
})
|
||||
|
||||
test('CONTROLLER.md outranks AGENTS.md in the same directory', () => {
|
||||
const result = resolveFocus({
|
||||
workspace: WORKSPACE,
|
||||
cwd: WORKSPACE,
|
||||
...fakeFs(['/repo/AGENTS.md', '/repo/CONTROLLER.md']),
|
||||
})
|
||||
assert.equal(result.marker, 'CONTROLLER.md', 'the more specific marker should be reported')
|
||||
})
|
||||
|
||||
test('an explicit focus wins over any marker', () => {
|
||||
const lights = '/repo/src/controllers/room/device/lights'
|
||||
const result = resolveFocus({
|
||||
workspace: WORKSPACE,
|
||||
cwd: WORKSPACE,
|
||||
explicit: 'src/controllers/room/device/lights',
|
||||
...fakeFs(['/repo/AGENTS.md', lights]),
|
||||
})
|
||||
assert.equal(result.root, lights)
|
||||
assert.equal(result.source, 'explicit')
|
||||
})
|
||||
|
||||
test('an explicit focus may be absolute', () => {
|
||||
const result = resolveFocus({ workspace: WORKSPACE, explicit: '/repo/src/x', ...fakeFs(['/repo/src/x']) })
|
||||
assert.equal(result.root, '/repo/src/x')
|
||||
assert.equal(result.source, 'explicit')
|
||||
})
|
||||
|
||||
test('an explicit focus that is a file, not a directory, is refused', () => {
|
||||
// The two predicates must genuinely differ, or this passes by accident.
|
||||
const result = resolveFocus({
|
||||
workspace: WORKSPACE,
|
||||
explicit: 'src/AGENTS.md',
|
||||
...fakeFs(['/repo/src/AGENTS.md'], ['/repo', '/repo/src']),
|
||||
})
|
||||
assert.equal(result.root, '/repo')
|
||||
assert.match(result.problem, /not a directory/)
|
||||
})
|
||||
|
||||
test('a focus outside the workspace is refused and reported, not obeyed', () => {
|
||||
const result = resolveFocus({
|
||||
workspace: WORKSPACE,
|
||||
explicit: '../elsewhere',
|
||||
...fakeFs(['/elsewhere'], ['/elsewhere']),
|
||||
})
|
||||
assert.equal(result.root, '/repo', 'must fall back to the workspace')
|
||||
assert.match(result.problem, /outside the workspace/)
|
||||
})
|
||||
|
||||
test('a focus that does not exist is refused and reported', () => {
|
||||
const result = resolveFocus({ workspace: WORKSPACE, explicit: 'src/typo', ...fakeFs([]) })
|
||||
assert.equal(result.root, '/repo')
|
||||
assert.match(result.problem, /not a directory/)
|
||||
})
|
||||
|
||||
test('an empty marker list still resolves, to the workspace', () => {
|
||||
const result = resolveFocus({
|
||||
workspace: WORKSPACE,
|
||||
cwd: WORKSPACE,
|
||||
markers: [],
|
||||
...fakeFs(['/repo/AGENTS.md']),
|
||||
})
|
||||
assert.equal(result.root, '/repo')
|
||||
assert.equal(result.source, 'workspace')
|
||||
})
|
||||
|
||||
test('a cwd outside the workspace cannot escape it', () => {
|
||||
const result = resolveFocus({
|
||||
workspace: WORKSPACE,
|
||||
cwd: '/somewhere/else',
|
||||
...fakeFs(['/somewhere/else/AGENTS.md', '/repo/AGENTS.md']),
|
||||
})
|
||||
assert.equal(result.root, '/repo', 'a stray cwd must not pull the scope out of the workspace')
|
||||
})
|
||||
|
||||
test('focusKey is a stable relative path, with "." for the workspace itself', () => {
|
||||
assert.equal(focusKey('/repo', '/repo'), '.')
|
||||
assert.equal(focusKey('/repo', '/repo/src/controllers/room/occupancy'), 'src/controllers/room/occupancy')
|
||||
})
|
||||
|
||||
test('the default markers include the controller instruction files', () => {
|
||||
assert.ok(DEFAULT_FOCUS_MARKERS.includes('CONTROLLER.md'))
|
||||
assert.ok(DEFAULT_FOCUS_MARKERS.includes('DEBUG_UI.md'))
|
||||
// CONTROLLER.md must be tried before AGENTS.md, since both may be present.
|
||||
assert.ok(DEFAULT_FOCUS_MARKERS.indexOf('CONTROLLER.md') < DEFAULT_FOCUS_MARKERS.indexOf('AGENTS.md'))
|
||||
})
|
||||
|
||||
test('discoverScopes finds marker directories, sorted and workspace-relative', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'recall-scopes-'))
|
||||
try {
|
||||
await mkdir(join(root, 'src', 'weather'), { recursive: true })
|
||||
await mkdir(join(root, 'src', 'auth'), { recursive: true })
|
||||
await mkdir(join(root, 'node_modules', 'dep'), { recursive: true })
|
||||
await writeFile(join(root, 'src', 'auth', 'CONTROLLER.md'), '# auth\n')
|
||||
await writeFile(join(root, 'src', 'weather', 'CONTROLLER.md'), '# weather\n')
|
||||
await writeFile(join(root, 'AGENTS.md'), '# repo\n')
|
||||
// A marker inside a dependency describes somebody else's project.
|
||||
await writeFile(join(root, 'node_modules', 'dep', 'CONTROLLER.md'), '# not ours\n')
|
||||
await writeFile(join(root, 'src', 'code.ts'), 'export const x = 1\n')
|
||||
|
||||
const scopes = await discoverScopes({ workspace: root })
|
||||
assert.deepEqual(
|
||||
scopes.map((scope) => scope.path),
|
||||
['.', 'src/auth', 'src/weather'],
|
||||
'sorted, relative, and node_modules excluded',
|
||||
)
|
||||
assert.equal(scopes[0].marker, 'AGENTS.md')
|
||||
assert.equal(scopes[1].marker, 'CONTROLLER.md')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('discoverScopes honours a custom marker list and maxDepth', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'recall-scopes-'))
|
||||
try {
|
||||
await mkdir(join(root, 'deep', 'deeper', 'deepest'), { recursive: true })
|
||||
await writeFile(join(root, 'deep', 'SCOPE.md'), '# scope\n')
|
||||
await writeFile(join(root, 'deep', 'deeper', 'deepest', 'SCOPE.md'), '# scope\n')
|
||||
|
||||
const shallow = await discoverScopes({ workspace: root, markers: ['SCOPE.md'], maxDepth: 1 })
|
||||
assert.deepEqual(shallow.map((scope) => scope.path), ['deep'])
|
||||
|
||||
const deep = await discoverScopes({ workspace: root, markers: ['SCOPE.md'], maxDepth: 4 })
|
||||
assert.deepEqual(deep.map((scope) => scope.path), ['deep', 'deep/deeper/deepest'])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('discoverScopes returns nothing for a directory with no markers', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'recall-scopes-'))
|
||||
try {
|
||||
await mkdir(join(root, 'src'), { recursive: true })
|
||||
await writeFile(join(root, 'src', 'a.ts'), 'a\n')
|
||||
assert.deepEqual(await discoverScopes({ workspace: root }), [])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared test scaffolding.
|
||||
*
|
||||
* The embedding stub is a bag-of-words hash rather than the real model, for two
|
||||
* reasons: the suite must run without a network, and a deterministic vector
|
||||
* makes a ranking assertion a statement about the ranking code rather than a
|
||||
* statement about a model that may change under it. Whether the real model
|
||||
* retrieves the right file is a different question, answered by
|
||||
* `scripts/smoke.mjs` against a live endpoint.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
/** Deterministic unit vector for a piece of text. */
|
||||
export function hashEmbed(text, width) {
|
||||
const vector = new Float32Array(width)
|
||||
for (const token of String(text).toLowerCase().split(/[^a-z0-9]+/)) {
|
||||
if (token.length < 3) continue
|
||||
const digest = createHash('sha1').update(token).digest()
|
||||
const slot = ((digest[0] << 8) | digest[1]) % width
|
||||
vector[slot] += 1
|
||||
}
|
||||
let sum = 0
|
||||
for (let at = 0; at < width; at += 1) sum += vector[at] * vector[at]
|
||||
if (sum === 0) return vector
|
||||
const scale = 1 / Math.sqrt(sum)
|
||||
for (let at = 0; at < width; at += 1) vector[at] *= scale
|
||||
return vector
|
||||
}
|
||||
|
||||
/** A `fetch` that answers the Ollama embedding API deterministically. */
|
||||
export function makeStubFetch(options = {}) {
|
||||
const { width = 768, onCall, unsupportedBatch = false, generate = undefined } = options
|
||||
const calls = []
|
||||
const impl = async (url, init) => {
|
||||
const address = String(url)
|
||||
const body = JSON.parse(init.body)
|
||||
calls.push({ url: address, body })
|
||||
onCall?.(address, body)
|
||||
// The generation endpoint the expander and the re-ranker share. Left
|
||||
// unanswered unless a test supplies a response, so a caller that expects
|
||||
// the local model to be absent gets that for free.
|
||||
if (address.endsWith('/api/generate')) {
|
||||
if (generate === undefined) return { ok: false, status: 404, text: async () => 'no model', json: async () => ({}) }
|
||||
const response = typeof generate === 'function' ? generate(body) : generate
|
||||
return { ok: true, status: 200, json: async () => ({ response }) }
|
||||
}
|
||||
if (address.endsWith('/api/embed')) {
|
||||
if (unsupportedBatch) {
|
||||
return { ok: false, status: 404, text: async () => 'not found', json: async () => ({}) }
|
||||
}
|
||||
const inputs = body.input
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ embeddings: inputs.map((text) => Array.from(hashEmbed(text, width))) }),
|
||||
}
|
||||
}
|
||||
if (address.endsWith('/api/embeddings')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ embedding: Array.from(hashEmbed(body.prompt, width)) }),
|
||||
}
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => 'unknown endpoint', json: async () => ({}) }
|
||||
}
|
||||
impl.calls = calls
|
||||
return impl
|
||||
}
|
||||
|
||||
/**
|
||||
* The smallest context shape `apply` actually touches.
|
||||
*
|
||||
* `services` mounts optional dependencies: `ctx.inject` invokes its callback
|
||||
* only when every named dependency is present, which is how the plugin decides
|
||||
* whether it can offer a settings namespace or a projection. Passing none
|
||||
* exercises the composition-config path every minimal profile takes.
|
||||
*/
|
||||
export function makeContext(options = {}) {
|
||||
const { services = {} } = options
|
||||
const tools = []
|
||||
const cleanups = []
|
||||
const ctx = {
|
||||
logger: () => ({ warn() {}, info() {}, error() {}, debug() {} }),
|
||||
effect: (fn) => {
|
||||
const dispose = fn()
|
||||
if (typeof dispose === 'function') cleanups.push(dispose)
|
||||
},
|
||||
get: (name) => services[name],
|
||||
inject: (deps, callback) => {
|
||||
const missing = deps.filter((dep) => services[dep] === undefined)
|
||||
if (missing.length > 0) return
|
||||
callback({ ...ctx, ...Object.fromEntries(deps.map((dep) => [dep, services[dep]])) })
|
||||
},
|
||||
tools: { register: (tool) => tools.push(tool) },
|
||||
}
|
||||
return { ctx, tools, dispose: () => cleanups.forEach((fn) => fn()) }
|
||||
}
|
||||
|
||||
/** An `exec` argument for one tool call. */
|
||||
export function makeExec(cwd, signal) {
|
||||
return { signal: signal ?? new AbortController().signal, agent: { session: { header: { cwd } } } }
|
||||
}
|
||||
|
||||
/** Write a map of relative path to content into a fresh temporary directory. */
|
||||
export async function makeWorkspace(files) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'recall-test-'))
|
||||
for (const [path, content] of Object.entries(files)) {
|
||||
const absolute = join(root, path)
|
||||
await mkdir(dirname(absolute), { recursive: true })
|
||||
await writeFile(absolute, content, 'utf8')
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** A fresh temporary directory, for index state. */
|
||||
export async function makeStateDir() {
|
||||
return mkdtemp(join(tmpdir(), 'recall-state-'))
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { usageProjection } from '../index.js'
|
||||
import { deriveUsage, emptyUsage, foldUsage, usageSummary } from '../lib/usage.js'
|
||||
|
||||
/**
|
||||
* The projection contract, tested against the framework's stated requirements
|
||||
* rather than against the shape of my own code:
|
||||
*
|
||||
* - the state schema validates the persisted checkpoint before it seeds a fold,
|
||||
* so a stale or corrupt row must be *rejected*, not coerced;
|
||||
* - `apply` must return the same reference for an event it does not own, or the
|
||||
* unit republishes on every step of every session;
|
||||
* - the view is compared by `Object.is`, so one state must yield one object.
|
||||
*/
|
||||
|
||||
/** A folded state, as a conversation with a couple of searches would produce. */
|
||||
function foldedState() {
|
||||
const value = {
|
||||
action: 'search',
|
||||
ok: true,
|
||||
weak: false,
|
||||
focus: { path: 'src/auth' },
|
||||
results: [{ excerpt: 'x'.repeat(400) }, { excerpt: 'y'.repeat(200) }],
|
||||
measure: { excerpts: 2, returnedBytes: 600, sourceBytes: 24_000, files: 2 },
|
||||
}
|
||||
const event = { type: 'tool/result', meta: deriveUsage({ action: 'search' }, value) }
|
||||
return foldUsage(foldUsage(emptyUsage(), event), event)
|
||||
}
|
||||
|
||||
test('the unit owns the key the client will read', () => {
|
||||
assert.equal(usageProjection.key, 'recallUsage')
|
||||
assert.equal(usageProjection.stateVersion, 1)
|
||||
assert.ok(Number.isInteger(usageProjection.stateVersion) && usageProjection.stateVersion >= 0)
|
||||
})
|
||||
|
||||
test('init produces the empty state, and it survives a round trip', () => {
|
||||
const state = usageProjection.init()
|
||||
assert.deepEqual(state, emptyUsage())
|
||||
assert.deepEqual(usageProjection.stateSchema.parse(state), state)
|
||||
})
|
||||
|
||||
test('the state schema accepts a folded state and preserves it', () => {
|
||||
const state = foldedState()
|
||||
assert.equal(state.searches, 2)
|
||||
// Persisted as JSON and re-parsed on restore, so that round trip must hold.
|
||||
const restored = usageProjection.stateSchema.parse(JSON.parse(JSON.stringify(state)))
|
||||
assert.deepEqual(restored, state)
|
||||
})
|
||||
|
||||
test('the state schema REJECTS a corrupt checkpoint rather than coercing it', () => {
|
||||
// The service catches a parse failure and skips the row; coercing garbage
|
||||
// would forward-apply nonsense into every later total instead.
|
||||
for (const bad of [null, undefined, 42, 'state', {}, { searches: 'two' }, { ...emptyUsage(), searches: null }]) {
|
||||
assert.equal(usageProjection.stateSchema.safeParse(bad).success, false, `accepted ${JSON.stringify(bad)}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('apply returns the SAME reference for events the unit does not own', () => {
|
||||
const state = foldedState()
|
||||
assert.equal(usageProjection.apply(state, { type: 'user/message' }), state)
|
||||
assert.equal(usageProjection.apply(state, { type: 'tool/result', meta: { other: 1 } }), state)
|
||||
assert.equal(usageProjection.apply(state, { type: 'assistant/message' }), state)
|
||||
})
|
||||
|
||||
test('apply folds an event the unit does own', () => {
|
||||
const state = usageProjection.init()
|
||||
const value = {
|
||||
action: 'search',
|
||||
ok: true,
|
||||
results: [{ excerpt: 'abc' }],
|
||||
measure: { excerpts: 1, returnedBytes: 3, sourceBytes: 900, files: 1 },
|
||||
}
|
||||
const event = { type: 'tool/result', meta: deriveUsage({ action: 'search' }, value) }
|
||||
const next = usageProjection.apply(state, event)
|
||||
assert.notEqual(next, state, 'a real event must produce a new state')
|
||||
assert.equal(next.searches, 1)
|
||||
assert.equal(next.sourceBytes, 900)
|
||||
})
|
||||
|
||||
test('the view validates against its own schema', () => {
|
||||
const state = foldedState()
|
||||
const view = usageProjection.wire.view(state)
|
||||
assert.deepEqual(usageProjection.wire.viewSchema.parse(view), view)
|
||||
})
|
||||
|
||||
test('one state yields ONE view object, by reference', () => {
|
||||
// The live drive compares the two latest views with Object.is; returning a
|
||||
// fresh object per call would republish on every event that touched the state
|
||||
// even when the numbers a surface shows had not moved.
|
||||
const state = foldedState()
|
||||
const first = usageProjection.wire.view(state)
|
||||
const second = usageProjection.wire.view(state)
|
||||
assert.equal(first, second)
|
||||
})
|
||||
|
||||
test('a different state yields a different view', () => {
|
||||
const one = usageProjection.wire.view(foldedState())
|
||||
const two = usageProjection.wire.view(emptyUsage())
|
||||
assert.notEqual(one, two)
|
||||
assert.equal(one.searches, 2)
|
||||
assert.equal(two.searches, 0)
|
||||
})
|
||||
|
||||
test('the view carries the comparison and the compounding figure', () => {
|
||||
const view = usageProjection.wire.view(foldedState())
|
||||
assert.equal(view.searches, 2)
|
||||
assert.equal(view.sourceBytes, 48_000)
|
||||
assert.equal(view.returnedBytes, 1200)
|
||||
assert.equal(view.avoidedBytes, 46_800)
|
||||
// bytes/4 is an approximation and is labelled as one where it is displayed.
|
||||
assert.equal(view.avoidedTokens, Math.round(46_800 / 4))
|
||||
assert.ok(view.compression > 1)
|
||||
assert.equal(view.avoidedUsd, usageSummary(foldedState()).avoidedUsd)
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { excerptOf, queryTerms, rankChunks, splitIdentifier } from '../lib/rank.js'
|
||||
import { hashEmbed } from './helpers.js'
|
||||
|
||||
const DIMENSIONS = 128
|
||||
|
||||
/** Build a scoring matrix from a list of chunks. */
|
||||
function makeMatrix(chunks) {
|
||||
const vectors = new Float32Array(chunks.length * DIMENSIONS)
|
||||
chunks.forEach((chunk, at) => {
|
||||
vectors.set(hashEmbed(`${chunk.path} ${chunk.symbol ?? ''} ${chunk.text}`, DIMENSIONS), at * DIMENSIONS)
|
||||
})
|
||||
return {
|
||||
vectors,
|
||||
dimensions: DIMENSIONS,
|
||||
rows: chunks.map((chunk, at) => ({
|
||||
id: at + 1,
|
||||
path: chunk.path,
|
||||
startLine: chunk.startLine,
|
||||
endLine: chunk.endLine,
|
||||
...(chunk.symbol === undefined ? {} : { symbol: chunk.symbol }),
|
||||
text: chunk.text,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
test('splitIdentifier breaks camelCase and snake_case alike', () => {
|
||||
assert.deepEqual(splitIdentifier('getUserById'), ['get', 'User', 'By', 'Id'])
|
||||
assert.deepEqual(splitIdentifier('get_user_by_id'), ['get', 'user', 'by', 'id'])
|
||||
assert.deepEqual(splitIdentifier('HTTPResponseParser'), ['HTTP', 'Response', 'Parser'])
|
||||
})
|
||||
|
||||
test('queryTerms keeps identifier sub-words and drops stopwords', () => {
|
||||
const terms = queryTerms('where is the getUserById function')
|
||||
assert.ok(terms.includes('get'))
|
||||
assert.ok(terms.includes('user'))
|
||||
assert.ok(!terms.includes('the'), 'stopwords must not rank anything')
|
||||
})
|
||||
|
||||
test('a named identifier wins even against a topically similar decoy', () => {
|
||||
const matrix = makeMatrix([
|
||||
{
|
||||
path: 'src/services/auth/session.ts',
|
||||
symbol: 'issueSession',
|
||||
startLine: 1,
|
||||
endLine: 10,
|
||||
text: 'export function issueSession(userId) { return { userId, expiresAt: Date.now() } }',
|
||||
},
|
||||
{
|
||||
path: 'src/services/auth/other.ts',
|
||||
symbol: 'revokeSession',
|
||||
startLine: 1,
|
||||
endLine: 10,
|
||||
text: 'export function revokeSession(record) { record.revoked = true; return record }',
|
||||
},
|
||||
{ path: 'src/weather/forecast.ts', symbol: 'average', startLine: 1, endLine: 5, text: 'export function average(days) { return days.length }' },
|
||||
])
|
||||
const query = 'issueSession'
|
||||
const ranked = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 3 })
|
||||
assert.equal(ranked.results[0].path, 'src/services/auth/session.ts')
|
||||
assert.equal(ranked.results[0].symbol, 'issueSession')
|
||||
})
|
||||
|
||||
test('a behavioural query still finds the right file without naming it', () => {
|
||||
const matrix = makeMatrix([
|
||||
{
|
||||
path: 'src/services/auth/session.ts',
|
||||
symbol: 'issueSession',
|
||||
startLine: 1,
|
||||
endLine: 6,
|
||||
text: 'export function issueSession(userId) { const expiresAt = Date.now() + ttl; return { userId, expiresAt } }',
|
||||
},
|
||||
{ path: 'src/weather/forecast.ts', symbol: 'averageTemperature', startLine: 1, endLine: 6, text: 'export function averageTemperature(days) { return days.reduce(sum, 0) }' },
|
||||
])
|
||||
const query = 'session expiry userId expiresAt'
|
||||
const ranked = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 2 })
|
||||
assert.equal(ranked.results[0].path, 'src/services/auth/session.ts')
|
||||
})
|
||||
|
||||
test('at most perFile chunks come from one file', () => {
|
||||
const chunks = Array.from({ length: 6 }, (_, at) => ({
|
||||
path: 'src/big.ts',
|
||||
symbol: `fn_${at}`,
|
||||
startLine: at * 10 + 1,
|
||||
endLine: at * 10 + 9,
|
||||
text: `export function fn_${at}(handler) { return handler(${at}) }`,
|
||||
}))
|
||||
chunks.push({ path: 'src/other.ts', symbol: 'fn_x', startLine: 1, endLine: 5, text: 'export function fn_x(handler) { return handler }' })
|
||||
const matrix = makeMatrix(chunks)
|
||||
const query = 'fn handler'
|
||||
const ranked = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 6, perFile: 2 })
|
||||
const fromBig = ranked.results.filter((result) => result.path === 'src/big.ts')
|
||||
assert.ok(fromBig.length <= 2, `expected at most 2 from one file, got ${fromBig.length}`)
|
||||
})
|
||||
|
||||
test('excerptOf keeps the head and reports truncation', () => {
|
||||
const text = Array.from({ length: 100 }, (_, at) => `line ${at}`).join('\n')
|
||||
const short = excerptOf(text, 200)
|
||||
assert.equal(short.truncated, false)
|
||||
assert.equal(short.excerpt, text)
|
||||
const long = excerptOf(text, 10)
|
||||
assert.equal(long.truncated, true)
|
||||
assert.equal(long.excerpt.split('\n').length, 10)
|
||||
assert.equal(long.excerpt.split('\n')[0], 'line 0')
|
||||
})
|
||||
|
||||
test('rankChunks handles an empty matrix without inventing results', () => {
|
||||
const ranked = rankChunks({
|
||||
matrix: { vectors: new Float32Array(0), rows: [], dimensions: 0 },
|
||||
queryVector: new Float32Array(0),
|
||||
query: 'anything',
|
||||
})
|
||||
assert.deepEqual(ranked.results, [])
|
||||
assert.equal(ranked.vectorBest, 0)
|
||||
})
|
||||
|
||||
test('ranking is deterministic for equal scores', () => {
|
||||
const chunks = Array.from({ length: 4 }, (_, at) => ({
|
||||
path: `src/f${at}.ts`,
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
text: 'export const same = 1',
|
||||
}))
|
||||
const matrix = makeMatrix(chunks)
|
||||
const query = 'same'
|
||||
const first = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 4 })
|
||||
const second = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 4 })
|
||||
assert.deepEqual(
|
||||
first.results.map((result) => result.path),
|
||||
second.results.map((result) => result.path),
|
||||
)
|
||||
})
|
||||
test('a chunk that fits is returned whole rather than cut in half', () => {
|
||||
// The default cap equals the chunk cap, so a chunk — already a semantic unit —
|
||||
// is not truncated. Cutting below it is what forces a follow-up read.
|
||||
const body = Array.from({ length: 60 }, (_, at) => ` const line_${at} = compute(${at})`).join('\n')
|
||||
const text = `export function handler() {\n${body}\n}`
|
||||
const matrix = makeMatrix([{ path: 'src/a.ts', symbol: 'handler', startLine: 1, endLine: 62, text }])
|
||||
const ranked = rankChunks({ matrix, queryVector: hashEmbed('handler', DIMENSIONS), query: 'handler', limit: 1 })
|
||||
assert.equal(ranked.results[0].truncated, false, 'a 62-line chunk should arrive whole')
|
||||
assert.equal(ranked.results[0].excerpt, text)
|
||||
})
|
||||
|
||||
test('a per-excerpt cap still truncates, and says so', () => {
|
||||
const text = Array.from({ length: 60 }, (_, at) => `line ${at}`).join('\n')
|
||||
const matrix = makeMatrix([{ path: 'src/a.ts', startLine: 1, endLine: 60, text }])
|
||||
const ranked = rankChunks({ matrix, queryVector: hashEmbed('line', DIMENSIONS), query: 'line', limit: 1, excerptLines: 10 })
|
||||
assert.equal(ranked.results[0].truncated, true)
|
||||
assert.equal(ranked.results[0].excerpt.split('\n').length, 10)
|
||||
})
|
||||
|
||||
test('the total line budget bounds the whole set, allocating by rank', () => {
|
||||
const chunks = Array.from({ length: 6 }, (_, at) => ({
|
||||
path: `src/f${at}.ts`,
|
||||
symbol: `fn_${at}`,
|
||||
startLine: 1,
|
||||
endLine: 40,
|
||||
text: Array.from({ length: 40 }, (_, line) => `fn_${at} line ${line} handler`).join('\n'),
|
||||
}))
|
||||
const matrix = makeMatrix(chunks)
|
||||
const query = 'fn handler'
|
||||
const ranked = rankChunks({ matrix, queryVector: hashEmbed(query, DIMENSIONS), query, limit: 6, excerptLines: 40, totalLines: 90 })
|
||||
|
||||
const total = ranked.results.reduce((sum, result) => sum + result.excerpt.split('\n').length, 0)
|
||||
assert.ok(total <= 90 + 40, `budget should bind, total was ${total}`)
|
||||
assert.ok(ranked.results.length < 6, 'the budget should have stopped the set early')
|
||||
assert.ok(ranked.results.length >= 1, 'at least one result must survive any budget')
|
||||
})
|
||||
|
||||
test('at least one result is returned even with no budget at all', () => {
|
||||
const chunks = Array.from({ length: 3 }, (_, at) => ({
|
||||
path: `src/f${at}.ts`,
|
||||
startLine: 1,
|
||||
endLine: 30,
|
||||
text: Array.from({ length: 30 }, (_, line) => `handler line ${line}`).join('\n'),
|
||||
}))
|
||||
const matrix = makeMatrix(chunks)
|
||||
const ranked = rankChunks({ matrix, queryVector: hashEmbed('handler', DIMENSIONS), query: 'handler', limit: 3, totalLines: 0 })
|
||||
assert.equal(ranked.results.length, 1, 'an empty budget must still answer with something')
|
||||
assert.equal(ranked.results[0].excerpt.split('\n').length, 1, 'squeezed to the minimum, not dropped')
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { fuseRankings } from '../lib/rank.js'
|
||||
import { createReranker, parseScores } from '../lib/rerank.js'
|
||||
|
||||
/** A stub endpoint answering with a canned model response. */
|
||||
function stubFetch(response, options = {}) {
|
||||
const { hang = false, status = 200 } = options
|
||||
const calls = []
|
||||
const impl = async (url, init) => {
|
||||
calls.push({ url, body: JSON.parse(init.body) })
|
||||
if (hang) {
|
||||
return new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true })
|
||||
})
|
||||
}
|
||||
return { ok: status === 200, status, json: async () => ({ response }) }
|
||||
}
|
||||
impl.calls = calls
|
||||
return impl
|
||||
}
|
||||
|
||||
const MODEL = { apiBase: 'http://stub:11434', model: 'small-model', timeoutMs: 2_000 }
|
||||
|
||||
test('parseScores accepts the documented shape and a bare array', () => {
|
||||
assert.deepEqual(parseScores('{"scores": [10, 4, 0]}'), [10, 4, 0])
|
||||
assert.deepEqual(parseScores('Sure: [8, 2, 6]'), [8, 2, 6])
|
||||
assert.deepEqual(parseScores('{"scores":[7]}'), [7])
|
||||
})
|
||||
|
||||
test('parseScores returns undefined rather than guessing', () => {
|
||||
assert.equal(parseScores('no numbers here'), undefined)
|
||||
assert.equal(parseScores('{"scores": []}'), undefined)
|
||||
assert.equal(parseScores(''), undefined)
|
||||
assert.equal(parseScores(undefined), undefined)
|
||||
})
|
||||
|
||||
test('an unusable response is no opinion, not a zero', async () => {
|
||||
// The distinction the whole module rests on: returning zeros here would
|
||||
// replace a good embedding ranking with nothing at all.
|
||||
for (const fetchImpl of [
|
||||
async () => {
|
||||
throw new Error('ECONNREFUSED')
|
||||
},
|
||||
stubFetch('I think the first one is best.'),
|
||||
stubFetch('{"scores": [10, 5]}'), // wrong length for 3 candidates
|
||||
stubFetch('', { status: 500 }),
|
||||
]) {
|
||||
assert.equal(await createReranker({ ...MODEL, fetchImpl }).score('q', ['a', 'b', 'c']), undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('a WEDGED endpoint yields no opinion, and does not hang', async () => {
|
||||
const started = Date.now()
|
||||
const scores = await createReranker({ ...MODEL, fetchImpl: stubFetch('', { hang: true }), timeoutMs: 120 }).score('q', ['a', 'b'])
|
||||
assert.equal(scores, undefined)
|
||||
assert.ok(Date.now() - started < 3_000, 'the deadline must bound the wait')
|
||||
})
|
||||
|
||||
test('scores come back normalised and clamped, in candidate order', async () => {
|
||||
const fetchImpl = stubFetch('{"scores": [10, 0, 5, 42, -3]}')
|
||||
const scores = await createReranker({ ...MODEL, fetchImpl }).score('q', ['a', 'b', 'c', 'd', 'e'])
|
||||
assert.deepEqual(scores, [1, 0, 0.5, 1, 0], 'out-of-range values are clamped, not trusted')
|
||||
})
|
||||
|
||||
test('a non-finite timeout is clamped rather than read as no opinion', async () => {
|
||||
const fetchImpl = stubFetch('{"scores": [9, 1]}')
|
||||
const scores = await createReranker({ ...MODEL, fetchImpl, timeoutMs: Number.NaN }).score('q', ['a', 'b'])
|
||||
assert.deepEqual(scores, [0.9, 0.1])
|
||||
})
|
||||
|
||||
test('the prompt carries the query and every candidate', async () => {
|
||||
const fetchImpl = stubFetch('{"scores": [1, 1]}')
|
||||
await createReranker({ ...MODEL, fetchImpl }).score('occupancy detection', ['excerpt one', 'excerpt two'])
|
||||
const prompt = fetchImpl.calls[0].body.prompt
|
||||
assert.match(prompt, /occupancy detection/)
|
||||
assert.match(prompt, /excerpt one/)
|
||||
assert.match(prompt, /excerpt two/)
|
||||
})
|
||||
|
||||
test('nothing to score is no opinion, and no request', async () => {
|
||||
const fetchImpl = stubFetch('{"scores": []}')
|
||||
const reranker = createReranker({ ...MODEL, fetchImpl })
|
||||
assert.equal(await reranker.score('q', []), undefined)
|
||||
assert.equal(await reranker.score(' ', ['a']), undefined)
|
||||
assert.equal(fetchImpl.calls.length, 0)
|
||||
})
|
||||
|
||||
test('caller cancellation yields no opinion without a request', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const fetchImpl = stubFetch('{"scores": [1]}')
|
||||
assert.equal(await createReranker({ ...MODEL, fetchImpl }).score('q', ['a'], controller.signal), undefined)
|
||||
assert.equal(fetchImpl.calls.length, 0)
|
||||
})
|
||||
|
||||
test('fusing one ranking keeps its order', () => {
|
||||
assert.deepEqual(fuseRankings([[3, 1, 2]]), [3, 1, 2])
|
||||
})
|
||||
|
||||
test('a chunk several queries agree on outranks one that a single query liked', () => {
|
||||
// Row 7 is second for both queries; row 1 is first for one and absent from the
|
||||
// other. Agreement should win, which is the whole point of expansion.
|
||||
const fused = fuseRankings([
|
||||
[1, 7, 2],
|
||||
[5, 7, 9],
|
||||
])
|
||||
assert.equal(fused[0], 7)
|
||||
})
|
||||
|
||||
test('fusion is empty for no rankings and stable for ties', () => {
|
||||
assert.deepEqual(fuseRankings([]), [])
|
||||
assert.deepEqual(fuseRankings([[], []]), [])
|
||||
const once = fuseRankings([[2, 1, 3], [1, 2, 3]])
|
||||
const twice = fuseRankings([[2, 1, 3], [1, 2, 3]])
|
||||
assert.deepEqual(once, twice, 'the same input must give the same order')
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { RecallIndex } from '../lib/store.js'
|
||||
import { makeStateDir } from './helpers.js'
|
||||
|
||||
const IDENTITY = { model: 'test-model', dimensions: 4, apiBase: 'http://stub' }
|
||||
|
||||
/** A chunk with a hand-written vector, so assertions are exact. */
|
||||
function chunk(startLine, vector, symbol) {
|
||||
return {
|
||||
startLine,
|
||||
endLine: startLine + 2,
|
||||
...(symbol === undefined ? {} : { symbol }),
|
||||
text: `line ${startLine}`,
|
||||
vec: Float32Array.from(vector),
|
||||
}
|
||||
}
|
||||
|
||||
async function withIndex(t, work) {
|
||||
const dir = await makeStateDir()
|
||||
const index = new RecallIndex(join(dir, 'index.sqlite'))
|
||||
t.after(async () => {
|
||||
index.close()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
index.open(IDENTITY)
|
||||
return work(index)
|
||||
}
|
||||
|
||||
test('replaceFile round-trips chunks and their vectors', async (t) => {
|
||||
await withIndex(t, (index) => {
|
||||
index.replaceFile('a.ts', 'hash-a', 100, [chunk(1, [1, 0, 0, 0], 'alpha'), chunk(10, [0, 1, 0, 0], 'beta')])
|
||||
const stats = index.stats()
|
||||
assert.equal(stats.files, 1)
|
||||
assert.equal(stats.chunks, 2)
|
||||
|
||||
const matrix = index.matrix()
|
||||
assert.equal(matrix.dimensions, 4)
|
||||
assert.equal(matrix.rows.length, 2)
|
||||
assert.deepEqual(Array.from(matrix.vectors.slice(0, 4)), [1, 0, 0, 0])
|
||||
assert.equal(matrix.rows[0].symbol, 'alpha')
|
||||
assert.equal(matrix.rows[0].path, 'a.ts')
|
||||
})
|
||||
})
|
||||
|
||||
test('replacing a file drops its previous chunks rather than appending', async (t) => {
|
||||
await withIndex(t, (index) => {
|
||||
index.replaceFile('a.ts', 'hash-1', 10, [chunk(1, [1, 0, 0, 0])])
|
||||
index.replaceFile('a.ts', 'hash-2', 20, [chunk(1, [0, 1, 0, 0]), chunk(5, [0, 0, 1, 0])])
|
||||
assert.equal(index.stats().chunks, 2)
|
||||
assert.equal(index.fileHashes().get('a.ts'), 'hash-2')
|
||||
})
|
||||
})
|
||||
|
||||
test('prune forgets files that disappeared from disk', async (t) => {
|
||||
await withIndex(t, (index) => {
|
||||
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
|
||||
index.replaceFile('b.ts', 'hb', 1, [chunk(1, [0, 1, 0, 0])])
|
||||
assert.equal(index.prune(['a.ts']), 1)
|
||||
assert.equal(index.stats().files, 1)
|
||||
assert.equal(index.matrix().rows[0].path, 'b.ts')
|
||||
assert.equal(index.prune([]), 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('changing the embedding model discards the index instead of mixing spaces', async (t) => {
|
||||
await withIndex(t, (index) => {
|
||||
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
|
||||
assert.equal(index.stats().chunks, 1)
|
||||
|
||||
const outcome = index.recheck({ model: 'other-model', dimensions: 4, apiBase: 'http://stub' })
|
||||
assert.match(outcome.discarded, /embedding model changed/)
|
||||
assert.equal(index.stats().chunks, 0)
|
||||
assert.equal(index.stats().files, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('changing the embedding width discards the index', async (t) => {
|
||||
await withIndex(t, (index) => {
|
||||
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
|
||||
const outcome = index.recheck({ model: 'test-model', dimensions: 8, apiBase: 'http://stub' })
|
||||
assert.match(outcome.discarded, /embedding width changed/)
|
||||
assert.equal(index.stats().chunks, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('recheck is a no-op when nothing about the identity moved', async (t) => {
|
||||
await withIndex(t, (index) => {
|
||||
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
|
||||
assert.deepEqual(index.recheck(IDENTITY), {})
|
||||
assert.equal(index.stats().chunks, 1)
|
||||
})
|
||||
})
|
||||
|
||||
test('a write invalidates the cached matrix', async (t) => {
|
||||
await withIndex(t, (index) => {
|
||||
index.replaceFile('a.ts', 'ha', 1, [chunk(1, [1, 0, 0, 0])])
|
||||
assert.equal(index.matrix().rows.length, 1)
|
||||
index.replaceFile('b.ts', 'hb', 1, [chunk(1, [0, 1, 0, 0])])
|
||||
assert.equal(index.matrix().rows.length, 2, 'the matrix must not serve a stale cache')
|
||||
})
|
||||
})
|
||||
|
||||
test('an index with no chunks reports a zero-width matrix', async (t) => {
|
||||
await withIndex(t, (index) => {
|
||||
const matrix = index.matrix()
|
||||
assert.equal(matrix.rows.length, 0)
|
||||
assert.equal(matrix.dimensions, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { deriveUsage, emptyUsage, foldUsage, usageSummary } from '../lib/usage.js'
|
||||
|
||||
/** A tool result shaped like the ones the tool actually returns. */
|
||||
function searchValue(overrides = {}) {
|
||||
return {
|
||||
action: 'search',
|
||||
ok: true,
|
||||
focus: { path: '.', source: 'workspace' },
|
||||
weak: false,
|
||||
results: [
|
||||
{ path: 'src/auth/session.ts', excerpt: 'x'.repeat(1000), similarity: 0.6 },
|
||||
{ path: 'src/weather/forecast.ts', excerpt: 'y'.repeat(500), similarity: 0.4 },
|
||||
],
|
||||
measure: { excerpts: 2, returnedBytes: 1500, sourceBytes: 60_000, files: 2 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap a record the way the harness wraps the tool's metadata. */
|
||||
function asEvent(meta) {
|
||||
return { type: 'tool/result', turn: 1, step: 1, message: {}, meta }
|
||||
}
|
||||
|
||||
test('emptyUsage is a plain zeroed record', () => {
|
||||
const state = emptyUsage()
|
||||
assert.equal(state.searches, 0)
|
||||
assert.equal(state.sourceBytes, 0)
|
||||
// The projection stores this as plain JSON, so nothing exotic may appear.
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(state)), state)
|
||||
})
|
||||
|
||||
test('deriveUsage records a search without carrying any content', () => {
|
||||
const { recall } = deriveUsage({ action: 'search' }, searchValue())
|
||||
assert.equal(recall.action, 'search')
|
||||
assert.equal(recall.excerpts, 2)
|
||||
assert.equal(recall.returnedBytes, 1500)
|
||||
assert.equal(recall.sourceBytes, 60_000)
|
||||
assert.equal(recall.files, 2)
|
||||
assert.equal(recall.focus, '.')
|
||||
// The whole point of the record is that it is tiny: no excerpts, no query.
|
||||
const serialised = JSON.stringify(recall)
|
||||
assert.ok(!serialised.includes('x'.repeat(50)), 'excerpt text must not reach the log')
|
||||
assert.ok(serialised.length < 300, `record was ${serialised.length} bytes`)
|
||||
})
|
||||
|
||||
test('deriveUsage marks a failure and a weak result', () => {
|
||||
assert.equal(deriveUsage({ action: 'search' }, { action: 'search', ok: false }).recall.failed, 1)
|
||||
assert.equal(deriveUsage({ action: 'search' }, searchValue({ weak: true })).recall.weak, 1)
|
||||
})
|
||||
|
||||
test('deriveUsage records an index without pretending it returned excerpts', () => {
|
||||
const { recall } = deriveUsage({ action: 'index' }, { action: 'index', ok: true, index: { embedded: 4 } })
|
||||
assert.equal(recall.action, 'index')
|
||||
assert.equal(recall.embedded, 4)
|
||||
assert.equal(recall.excerpts, undefined)
|
||||
})
|
||||
|
||||
test('foldUsage returns the SAME reference for events it does not care about', () => {
|
||||
const state = emptyUsage()
|
||||
// A fresh object here would republish the projection on every step of every
|
||||
// session, which is why the framework states this requirement explicitly.
|
||||
assert.equal(foldUsage(state, { type: 'user/message' }), state)
|
||||
assert.equal(foldUsage(state, { type: 'tool/result', meta: {} }), state)
|
||||
assert.equal(foldUsage(state, { type: 'tool/result' }), state)
|
||||
assert.equal(foldUsage(state, { type: 'tool/result', meta: { other: 1 } }), state)
|
||||
assert.equal(foldUsage(state, { type: 'tool/result', meta: { recall: null } }), state)
|
||||
})
|
||||
|
||||
test('foldUsage accumulates one conversation', () => {
|
||||
let state = emptyUsage()
|
||||
state = foldUsage(state, asEvent(deriveUsage({}, searchValue())))
|
||||
state = foldUsage(state, asEvent(deriveUsage({}, searchValue())))
|
||||
state = foldUsage(state, asEvent(deriveUsage({ action: 'index' }, { action: 'index', index: { embedded: 3 } })))
|
||||
|
||||
assert.equal(state.searches, 2)
|
||||
assert.equal(state.indexes, 1)
|
||||
assert.equal(state.excerpts, 4)
|
||||
assert.equal(state.returnedBytes, 3000)
|
||||
assert.equal(state.sourceBytes, 120_000)
|
||||
assert.equal(state.files, 4)
|
||||
})
|
||||
|
||||
test('a fold and a direct derive agree on the same numbers', () => {
|
||||
// Guards against the two paths drifting: whatever the tool writes must be
|
||||
// what the running total adds up to.
|
||||
const value = searchValue()
|
||||
const folded = foldUsage(emptyUsage(), asEvent(deriveUsage({}, value)))
|
||||
const summary = usageSummary(folded)
|
||||
assert.equal(summary.searches, 1)
|
||||
assert.equal(summary.sourceBytes, value.measure.sourceBytes)
|
||||
assert.equal(summary.returnedBytes, value.measure.returnedBytes)
|
||||
})
|
||||
|
||||
test('usageSummary reports the comparison, and calls it an upper bound', () => {
|
||||
const state = foldUsage(emptyUsage(), asEvent(deriveUsage({}, searchValue())))
|
||||
const summary = usageSummary(state)
|
||||
assert.equal(summary.avoidedBytes, 58_500)
|
||||
assert.equal(summary.avoidedTokens, Math.round(58_500 / 4))
|
||||
assert.equal(summary.compression, 40)
|
||||
// The dollars are real rather than rounded to nothing: the direct saving is
|
||||
// small, which is exactly why the surface must not lead with it.
|
||||
assert.ok(summary.avoidedUsd > 0)
|
||||
})
|
||||
|
||||
test('each step re-reads the avoided context, so the fold compounds it', () => {
|
||||
let state = foldUsage(emptyUsage(), asEvent(deriveUsage({}, searchValue())))
|
||||
const after = usageSummary(state)
|
||||
assert.equal(after.rereadTokens, 0, 'nothing has been re-read yet')
|
||||
|
||||
// Every step after the search re-reads what the conversation is carrying.
|
||||
for (let step = 0; step < 100; step += 1) state = foldUsage(state, { type: 'step/start' })
|
||||
const later = usageSummary(state)
|
||||
assert.equal(later.rereadTokens, after.avoidedTokens * 100)
|
||||
assert.ok(later.avoidedUsd > after.avoidedUsd)
|
||||
assert.equal(later.avoidedUsd, Number((((after.avoidedTokens + later.rereadTokens) / 1e6) * 0.007).toFixed(4)))
|
||||
})
|
||||
|
||||
test('a step before anything was avoided adds nothing', () => {
|
||||
const state = emptyUsage()
|
||||
assert.equal(foldUsage(state, { type: 'step/start' }), state, 'and keeps the same reference')
|
||||
})
|
||||
|
||||
test('usageSummary survives a conversation with no calls', () => {
|
||||
const summary = usageSummary(emptyUsage())
|
||||
assert.equal(summary.searches, 0)
|
||||
assert.equal(summary.avoidedTokens, 0)
|
||||
assert.equal(summary.compression, 0, 'no division by zero')
|
||||
assert.equal(summary.avoidedUsd, 0)
|
||||
})
|
||||
|
||||
test('an excerpt larger than its source cannot produce a negative saving', () => {
|
||||
const state = foldUsage(emptyUsage(), asEvent({
|
||||
recall: { action: 'search', excerpts: 1, returnedBytes: 900, sourceBytes: 100 },
|
||||
}))
|
||||
assert.equal(usageSummary(state).avoidedBytes, 0)
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { mkdtemp, mkdir, symlink, writeFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { DEFAULT_EXTENSIONS, extensionOf, globToRegExp, listCodeFiles, matchesAny } from '../lib/walk.js'
|
||||
|
||||
test('globToRegExp anchors and handles star and double-star', () => {
|
||||
assert.equal(globToRegExp('src/*.ts').test('src/a.ts'), true)
|
||||
assert.equal(globToRegExp('src/*.ts').test('src/nested/a.ts'), false)
|
||||
assert.equal(globToRegExp('**/*.ts').test('a/b/c.ts'), true)
|
||||
assert.equal(globToRegExp('**/*.ts').test('c.ts'), true)
|
||||
assert.equal(globToRegExp('src/**').test('src/a/b/c.ts'), true)
|
||||
assert.equal(globToRegExp('dist/').test('dist/a/b.js'), true)
|
||||
assert.equal(globToRegExp('src/a?.ts').test('src/ab.ts'), true)
|
||||
assert.equal(globToRegExp('src/a?.ts').test('src/abc.ts'), false)
|
||||
// Dots must be literal, not wildcards.
|
||||
assert.equal(globToRegExp('a.ts').test('axts'), false)
|
||||
})
|
||||
|
||||
test('matchesAny ignores empty patterns', () => {
|
||||
assert.equal(matchesAny('a/b.ts', ['']), false)
|
||||
assert.equal(matchesAny('a/b.ts', ['', 'a/*.ts']), true)
|
||||
})
|
||||
|
||||
test('extensionOf lowercases and rejects dotfiles', () => {
|
||||
assert.equal(extensionOf('a/B.TS'), 'ts')
|
||||
assert.equal(extensionOf('.gitignore'), '')
|
||||
assert.equal(extensionOf('Makefile'), '')
|
||||
})
|
||||
|
||||
test('listCodeFiles returns a deterministic sorted list of code files only', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'recall-walk-'))
|
||||
try {
|
||||
await mkdir(join(root, 'src', 'nested'), { recursive: true })
|
||||
await mkdir(join(root, 'node_modules', 'dep'), { recursive: true })
|
||||
await writeFile(join(root, 'src', 'b.ts'), 'export const b = 1\n')
|
||||
await writeFile(join(root, 'src', 'a.ts'), 'export const a = 1\n')
|
||||
await writeFile(join(root, 'src', 'nested', 'c.py'), 'c = 1\n')
|
||||
await writeFile(join(root, 'README.md'), '# not code\n')
|
||||
await writeFile(join(root, 'node_modules', 'dep', 'index.js'), 'module.exports = 1\n')
|
||||
|
||||
const { files } = await listCodeFiles(root)
|
||||
assert.deepEqual(
|
||||
files.map((file) => file.path),
|
||||
['src/a.ts', 'src/b.ts', 'src/nested/c.py'],
|
||||
)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listCodeFiles honours an extension filter and an exclude glob', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'recall-walk-'))
|
||||
try {
|
||||
await mkdir(join(root, 'src', 'generated'), { recursive: true })
|
||||
await writeFile(join(root, 'src', 'a.ts'), 'a\n')
|
||||
await writeFile(join(root, 'src', 'generated', 'b.ts'), 'b\n')
|
||||
await writeFile(join(root, 'src', 'c.py'), 'c\n')
|
||||
|
||||
const onlyTs = await listCodeFiles(root, { extensions: ['ts'] })
|
||||
assert.deepEqual(onlyTs.files.map((file) => file.path), ['src/a.ts', 'src/generated/b.ts'])
|
||||
|
||||
const excluded = await listCodeFiles(root, { exclude: ['src/generated/'] })
|
||||
assert.deepEqual(excluded.files.map((file) => file.path), ['src/a.ts', 'src/c.py'])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listCodeFiles reports truncation instead of silently dropping files', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'recall-walk-'))
|
||||
try {
|
||||
for (let at = 0; at < 5; at += 1) await writeFile(join(root, `f${at}.ts`), `${at}\n`)
|
||||
const { files, truncated } = await listCodeFiles(root, { maxFiles: 2 })
|
||||
assert.equal(files.length, 2)
|
||||
assert.equal(truncated, true)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('listCodeFiles does not follow symlinks', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'recall-walk-'))
|
||||
const outside = await mkdtemp(join(tmpdir(), 'recall-outside-'))
|
||||
try {
|
||||
await writeFile(join(outside, 'secret.ts'), 'export const secret = 1\n')
|
||||
await writeFile(join(root, 'real.ts'), 'export const real = 1\n')
|
||||
await symlink(outside, join(root, 'linked'), 'dir')
|
||||
|
||||
const { files } = await listCodeFiles(root)
|
||||
assert.deepEqual(files.map((file) => file.path), ['real.ts'])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('the default extension set is code, not prose', () => {
|
||||
assert.ok(DEFAULT_EXTENSIONS.includes('ts'))
|
||||
assert.ok(DEFAULT_EXTENSIONS.includes('py'))
|
||||
assert.ok(!DEFAULT_EXTENSIONS.includes('md'))
|
||||
})
|
||||
Reference in new issue
Block a user