fix!: restore the settings page against the harness 0.1.7 plugin contracts

The configuration page registered into seats that 0.1.7 removed, so it could
not appear at all — and the schema carried none of the metadata the new
settings document requires.

Host half. `ctx.settings.installSection` no longer exists: a plugin exports a
`Config` schema, the Harness keys one form per composed entry by that row's
`id`, and `ctx.settings.describe` skips an entry with no `volatile()` field
outright. Every field is now volatile, which is also what makes a saved edit
apply to the running instance instead of remounting it — so `apply` reads the
section through `settingsSnapshot`, which unwraps the live references a
volatile field arrives as. Writes go through `ctx.settings.update` fenced by
the revision `describe` reports. The `.min`/`.max` bounds the schema can state
are stated there, so the form refuses an impossible draft in front of the
person typing it; `validateSettings` keeps the cross-field rules no single
field can express, and now runs on the section a call is about to act on
rather than at write time, because 0.1.7 has no write-time validator hook.

Browser half. `settingsScope` and the `settings.plugin.item` slot are gone. The
page renders through `plugins.row.config`, keyed `<bundle package>#<row id>`
(`dsh-recall#dsh-recall`), which is what puts a Configure control on the
plugin's row in the Plugins panel. It uses the framework's own staged form —
`SettingsFormModel` over `ctx.configForms`, drawn by `SettingsForm` and
`SettingsValueField`/`SettingsSecretField` — instead of reproducing card chrome
by hand, so the unsaved, overridden, invalid, and save states come from the
same code as every other plugin's page. All 33 settings are editable now; the
old card exposed 23. The right-sidebar tab also needed the stage-one tab TYPE
(`ctx.sidebarRightTabs.register`) that its keyed body slot is dispatched under,
so a tab can finally be opened. `--dsh-border`, defined nowhere, becomes the
product's `--dsw-alias-border-l2`.

Both halves are now pinned by tests: every field is volatile, the field list
and the schema cannot drift, the page claims the key the patch declares, and a
blank API key is never written — the secret control reports configured-and-
absent instead.

Verified against the harness rather than only in unit tests: `--dump-config-
schema` emits the volatile and secret metadata, `apply` registers `recall` on
the real tool registry, `settings.describe({redactSecrets:true})` serves
`dsh-recall` with 33 fields and the key reported set-but-redacted, and a
scratch profile boots the web app with the plugin composed and its bundle
served byte-for-byte as this checkout has it.
This commit is contained in:
NotExpectedYet committed 2026-09-26 06:37:57 +01:00
1 parent 0964819cdc
commit 040c69b57d
7 files changed
+1352 -668

No files matched your search

+434 -207
View File
@@ -2,20 +2,34 @@
* 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:
* module loader, so it can be exercised in Node by stubbing `window`, `react`,
* and the one primitives package it imports. That is worth doing rather than
* leaving the page to be eyeballed in a running app, because the failures this
* file exists to catch are structural and invisible until someone opens Plugins:
*
* - 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.
* - the configuration page is registered into `plugins.row.config` under
* `<bundle package>#<row id>`. The 0.1.5 seat (`settings.plugin.item`, bound
* through `settingsScope`) does not exist in 0.1.7, so a page left in it
* registers against a slot nobody declares and simply never appears.
* - the right-sidebar tab needs a TYPE registered before its body means
* anything: the keyed body slot is dispatched under the definition's `id`.
* - the field list here and the `Config` schema in index.js must name the same
* settings, or a setting is silently uneditable.
*
* Two things are stubbed rather than real, and the tests that depend on them say
* so. `SettingsFormModel` and the field controls come from
* `@deepseek-ai/dsh-client-ui-primitives`, whose bundle imports CSS and is
* therefore not loadable in bare Node; the stub records what this plugin hands
* the framework and answers the same questions the real one does. What the
* framework is trusted for — staging, save ordering, revision fencing, the
* rendered chrome — is covered by the harness's own suite, not here.
*/
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { SETTING_KEYS } from '../index.js'
/** A React good enough to run one component: element, state, effect, memo. */
function makeReact() {
const hooks = []
@@ -53,8 +67,8 @@ function makeReact() {
/**
* 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'`
* The card renders its controls as `h(Field, …)`, and those elements are only
* real nodes once React calls them — so a walk that stops at `type === 'input'`
* finds nothing without this.
*/
function expand(node) {
@@ -73,31 +87,114 @@ function find(node, 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]
}
/**
* Stand-ins for the framework's settings-form kit.
*
* The model records the specs and secret writers this plugin hands it, and
* answers `shell`/`field`/`plan` the way the real one does; the controls render
* plain nodes carrying the props they were given.
*/
function makePrimitives(record) {
class SettingsFormModel {
constructor(scope, specs, secrets = []) {
this.scope = scope
this.specs = specs
this.secrets = secrets
this.staged = new Map()
record.model = this
}
/** The save button, whatever its current label. */
function saveButton(tree) {
return find(tree, (node) => node.type === 'button' && String(node.props.children).startsWith('Save'))[0]
}
bind(project) {
this.project = project
return { getSnapshot: () => project(), subscribe: () => () => {} }
}
shell() {
return {
available: this.scope.getSnapshot().status === 'ready',
writable: this.scope.getSnapshot().writable,
dirty: this.staged.size > 0,
invalid: [...this.staged].some(([name, text]) => {
if (this.secrets.some((entry) => entry.field === name)) return false
return this.specs.find((entry) => entry.field === name).parse(text) === undefined
}),
saving: false,
failed: false,
}
}
field(name) {
if (this.secrets.some((entry) => entry.field === name)) {
return { text: this.staged.get(name) ?? '', overridden: false, invalid: false }
}
const spec = this.specs.find((entry) => entry.field === name)
const text = this.staged.has(name)
? this.staged.get(name)
: spec.format(this.scope.getSnapshot().value?.[name])
const write = spec.parse(text)
const user = this.scope.getSnapshot().user
return {
text,
overridden: this.staged.has(name) ? write?.kind === 'set' : Object.hasOwn(user ?? {}, name),
invalid: write === undefined,
}
}
actions() {
return {
edit: (name, text) => {
this.staged.set(name, text)
record.edits.push({ name, text })
},
resetField: (name) => this.staged.set(name, undefined),
save: () => {
record.saved = true
},
discard: () => this.staged.clear(),
}
}
dispose() {
record.disposed = true
}
/** What a save would write, built from this plugin's own spec objects. */
plan() {
const plan = []
for (const [name, text] of this.staged) {
if (this.secrets.some((entry) => entry.field === name)) {
plan.push({ field: name, run: () => this.secrets.find((s) => s.field === name).write(String(text)) })
continue
}
const write = this.specs.find((entry) => entry.field === name).parse(String(text))
if (write === undefined) plan.push({ field: name })
else if (write.kind === 'clear') plan.push({ field: name, op: { op: 'unset', path: [name] } })
else plan.push({ field: name, op: { op: 'set', path: [name], value: write.value } })
}
return plan
}
}
/** 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 })
},
SettingsForm: (props) => ({ type: 'form', props }),
SettingsValueField: (props) => ({ type: 'input', props }),
SettingsSecretField: (props) => ({ type: 'input', props: { ...props, type: 'password' } }),
SettingsFormModel,
settingsNumberField: (field) => ({
field,
format: (value) => (value === undefined || value === null ? '' : String(value)),
parse: (text) =>
String(text).trim() === ''
? { kind: 'clear' }
: Number.isFinite(Number(text))
? { kind: 'set', value: Number(text) }
: undefined,
}),
settingsTextField: (field) => ({
field,
format: (value) => (value === undefined || value === null ? '' : String(value)),
parse: (text) => (String(text).trim() === '' ? { kind: 'clear' } : { kind: 'set', value: text }),
}),
}
}
@@ -108,20 +205,28 @@ await import('../client/client.js')
const entry = loaded[0]
/** Instantiate the module with a stubbed `react`. */
function makeModule() {
/**
* Instantiate the bundle, mount it on a stubbed context, and expose what it
* registered.
*
* @param {object} options `services` mounts extra optional dependencies,
* `served` is what the (stubbed) describe mirror reports, and
* `withConfigForms: false` models a deployment with no settings provider.
*/
function mount(options = {}) {
const { services = {}, served = ['dsh-recall'], withConfigForms = true } = options
const record = { edits: [], saved: false, disposed: false, model: undefined }
const react = makeReact()
const primitives = makePrimitives(record)
const factory = entry.factory((name) => {
if (name === 'react') return react
if (name === '@deepseek-ai/dsh-client-ui-primitives') return primitives
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 tabTypes = []
const whileServedCalls = []
const slots = {
inject: (name, fn) => fn(),
register: (meta, component) => {
@@ -129,49 +234,122 @@ function mount(scope) {
return () => {}
},
}
const form = {
calls: [],
snapshot: { status: 'ready', value: {}, base: {}, user: {}, writable: true, revision: 9 },
getSnapshot() {
return this.snapshot
},
subscribe: () => () => {},
async mutate(ops, revision) {
this.calls.push({ ops, revision })
return true
},
}
const describe = {
getSnapshot: () => ({
status: 'ready',
view: {
writable: true,
namespaces: served.map((ns) => ({ ns, secrets: [{ path: ['embeddingApiKey'], set: true }] })),
},
}),
subscribe: () => () => {},
}
const configForms = {
requested: undefined,
get(id) {
this.requested = id
return form
},
describe: () => describe,
whileServed: (namespaces, register) => {
whileServedCalls.push({ namespaces })
const live = namespaces.some((ns) => served.includes(ns))
const off = live ? register(new Set(served)) : undefined
return () => off?.()
},
}
const sidebarRightTabs =
services.sidebarRightTabs ??
{
register: (definition) => {
tabTypes.push(definition)
return () => {}
},
}
const ctx = {
slots,
inject: (deps, cb) =>
cb({
settingsScope: {
bind: (options) => {
assert.equal(options.namespace, 'dsh-recall')
return scope
},
},
slots,
}),
effect: (fn) => {
const dispose = fn()
if (typeof dispose === 'function') record.disposables = [...(record.disposables ?? []), dispose]
},
inject: (deps, callback) => {
const available = {
...services,
sidebarRightTabs,
...(withConfigForms ? { configForms } : {}),
}
const missing = deps.filter((dep) => available[dep] === undefined)
if (missing.length > 0) return
callback({ ...ctx, ...Object.fromEntries(deps.map((dep) => [dep, available[dep]])) })
},
}
module.apply(ctx)
const byName = new Map(registered.map((entry) => [entry.meta.name, entry]))
factory.apply(ctx)
const byName = new Map(registered.map((item) => [item.meta.name, item]))
/** 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}`)
const item = byName.get(name)
assert.ok(item !== undefined, `nothing registered for ${name}`)
react.__render()
const element = entry.component(props)
const element = item.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)
/**
* The settings page's element plus the props it would receive.
*
* `hooks` is resolved into `use<Name>` here exactly as the slot machinery
* does, so the component under test sees the props a real render would give it.
*/
const card = (view) => {
const item = byName.get('plugins.row.config')
assert.ok(item !== undefined, 'no settings page was registered')
const props = { ...item.meta.inject(), view }
delete props.hooks
props.useRecallSettings = (selector) => selector(record.model.project())
return { item, props, element: item.component(props) }
}
return { react, module, meta: byName.get('settings.plugin.item').meta, registered, render, renderSlot }
return {
react,
module: factory,
record,
form,
registered,
tabTypes,
whileServedCalls,
renderSlot,
card,
configForms,
}
}
/** 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]
/** The rendered page's control nodes, keyed by their HTML id. */
function controlsOf(element) {
const form = expand(element)
const controls = new Map()
for (const node of find(form, (candidate) => candidate.type === 'input')) controls.set(node.props.id, node)
return controls
}
test('the bundle registers under the loader with the plugin id', () => {
@@ -180,146 +358,203 @@ test('the bundle registers under the loader with the plugin id', () => {
assert.equal(typeof entry.factory, 'function')
})
test('the client module claims slots and reports the host namespace', () => {
const { module } = makeModule()
test('the client module claims the slot registry in its inject list', () => {
const { module } = mount()
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 configuration page claims the 0.1.7 seat, keyed as the patch declares it', () => {
const { registered } = mount()
const page = registered.find((item) => item.meta.name === 'plugins.row.config')
assert.ok(page !== undefined, 'the page must register into plugins.row.config')
// `<bundle package name>#<row id>`: the row id is the one this bundle's own
// cordis.patch.yml declares, which is what the Plugins page dispatches by.
assert.equal(page.meta.key, 'dsh-recall#dsh-recall')
assert.equal(typeof page.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 page edits the settings entry the Host keys this plugin under', () => {
const { configForms, whileServedCalls } = mount()
assert.equal(configForms.requested, 'dsh-recall')
assert.deepEqual(whileServedCalls, [{ namespaces: ['dsh-recall'] }])
})
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('no page is registered for a namespace the Host does not serve', () => {
// A control that writes nowhere is worse than no control: the page would open
// onto fields nothing would accept.
const { registered } = mount({ served: ['some-other-plugin'] })
assert.equal(
registered.some((item) => item.meta.name === 'plugins.row.config'),
false,
)
})
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, [
test('the chip and the sidebar tab survive a deployment with no settings provider', () => {
// The page is reached through `ctx.inject`, not a top-level `inject`, so a
// profile without a settings provider loses the page and keeps the rest.
const { registered, tabTypes } = mount({ withConfigForms: false })
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner',
'settings.plugin.item',
'sidebar.right.pane.tab',
'sidebar.right.pane.tab.title',
])
assert.equal(tabTypes.length, 1, 'the tab type is independent of settings')
})
test('the header chip is a single-seat contribution with no key', () => {
const { registered } = mount()
const chip = registered.find((item) => item.meta.name === 'conversation.session.header.corner')
assert.ok(chip !== undefined)
assert.equal(chip.meta.key, undefined, 'the corner is a single seat, so it takes no key')
})
test('the sidebar tab registers a type AND a body under the same id', () => {
// The body slot is dispatched under the id of the type in force for a tab's
// kind. A body with no type is a page nothing can open.
const { registered, tabTypes } = mount()
const body = registered.find((item) => item.meta.name === 'sidebar.right.pane.tab')
assert.ok(body !== undefined)
assert.equal(body.meta.key, 'dsh-recall')
assert.equal(tabTypes.length, 1, 'exactly one tab type')
assert.equal(tabTypes[0].id, 'dsh-recall', "the body's key must be the definition's id")
assert.equal(tabTypes[0].kind, 'dsh-recall')
assert.equal(tabTypes[0].priority, 'extension', 'a type from outside the product sits in that band')
assert.equal(typeof tabTypes[0].title, 'function')
assert.equal(tabTypes[0].title(), 'Recall')
// A page type recognises no address, so the guide entry is the only way in.
assert.equal(tabTypes[0].guide.length, 1)
assert.equal(tabTypes[0].guide[0].title(), 'Recall')
assert.match(tabTypes[0].guide[0].description(), /kept out/)
})
test('the browser half contributes three seats, none of them the retired 0.1.5 one', () => {
const { registered } = mount()
assert.deepEqual(registered.map((item) => item.meta.name).sort(), [
'conversation.session.header.corner',
'plugins.row.config',
'sidebar.right.pane.tab',
])
})
test('the page asks for nothing to be edited before the user types', () => {
const { card } = mount()
const { element } = card('page')
const state = find(element, (node) => node.type === 'form')[0]
assert.equal(state.props.state.dirty, false)
assert.equal(state.props.state.invalid, false)
assert.equal(state.props.state.writable, true)
assert.equal(typeof state.props.onSave, 'function')
assert.equal(typeof state.props.onDiscard, 'function')
assert.equal(state.props.labels.save, 'Save')
})
test('the summary view is the one-liner the Plugins page falls back to', () => {
const { card } = mount()
// The page asks for `summary` to fill a row with no description of its own,
// so it must be text, and it must describe the plugin rather than the form.
const summary = card('summary').element
assert.equal(typeof summary, 'string')
assert.match(summary, /Local embedding endpoint/)
// And the page view is the form, not that string.
assert.notEqual(typeof card('page').element, 'string')
})
test('every declared setting renders a control, and the API key renders as a secret', () => {
const { card } = mount()
const controls = controlsOf(card('page').element)
assert.deepEqual(
[...controls.keys()].sort(),
SETTING_KEYS.map((key) => `dsh-recall-${key}`).sort(),
'every setting in the host schema must be editable here, and nothing else',
)
const secret = controls.get('dsh-recall-embeddingApiKey')
assert.equal(secret.props.type, 'password')
assert.equal(secret.props.text, '', 'a redacted secret renders blank')
// The page learns whether a key is stored, never the key itself.
assert.equal(secret.props.configured, true)
assert.match(secret.props.stateLabel, /stored/)
})
test('the field list and the schema cannot drift apart', () => {
const { record } = mount()
assert.deepEqual(
record.model.specs.map((spec) => spec.field).sort(),
SETTING_KEYS.filter((key) => key !== 'embeddingApiKey').sort(),
'every non-secret setting needs a conversion spec',
)
assert.deepEqual(record.model.secrets.map((spec) => spec.field), ['embeddingApiKey'])
})
test('a list setting round-trips as comma separated text', () => {
const { record } = mount()
const spec = record.model.specs.find((entry) => entry.field === 'exclude')
assert.equal(spec.format(['dist/', 'vendor/']), 'dist/, vendor/')
assert.deepEqual(spec.parse('dist/, vendor/, tmp/'), { kind: 'set', value: ['dist/', 'vendor/', 'tmp/'] })
assert.deepEqual(spec.parse(''), { kind: 'clear' }, 'emptying the control reverts to the schema default')
})
test('a boolean setting accepts the obvious spellings and refuses the rest', () => {
const { record } = mount()
const spec = record.model.specs.find((entry) => entry.field === 'rerankEnabled')
assert.equal(spec.format(true), 'true')
assert.equal(spec.format(false), 'false')
assert.deepEqual(spec.parse('TRUE'), { kind: 'set', value: true })
assert.deepEqual(spec.parse('off'), { kind: 'set', value: false })
assert.deepEqual(spec.parse(''), { kind: 'clear' })
// Anything else blocks the save rather than guessing which way the user meant.
assert.equal(spec.parse('maybe'), undefined)
})
test('an invalid draft is reported, so the framework can block the save', () => {
const { record } = mount()
record.model.actions().edit('embeddingConcurrency', 'lots')
assert.equal(record.model.field('embeddingConcurrency').invalid, true)
assert.equal(record.model.shell().invalid, true)
})
test('an edit is staged in the model, never written to the document on the spot', () => {
const { record, form } = mount()
record.model.actions().edit('embeddingConcurrency', '4')
assert.deepEqual(record.edits, [{ name: 'embeddingConcurrency', text: '4' }])
assert.deepEqual(form.calls, [], 'only a save writes')
})
test('the API key is written through the document, fenced by the current revision', async () => {
const { record, form } = mount()
const secret = record.model.secrets[0]
assert.equal(secret.field, 'embeddingApiKey')
await secret.write('sk-secret')
assert.deepEqual(form.calls, [
{ ops: [{ op: 'set', path: ['embeddingApiKey'], value: 'sk-secret' }], revision: 9 },
])
})
test('a blank secret is never staged, so opening the page cannot delete the key', () => {
const { record } = mount()
assert.deepEqual(record.model.plan(), [], 'nothing typed means nothing to write')
})
test('a typed secret is planned as a write outside the section, not as a section op', () => {
const { record } = mount()
record.model.actions().edit('embeddingApiKey', 'sk-secret')
const plan = record.model.plan()
assert.equal(plan.length, 1)
assert.equal(plan[0].field, 'embeddingApiKey')
assert.equal(plan[0].op, undefined, 'a secret rides its own writer, because the wire never returns it')
})
test('a later revision is carried by the next write rather than the one staged from', async () => {
const { record, form } = mount()
form.snapshot = { ...form.snapshot, revision: 12 }
await record.model.secrets[0].write('sk-newer')
assert.equal(form.calls[0].revision, 12, 'every write carries the revision it was staged against')
})
test('the form subscription is released with the plugin', () => {
const { record } = mount()
for (const dispose of record.disposables ?? []) dispose()
// The controller's disposer runs the model's, so a reload leaks no listener.
assert.equal(record.disposed, true)
})
/** A projection value shaped like the host unit's wire view. */
@@ -344,7 +579,7 @@ function usageView(overrides = {}) {
}
test('the header chip renders nothing when the conversation never used it', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
// 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)
@@ -354,7 +589,7 @@ test('the header chip renders nothing when the conversation never used it', () =
})
test('the header chip leads with what was kept out, not what it cost', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView() })
assert.ok(chip !== null)
const text = String(chip.props.children)
@@ -367,7 +602,7 @@ test('the header chip leads with what was kept out, not what it cost', () => {
})
test('the chip tooltip carries the figures the chip has no room for', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
const chip = renderSlot('conversation.session.header.corner', { useProjection: () => usageView({ rereadTokens: 500_000 }) })
const detail = chip.props.title
assert.match(detail, /3 searches/)
@@ -377,7 +612,7 @@ test('the chip tooltip carries the figures the chip has no room for', () => {
})
test('the sidebar panel explains an unused conversation instead of showing zeroes', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
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/)
@@ -385,13 +620,13 @@ test('the sidebar panel explains an unused conversation instead of showing zeroe
})
test('the sidebar panel says so when the projection is absent', () => {
const { renderSlot } = mount(makeScope())
const { renderSlot } = mount()
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 { renderSlot } = mount()
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/)
@@ -400,11 +635,3 @@ test('the sidebar panel reports the comparison and the compounding', () => {
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')
})
+80 -19
View File
@@ -1,15 +1,21 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { Config, SETTING_KEYS, validateSettings } from '../index.js'
import { Config, SETTING_KEYS, configValue, settingsSnapshot, validateSettings } from '../index.js'
/**
* The load-time contract.
* The load-time contract, and the shape settings arrive in.
*
* `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.
*
* There are two contracts here, not one. The first is validation. The second is
* that a field marked `volatile()` arrives as a live reference rather than a
* value — so a schema edit changes both what the plugin validates against and
* how it must be read. Both are asserted, because getting the second wrong
* looks like a config of `undefined`s rather than like an error.
*/
test('Config is a Standard Schema, which is what Cordis requires', () => {
@@ -17,29 +23,35 @@ test('Config is a Standard Schema, which is what Cordis requires', () => {
assert.ok(Config['~standard'] !== undefined, 'Config must implement the Standard Schema interface')
})
/** The section as the plugin reads it: schema-resolved, then unwrapped. */
function resolve(patch = {}) {
return settingsSnapshot(Config(patch))
}
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, [])
const resolved = settingsSnapshot(filled)
assert.equal(resolved.embeddingApiBase, 'http://127.0.0.1:11434')
assert.equal(resolved.embeddingDimensions, 768)
assert.equal(resolved.syncBudgetMs, 20_000)
assert.deepEqual(resolved.exclude, [])
assert.deepEqual(resolved.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')
const resolved = resolve({ embeddingApiBase: 'http://10.50.1.2:11434', embeddingConcurrency: 8 })
assert.equal(resolved.embeddingApiBase, 'http://10.50.1.2:11434')
assert.equal(resolved.embeddingConcurrency, 8)
assert.equal(resolved.embeddingBatchSize, 16)
assert.equal(resolved.embeddingTimeoutMs, 30_000)
assert.equal(resolved.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)
assert.equal(resolved.excerptLines, 80)
assert.equal(resolved.totalExcerptLines, 400)
assert.equal(resolved.maxChunkLines, 80)
})
test('a wrongly typed value fails loudly at load rather than silently in apply', () => {
@@ -48,6 +60,48 @@ test('a wrongly typed value fails loudly at load rather than silently in apply',
assert.throws(() => Config({ embeddingApiBase: { host: 'localhost' } }))
})
test('a value outside a field range is refused by the schema, not just by validateSettings', () => {
// These bounds are duplicated in validateSettings on purpose, but the copy
// that matters most is this one: it is what the generated settings form
// validates a draft against before the Host is ever asked to store it.
assert.throws(() => Config({ minSimilarity: 1.5 }), /<= 1/)
assert.throws(() => Config({ chunkLines: 0 }), />= 1/)
assert.throws(() => Config({ overlapLines: -1 }), />= 0/)
assert.throws(() => Config({ embeddingDimensions: -1 }), />= 1/)
})
test('every declared field is volatile, or the settings surface is not served', () => {
// `ctx.settings.describe` skips an entry with no volatile field, so this is
// the difference between a configuration page and no configuration page —
// not between a full page and an empty one.
const envelope = JSON.parse(JSON.stringify(Config.toJSON()))
const root = envelope.refs[String(envelope.uid)]
const notVolatile = Object.entries(root.dict)
.filter(([, uid]) => envelope.refs[String(uid)].meta?.volatile !== true)
.map(([key]) => key)
assert.deepEqual(notVolatile, [], 'every field must be volatile')
})
test('a volatile field reads through configValue; a plain one is handed back', () => {
const filled = Config({ embeddingApiBase: 'http://stub' })
assert.equal(typeof filled.embeddingApiBase, 'object', 'a volatile field arrives as a reference')
assert.equal(configValue(filled.embeddingApiBase), 'http://stub')
// A test (or any caller) that hands `apply` a plain resolved object must keep
// working: the same reader serves both shapes.
assert.equal(configValue('http://plain'), 'http://plain')
assert.equal(configValue(undefined), undefined)
assert.equal(configValue(0), 0)
})
test('a snapshot tracks a live edit, because volatile references update in place', () => {
const filled = Config({ embeddingConcurrency: 2 })
assert.equal(settingsSnapshot(filled).embeddingConcurrency, 2)
// This is what the Loader does on a volatile-only config change: write the new
// value into the reference the running instance already holds.
filled.embeddingConcurrency[Object.getOwnPropertySymbols(filled.embeddingConcurrency)[0]](8)
assert.equal(settingsSnapshot(filled).embeddingConcurrency, 8)
})
test('every declared setting is a real schema key, so none is dead config', () => {
const filled = Config({})
for (const key of SETTING_KEYS) {
@@ -60,13 +114,20 @@ test('every declared setting is a real schema key, so none is dead config', () =
}
})
/** The defaults plus an override, as the settings provider would resolve them. */
/**
* A plain section with an override laid over the resolved defaults.
*
* Deliberately built WITHOUT going back through `Config`: these tests are about
* the rules `validateSettings` owns, and half of them are also schema bounds —
* so parsing them would fail at the schema and never reach the rule under test.
* The schema's own refusal of the same values is asserted separately above.
*/
function settingsWith(patch) {
return { ...Config({}), ...patch }
return { ...settingsSnapshot(Config({})), ...patch }
}
test('validateSettings accepts the defaults', () => {
assert.doesNotThrow(() => validateSettings(Config({})))
assert.doesNotThrow(() => validateSettings(resolve()))
})
test('validateSettings rejects an endpoint that is not a URL', () => {
+131 -5
View File
@@ -3,7 +3,7 @@ import { readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { test } from 'node:test'
import { apply } from '../index.js'
import { apply, Config, SETTING_KEYS, settingsSnapshot } from '../index.js'
import { makeContext, makeExec, makeStateDir, makeStubFetch, makeWorkspace } from './helpers.js'
/**
@@ -135,6 +135,7 @@ function configFor(stateDir, extra = {}) {
embeddingBatchSize: 32,
embeddingConcurrency: 2,
embeddingTimeoutMs: 5_000,
embeddingKeepAlive: '5m',
extensions: [],
exclude: [],
maxFiles: 500,
@@ -146,12 +147,16 @@ function configFor(stateDir, extra = {}) {
maxLimit: 25,
perFileLimit: 2,
excerptLines: 40,
totalExcerptLines: 400,
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.
// This stands in for the config Cordis hands `apply` after schema
// resolution: every declared field present, defaults filled. A key this
// object omits arrives as `undefined` in the plugin — which is how
// `AbortSignal.timeout(NaN)` once came to throw inside the expander, and how
// the use-time `validateSettings` guard below came to reject a whole call.
// `configFor covers exactly the declared settings` in plugin.test.js keeps
// the two lists from drifting apart again.
focus: '',
focusMarkers: [],
expansionEnabled: false,
@@ -199,6 +204,39 @@ test('the plugin registers one tool whose declared schema is closed', async (t)
assert.equal(tool.isConcurrencySafe({ action: 'index' }), false)
})
test('configFor covers exactly the declared settings', () => {
// `configFor` stands in for the schema-resolved config Cordis hands `apply`.
// If it drifts from the schema, the plugin reads `undefined` where a default
// belongs — and `validateSettings`, which now runs on every call, turns that
// into a refused call rather than a confusing one. Deriving the list from the
// real schema is what makes that impossible to miss.
const resolved = settingsSnapshot(Config({}))
const fixture = configFor('/tmp/state')
assert.deepEqual(
Object.keys(fixture).sort(),
SETTING_KEYS.slice().sort(),
'configFor must name every declared setting and nothing else',
)
assert.deepEqual(
Object.keys(resolved).sort(),
SETTING_KEYS.slice().sort(),
'SETTING_KEYS must name every field the schema declares',
)
})
test('a configured section that cannot be acted on is refused with a remedy', async (t) => {
// The Host validates each field against Config; the cross-field rules have no
// schema to live in, so `execute` checks them before putting the section to
// work. The alternative — acting on it — is an unexplained empty search.
const { tool, workspace } = await setup(t, FILES, { defaultLimit: 50, maxLimit: 10 })
const value = await tool.execute({ query: 'session' }, makeExec(workspace))
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'INVALID_SETTINGS')
assert.match(value.note, /defaultLimit \(50\) cannot exceed maxLimit \(10\)/)
assert.match(value.remedy, /Settings/)
})
test('a search returns the right file, as an excerpt rather than a whole file', async (t) => {
const { tool, workspace } = await setup(t)
const value = await tool.execute(
@@ -358,6 +396,94 @@ test('configure rejects an unknown setting by name', async (t) => {
assert.match(value.note, /nonsense/)
})
/**
* Mount the plugin with a settings provider, and record what it is asked to do.
*
* In 0.1.7 the namespace is the profile ENTRY id, and the revision the write is
* fenced with comes from `describe()` — so both are asserted rather than
* assumed, because getting either wrong writes to a row that does not exist or
* silently overwrites a concurrent edit.
*/
async function setupWithSettings(t, extra = {}, behaviour = {}) {
const workspace = await makeWorkspace(FILES)
const stateDir = await makeStateDir()
const calls = { update: [], describe: 0 }
globalThis.fetch = makeStubFetch({})
t.after(async () => {
await rm(workspace, { recursive: true, force: true })
await rm(stateDir, { recursive: true, force: true })
})
const settings = {
describe: () => {
calls.describe += 1
return [
{ ns: 'some-other-plugin', revision: 3 },
{ ns: 'dsh-recall', revision: 7 },
]
},
update: async (ns, patch, revision) => {
calls.update.push({ ns, patch, revision })
if (behaviour.reject === true) throw new Error('settings/rejected')
},
}
const { ctx, tools, dispose } = makeContext({ services: { settings } })
t.after(() => dispose())
apply(ctx, configFor(stateDir, extra))
return { tool: tools[0], workspace, calls }
}
test('configure writes to the profile entry id, fenced by the revision describe reported', async (t) => {
const { tool, workspace, calls } = await setupWithSettings(t)
const value = await tool.execute(
{ action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, true)
assert.match(value.note, /embeddingApiBase/)
assert.deepEqual(calls.update, [
{ ns: 'dsh-recall', patch: { embeddingApiBase: 'http://elsewhere:11434' }, revision: 7 },
])
})
test('configure judges the section a patch would leave, and writes nothing it refuses', async (t) => {
// Raising `defaultLimit` alone is valid; raising it past `maxLimit` is not,
// and the contradiction is invisible to either field's schema.
const { tool, workspace, calls } = await setupWithSettings(t)
const value = await tool.execute(
{ action: 'configure', config: { defaultLimit: 99 } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'INVALID_SETTING')
assert.match(value.note, /defaultLimit \(99\) cannot exceed maxLimit \(25\)/)
assert.deepEqual(calls.update, [], 'a refused patch must not reach the document')
})
test('a settings write the document refuses is reported, not thrown', async (t) => {
const { tool, workspace } = await setupWithSettings(t, {}, { reject: true })
const value = await tool.execute(
{ action: 'configure', config: { embeddingApiBase: 'http://elsewhere:11434' } },
makeExec(workspace),
)
assert.deepEqual(schemaErrors(tool.output.schema, value), [])
assert.equal(value.ok, false)
assert.equal(value.errorCode, 'SETTINGS_WRITE_FAILED')
assert.match(value.remedy, /concurrent/)
})
test('reading settings does not need a settings provider, because volatile fields are live', async (t) => {
// The 0.1.5 shape attached a reader to the settings provider; 0.1.7 does not,
// so `apply` must not discover configured values only when one is mounted.
const { tool, workspace } = await setupWithSettings(t, { defaultLimit: 3 })
const value = await tool.execute({ query: 'session token' }, makeExec(workspace))
assert.equal(value.ok, true)
assert.ok(value.results.length <= 3, 'the configured limit must be honoured without a provider')
})
test('an unreachable endpoint fails with a remedy, not a stack trace', async (t) => {
const workspace = await makeWorkspace(FILES)
const stateDir = await makeStateDir()