Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d60f03220 | ||
|
|
34b8a8bdaf | ||
|
|
8f68ef4e37 | ||
|
|
48e5238877 | ||
|
|
9ebd484e60 | ||
|
|
85c0d4dccd | ||
|
|
6beb4c0d39 |
No files matched your search
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
Every release, newest first, generated from the commits in each release.
|
Every release, newest first, generated from the commits in each release.
|
||||||
|
|
||||||
|
## v0.1.2 — 2026-09-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- read the settings values, not the references they arrive as
|
||||||
|
|
||||||
|
|
||||||
|
## v0.1.1 — 2026-09-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- draw the API key once, and let status answer the dimensions question
|
||||||
|
- keep local home paths and addresses out of the test fixtures
|
||||||
|
|
||||||
|
|
||||||
## v0.1.0 — 2026-09-26
|
## v0.1.0 — 2026-09-26
|
||||||
|
|
||||||
- port the settings page and shell seam to dsh 0.1.7-rc.2
|
- port the settings page and shell seam to dsh 0.1.7-rc.2
|
||||||
|
|||||||
@@ -113,6 +113,43 @@ and the tool write the same place).
|
|||||||
| `embeddingRequestDelay` | Milliseconds between embedding requests, for rate-limited providers. |
|
| `embeddingRequestDelay` | Milliseconds between embedding requests, for rate-limited providers. |
|
||||||
| `timeoutMs` | Cooperative budget for a foreground run. |
|
| `timeoutMs` | Cooperative budget for a foreground run. |
|
||||||
|
|
||||||
|
### How to find `embeddingDimensions`
|
||||||
|
|
||||||
|
It is the width of the vector the model returns, and slopo **freezes it into the
|
||||||
|
database on the first index** — get it wrong and every later run refuses with
|
||||||
|
`Configuration mismatch` until you delete the index and re-embed. So ask the
|
||||||
|
endpoint rather than guessing from a model name.
|
||||||
|
|
||||||
|
**The tool will tell you.** `status` probes the endpoint whenever a model and a
|
||||||
|
base URL are set — even when dimensions is not — and reports the width it
|
||||||
|
observed:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "action": "status" }
|
||||||
|
// → Missing: embedding_dimensions.
|
||||||
|
// The endpoint is reachable and returned 768 dimensions, so set
|
||||||
|
// embeddingDimensions to 768.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or ask it directly**, which is the same request:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The width of the vector that comes back.
|
||||||
|
curl -s http://127.0.0.1:11434/v1/embeddings \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"model":"unclemusclez/jina-embeddings-v2-base-code:latest","input":["probe"]}' |
|
||||||
|
python3 -c 'import json,sys; print(len(json.load(sys.stdin)["data"][0]["embedding"]))'
|
||||||
|
|
||||||
|
# On Ollama, the model's own recorded width, with no inference at all.
|
||||||
|
curl -s http://127.0.0.1:11434/api/show \
|
||||||
|
-d '{"model":"unclemusclez/jina-embeddings-v2-base-code:latest"}' |
|
||||||
|
python3 -c 'import json,sys; print(json.load(sys.stdin)["model_info"]["jina-bert-v2.embedding_length"])'
|
||||||
|
```
|
||||||
|
|
||||||
|
Both must agree. If they do not, the endpoint is truncating or padding — for
|
||||||
|
example a server honouring a `dimensions` parameter — and it is the **returned**
|
||||||
|
width that slopo needs, because that is what lands in the database.
|
||||||
|
|
||||||
### Choose the provider prefix carefully
|
### Choose the provider prefix carefully
|
||||||
|
|
||||||
The provider prefix is not cosmetic — it selects which LiteLLM validator runs.
|
The provider prefix is not cosmetic — it selects which LiteLLM validator runs.
|
||||||
@@ -257,6 +294,16 @@ invisible to every settings UI, and the `jobs` registry wants `exec.agent.id`
|
|||||||
(a session id) as its owner — handing it the agent object registered a job no
|
(a session id) as its owner — handing it the agent object registered a job no
|
||||||
session could collect.
|
session could collect.
|
||||||
|
|
||||||
|
`volatile()` cuts the other way as well, and this one is easy to miss because the
|
||||||
|
page still looks right. A volatile field resolves to a live *reference* rather
|
||||||
|
than to a value — that is what lets a save land without re-applying the plugin —
|
||||||
|
so `apply` receives an object where the code expects a string, a `typeof` check
|
||||||
|
falls through to its default, and the plugin runs on autodiscovery while the card
|
||||||
|
displays the settings that were ignored. This bundle reads its own settings
|
||||||
|
through `configValue`/`settingsSnapshot`, and the tests that matter resolve a
|
||||||
|
fixture through `Config['~standard'].validate` first, because a fixture built
|
||||||
|
from plain literals is the one shape a real profile never produces.
|
||||||
|
|
||||||
Two habits follow. First, this bundle's tests bind to the *installed* harness's
|
Two habits follow. First, this bundle's tests bind to the *installed* harness's
|
||||||
own slot catalog and package exports, so a rename fails a test instead of
|
own slot catalog and package exports, so a rename fails a test instead of
|
||||||
quietly emptying a page. Second, the browser half keeps its slot and service
|
quietly emptying a page. Second, the browser half keeps its slot and service
|
||||||
|
|||||||
@@ -299,6 +299,10 @@ window.__ModuleLoader__.load({
|
|||||||
const disabled = !state.writable
|
const disabled = !state.writable
|
||||||
const children = []
|
const children = []
|
||||||
for (const field of FIELDS) {
|
for (const field of FIELDS) {
|
||||||
|
// The secret is drawn once, by hand, below. Walking it here as well gave
|
||||||
|
// it a second control — an ordinary text input, echoing the key in
|
||||||
|
// clear — sitting beside the write-only one.
|
||||||
|
if (field.kind === 'secret') continue
|
||||||
const meta = describeField(t, field)
|
const meta = describeField(t, field)
|
||||||
const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta }
|
const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta }
|
||||||
children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared))
|
children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared))
|
||||||
|
|||||||
@@ -119,6 +119,14 @@ const SETTING_KEYS = [
|
|||||||
* unmarked field would still be *configurable* through the profile patch — it
|
* unmarked field would still be *configurable* through the profile patch — it
|
||||||
* would simply be invisible to every settings UI.
|
* would simply be invisible to every settings UI.
|
||||||
*
|
*
|
||||||
|
* `volatile()` has a second consequence, and it is the one that bites: the
|
||||||
|
* resolved config does not carry VALUES for these fields. Each arrives as a
|
||||||
|
* reference object with a `get()`, because that is what lets a settings write
|
||||||
|
* land in the running instance without re-applying the plugin. So a plain-value
|
||||||
|
* read (`typeof config.embeddingModel === 'string'`) is false for every field,
|
||||||
|
* and the plugin silently behaves as if nothing were configured. Every read
|
||||||
|
* goes through {@link configValue} or {@link settingsSnapshot}.
|
||||||
|
*
|
||||||
* `embeddingApiKey` additionally carries `role('secret')`, so the settings
|
* `embeddingApiKey` additionally carries `role('secret')`, so the settings
|
||||||
* wire never carries it: every remote read is redacted (`secrets` reports only
|
* wire never carries it: every remote read is redacted (`secrets` reports only
|
||||||
* whether a value is set) and the browser sends the key one way, on write.
|
* whether a value is set) and the browser sends the key one way, on write.
|
||||||
@@ -156,6 +164,40 @@ export const Config = Schema.object({
|
|||||||
timeoutMs: Schema.number().default(600_000).volatile(),
|
timeoutMs: Schema.number().default(600_000).volatile(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one configuration field.
|
||||||
|
*
|
||||||
|
* A volatile field is a reference in the running config, not a plain value; a
|
||||||
|
* non-volatile one (or a config object a test handed straight to `apply`) is
|
||||||
|
* the value itself. Both are accepted so the same path serves a real profile
|
||||||
|
* and a direct call. Duck-typed on `get` rather than importing cosmokit's
|
||||||
|
* `isVolatile`, because the only thing this plugin needs to know about a
|
||||||
|
* reference is how to read it.
|
||||||
|
*
|
||||||
|
* @param {unknown} value a field of the resolved config
|
||||||
|
* @returns {unknown} the current value
|
||||||
|
*/
|
||||||
|
export function configValue(value) {
|
||||||
|
return value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain snapshot of the whole settings section.
|
||||||
|
*
|
||||||
|
* Taken per tool call rather than cached, because that is exactly what a
|
||||||
|
* volatile field buys: the running instance's references are written in place
|
||||||
|
* when the document changes, so the next snapshot sees the new value without
|
||||||
|
* the plugin being remounted.
|
||||||
|
*
|
||||||
|
* @param {Record<string, any>} config the resolved composition config
|
||||||
|
* @returns {Record<string, any>} every declared field, unwrapped
|
||||||
|
*/
|
||||||
|
export function settingsSnapshot(config) {
|
||||||
|
const snapshot = {}
|
||||||
|
for (const key of Object.keys(config ?? {})) snapshot[key] = configValue(config[key])
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve the harness home, preferring the harness's own resolver. */
|
/** Resolve the harness home, preferring the harness's own resolver. */
|
||||||
function dshHomeOf(ctx) {
|
function dshHomeOf(ctx) {
|
||||||
try {
|
try {
|
||||||
@@ -170,8 +212,16 @@ function dshHomeOf(ctx) {
|
|||||||
return resolveDshHome()
|
return resolveDshHome()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every path one workspace's slopo state occupies. */
|
/**
|
||||||
export function workspacePaths(dshHome, cwd, settings) {
|
* Every path one workspace's slopo state occupies.
|
||||||
|
*
|
||||||
|
* `config` may be the live composition config — whose fields are all volatile
|
||||||
|
* references — or the plain snapshot a tool call already took of it. Both are
|
||||||
|
* accepted, because this is exported and the next caller cannot be assumed to
|
||||||
|
* know the difference; a plain value passes through `settingsSnapshot` intact.
|
||||||
|
*/
|
||||||
|
export function workspacePaths(dshHome, cwd, config) {
|
||||||
|
const settings = settingsSnapshot(config)
|
||||||
const stateDir =
|
const stateDir =
|
||||||
typeof settings.stateDir === 'string' && settings.stateDir.trim() !== ''
|
typeof settings.stateDir === 'string' && settings.stateDir.trim() !== ''
|
||||||
? settings.stateDir.trim()
|
? settings.stateDir.trim()
|
||||||
@@ -220,9 +270,13 @@ function embeddingParams(settings, problems) {
|
|||||||
* The names differ on purpose: dsh settings are camelCase by convention while
|
* The names differ on purpose: dsh settings are camelCase by convention while
|
||||||
* slopo's config file is snake_case, and slopo treats an unknown key as a hard
|
* slopo's config file is snake_case, and slopo treats an unknown key as a hard
|
||||||
* error, so this mapping is the only place a name can go wrong.
|
* error, so this mapping is the only place a name can go wrong.
|
||||||
|
*
|
||||||
|
* `config` is accepted in either shape for the same reason `workspacePaths`
|
||||||
|
* is: exported, so called with a live volatile config or with a plain one.
|
||||||
*/
|
*/
|
||||||
export function toSlopoConfig(settings, paths, problems = []) {
|
export function toSlopoConfig(config, paths, problems = []) {
|
||||||
const config = {
|
const settings = settingsSnapshot(config)
|
||||||
|
const mapped = {
|
||||||
source_dir: paths.sourceDir,
|
source_dir: paths.sourceDir,
|
||||||
source_dir_exclude: effectiveExcludes(settings),
|
source_dir_exclude: effectiveExcludes(settings),
|
||||||
db_file: paths.dbFile,
|
db_file: paths.dbFile,
|
||||||
@@ -236,7 +290,7 @@ export function toSlopoConfig(settings, paths, problems = []) {
|
|||||||
embedding_params: embeddingParams(settings, problems),
|
embedding_params: embeddingParams(settings, problems),
|
||||||
embedding_request_delay: Number(settings.embeddingRequestDelay) || 0,
|
embedding_request_delay: Number(settings.embeddingRequestDelay) || 0,
|
||||||
}
|
}
|
||||||
const { config: normalized, problems: numericProblems } = normalizeConfig(config)
|
const { config: normalized, problems: numericProblems } = normalizeConfig(mapped)
|
||||||
problems.push(...numericProblems)
|
problems.push(...numericProblems)
|
||||||
delete normalized.embedding_api_key
|
delete normalized.embedding_api_key
|
||||||
return normalized
|
return normalized
|
||||||
@@ -347,17 +401,23 @@ export const inject = ['tools', 'skills']
|
|||||||
* Register the skills, the settings namespace, and the tool.
|
* Register the skills, the settings namespace, and the tool.
|
||||||
*
|
*
|
||||||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
||||||
* @param {Record<string, unknown>} config validated composition config
|
* @param {Record<string, unknown>} config validated composition config, whose
|
||||||
|
* fields are volatile references rather than values
|
||||||
*/
|
*/
|
||||||
export function apply(ctx, config) {
|
export function apply(ctx, config) {
|
||||||
const logger = ctx.logger('dsh-slopo')
|
const logger = ctx.logger('dsh-slopo')
|
||||||
const dshHome = dshHomeOf(ctx)
|
const dshHome = dshHomeOf(ctx)
|
||||||
|
|
||||||
// `config` is the live configuration, not a snapshot of the composition
|
// The authoritative settings source: a plain snapshot of the running config,
|
||||||
// layer: a settings write rewrites this entry's patch and the Loader
|
// taken per call.
|
||||||
// re-applies the plugin with the new values, so every read below sees the
|
//
|
||||||
// deployment's current answer. There is deliberately no cached copy — a cache
|
// `config` holds LIVE REFERENCES, not values. Every field is `volatile()`, so
|
||||||
// is exactly what a reload invalidates.
|
// a settings write does NOT re-apply this plugin: the Loader writes the new
|
||||||
|
// value into the reference this instance already holds (`_commitVolatile`),
|
||||||
|
// and that is the whole point of the marker. It is also why nothing here is
|
||||||
|
// cached — a snapshot taken once at apply time would be frozen at the
|
||||||
|
// composition's values and would never see an edit.
|
||||||
|
const readSettings = () => settingsSnapshot(config)
|
||||||
let writeSettings
|
let writeSettings
|
||||||
|
|
||||||
// The settings namespace IS this plugin's Config. The framework projects the
|
// The settings namespace IS this plugin's Config. The framework projects the
|
||||||
@@ -481,7 +541,9 @@ export function apply(ctx, config) {
|
|||||||
},
|
},
|
||||||
async execute(args, exec) {
|
async execute(args, exec) {
|
||||||
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
|
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
|
||||||
const settings = config
|
// Fresh per call, so a settings edit made while this tool is loaded is
|
||||||
|
// picked up by the very next call — see `readSettings` above.
|
||||||
|
const settings = readSettings()
|
||||||
const paths = workspacePaths(dshHome, cwd, settings)
|
const paths = workspacePaths(dshHome, cwd, settings)
|
||||||
const shell = ctx.get('shell')
|
const shell = ctx.get('shell')
|
||||||
// Resolved once, up front, so `status` can probe without a second
|
// Resolved once, up front, so `status` can probe without a second
|
||||||
@@ -537,21 +599,43 @@ export function apply(ctx, config) {
|
|||||||
// A direct fetch rather than the shell seam: the probe is one small
|
// A direct fetch rather than the shell seam: the probe is one small
|
||||||
// HTTP request and does not belong behind a sandbox boundary that
|
// HTTP request and does not belong behind a sandbox boundary that
|
||||||
// exists to confine filesystem writes.
|
// exists to confine filesystem writes.
|
||||||
|
//
|
||||||
|
// It runs whenever the endpoint can be NAMED — a base URL and a model
|
||||||
|
// — not only once every required setting is present. Gating it on
|
||||||
|
// completeness hid it in the one state that needs it most: nobody can
|
||||||
|
// look up how wide an arbitrary embedding model is, and the probe is
|
||||||
|
// what reports the width it observed.
|
||||||
|
const apiBase = typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : ''
|
||||||
|
const model = String(slopoConfig.embedding_model ?? '')
|
||||||
const endpoint =
|
const endpoint =
|
||||||
missing.length === 0
|
apiBase.trim() !== '' && model.trim() !== ''
|
||||||
? await probeEmbeddingEndpoint({
|
? await probeEmbeddingEndpoint({
|
||||||
apiBase: typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : '',
|
apiBase,
|
||||||
model: String(slopoConfig.embedding_model ?? ''),
|
model,
|
||||||
dimensions: Number(slopoConfig.embedding_dimensions ?? 0),
|
dimensions: Number(slopoConfig.embedding_dimensions ?? 0),
|
||||||
apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '',
|
apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '',
|
||||||
})
|
})
|
||||||
: { checked: false, skipped: 'nothing is configured yet' }
|
: {
|
||||||
|
checked: false,
|
||||||
|
skipped: 'set embeddingModel and embeddingApiBase to probe the endpoint and read its dimensions',
|
||||||
|
}
|
||||||
|
|
||||||
let message
|
let message
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
message =
|
message =
|
||||||
`Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
|
`Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
|
||||||
'to set embeddingModel and embeddingDimensions, then "prepare".'
|
'to set embeddingModel and embeddingDimensions, then "prepare".'
|
||||||
|
// The one thing a new user cannot look up anywhere: how wide this
|
||||||
|
// model's vectors are. Say it plainly when that is what is missing.
|
||||||
|
if (
|
||||||
|
endpoint.checked === true &&
|
||||||
|
typeof endpoint.dimensions === 'number' &&
|
||||||
|
missing.includes('embedding_dimensions')
|
||||||
|
) {
|
||||||
|
message +=
|
||||||
|
` The endpoint is reachable and returned ${endpoint.dimensions} dimensions, so ` +
|
||||||
|
`set embeddingDimensions to ${endpoint.dimensions}.`
|
||||||
|
}
|
||||||
} else if (endpoint.ok === false) {
|
} else if (endpoint.ok === false) {
|
||||||
message =
|
message =
|
||||||
`Configured, but the embedding backend failed its check: ${endpoint.error}. ` +
|
`Configured, but the embedding backend failed its check: ${endpoint.error}. ` +
|
||||||
|
|||||||
+6
-1
@@ -156,7 +156,12 @@ export async function probeEmbeddingEndpoint({
|
|||||||
// is not lossless JSON and fails the tool's output validation before the
|
// is not lossless JSON and fails the tool's output validation before the
|
||||||
// result is ever materialised.
|
// result is ever materialised.
|
||||||
if (actual !== expected) {
|
if (actual !== expected) {
|
||||||
result.error = `the model returns ${actual} dimensions but embeddingDimensions is ${expected}`
|
// "not set yet" and "set wrong" are different problems with different
|
||||||
|
// remedies, and `dimensions: actual` is the answer to the first one.
|
||||||
|
result.error =
|
||||||
|
expected > 0
|
||||||
|
? `the model returns ${actual} dimensions but embeddingDimensions is ${expected}`
|
||||||
|
: `the model returns ${actual} dimensions and embeddingDimensions is not set`
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dsh-slopo",
|
"name": "dsh-slopo",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Slopo as a DeepSeek Harness bundle — embedding-based duplicate-code review, exposed as three user-triggered skills, one config-aware tool, and a settings card.",
|
"description": "Slopo as a DeepSeek Harness bundle — embedding-based duplicate-code review, exposed as three user-triggered skills, one config-aware tool, and a settings card.",
|
||||||
|
|||||||
+31
-2
@@ -31,11 +31,18 @@ import assert from 'node:assert/strict'
|
|||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
|
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
|
||||||
|
|
||||||
/** Where a global dsh installation keeps its packages, if there is one. */
|
/**
|
||||||
const DSH_PACKAGES = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai'
|
* The installed harness's packages, reached through this repository's own
|
||||||
|
* `node_modules/@deepseek-ai` symlink rather than a committed absolute path —
|
||||||
|
* the link is what makes the `@deepseek-ai/*` imports resolve during tests in
|
||||||
|
* the first place, so it is the one address that is already relative to the
|
||||||
|
* checkout.
|
||||||
|
*/
|
||||||
|
const DSH_PACKAGES = fileURLToPath(new URL('../node_modules/@deepseek-ai/', import.meta.url))
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// The two doubles
|
// The two doubles
|
||||||
@@ -625,6 +632,28 @@ test('the API key is a write-only password control that reports only whether one
|
|||||||
assert.match(findAll(tree, (node) => node.props['data-state'] === true).map((node) => node.children.join('')).join(''), /A key is stored/)
|
assert.match(findAll(tree, (node) => node.props['data-state'] === true).map((node) => node.children.join('')).join(''), /A key is stored/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('the API key has exactly one control, and it is never a plain text field', () => {
|
||||||
|
// The regression: the render loop walked every entry in FIELDS, so the secret
|
||||||
|
// drew a second control — an ordinary text input, echoing the key in clear —
|
||||||
|
// beside the write-only password control. One key, two boxes, one of them
|
||||||
|
// visible.
|
||||||
|
const { render } = registered()
|
||||||
|
const tree = render()
|
||||||
|
const labelled = findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === 'Embedding API key')
|
||||||
|
assert.equal(labelled.length, 1, 'the key must have exactly one control')
|
||||||
|
assert.equal(labelled[0].props.type, 'password')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the card draws one control per schema field, and no more', () => {
|
||||||
|
// A count, not a list of names: the label assertions above pass just as
|
||||||
|
// happily with a duplicate row sitting beside the real one.
|
||||||
|
const { render } = registered()
|
||||||
|
const controls = findAll(render(), (node) => node.type === 'input')
|
||||||
|
assert.equal(controls.length, 15, 'the schema declares 15 fields, so each gets one control')
|
||||||
|
const labels = controls.map((node) => node.props['aria-label'])
|
||||||
|
assert.equal(new Set(labels).size, labels.length, `duplicate controls: ${labels.join(', ')}`)
|
||||||
|
})
|
||||||
|
|
||||||
test('a deployment with no key says so', () => {
|
test('a deployment with no key says so', () => {
|
||||||
const { render } = registered({ secrets: [{ path: ['embeddingApiKey'], set: false }] })
|
const { render } = registered({ secrets: [{ path: ['embeddingApiKey'], set: false }] })
|
||||||
const tree = render()
|
const tree = render()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
|
|
||||||
|
import { Config, configValue, settingsSnapshot, toSlopoConfig, workspacePaths } from '../index.js'
|
||||||
import {
|
import {
|
||||||
SLOPO_CONFIG_KEYS,
|
SLOPO_CONFIG_KEYS,
|
||||||
missingRequired,
|
missingRequired,
|
||||||
@@ -105,3 +106,95 @@ test('normalizeConfig keeps thresholds fractional but rejects fractional counts'
|
|||||||
assert.equal(Object.hasOwn(bad.config, 'embedding_batch_size'), false)
|
assert.equal(Object.hasOwn(bad.config, 'embedding_batch_size'), false)
|
||||||
assert.match(bad.problems[0], /whole number/)
|
assert.match(bad.problems[0], /whole number/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other half of the config contract: the shape a section ARRIVES in.
|
||||||
|
*
|
||||||
|
* Every field of `Config` is `volatile()`, so Cordis hands `apply` a reference
|
||||||
|
* object per field rather than a value — that is what lets a settings edit land
|
||||||
|
* without re-applying the plugin. A plain-value read of such a field is silently
|
||||||
|
* false, which is how this plugin came to ignore its own settings and fall back
|
||||||
|
* to autodiscovery. These tests resolve through the real schema path, because a
|
||||||
|
* fixture built from plain literals can never catch that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The section as the Loader builds it: schema-resolved, references intact. */
|
||||||
|
function resolved(patch = {}) {
|
||||||
|
const result = Config['~standard'].validate(patch)
|
||||||
|
assert.equal(result.issues, undefined, `the schema refused the fixture: ${JSON.stringify(result.issues)}`)
|
||||||
|
return result.value
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a volatile field arrives as a reference, and configValue unwraps it', () => {
|
||||||
|
const config = resolved({ embeddingApiBase: 'http://192.0.2.10:11434' })
|
||||||
|
assert.equal(typeof config.embeddingApiBase, 'object', 'a volatile field is a reference, not a value')
|
||||||
|
assert.equal(configValue(config.embeddingApiBase), 'http://192.0.2.10:11434')
|
||||||
|
// …and a plain value is handed straight back, so a caller that passes a
|
||||||
|
// literal object — every other test in this suite, and `apply` in a profile
|
||||||
|
// whose fields are not volatile — takes the very same code path.
|
||||||
|
assert.equal(configValue('http://plain'), 'http://plain')
|
||||||
|
assert.equal(configValue(undefined), undefined)
|
||||||
|
assert.equal(configValue(0), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a snapshot reads the configured values out of a schema-resolved section', () => {
|
||||||
|
const settings = settingsSnapshot(
|
||||||
|
resolved({
|
||||||
|
embeddingModel: 'ollama/example:latest',
|
||||||
|
embeddingDimensions: 768,
|
||||||
|
embeddingRequestDelay: 25,
|
||||||
|
embeddingParamsJson: '{"keep_alive":"5m"}',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
assert.equal(settings.embeddingModel, 'ollama/example:latest')
|
||||||
|
assert.equal(settings.embeddingDimensions, 768)
|
||||||
|
assert.equal(settings.embeddingRequestDelay, 25)
|
||||||
|
assert.equal(settings.embeddingParamsJson, '{"keep_alive":"5m"}')
|
||||||
|
// The fields left unset are still the schema's own defaults, not `undefined`.
|
||||||
|
assert.equal(settings.sourceDir, '')
|
||||||
|
assert.deepEqual(settings.sourceDirExclude, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('toSlopoConfig turns a schema-resolved section into slopo config, not into defaults', () => {
|
||||||
|
// The regression: every read in this path was a plain-value check, so the
|
||||||
|
// model read as '' and the dimensions as undefined — the tool reported itself
|
||||||
|
// unconfigured while the settings card was displaying a configured one.
|
||||||
|
const config = resolved({
|
||||||
|
embeddingModel: 'ollama/example:latest',
|
||||||
|
embeddingDimensions: 768,
|
||||||
|
embeddingRequestDelay: 25,
|
||||||
|
embeddingApiBase: 'http://192.0.2.10:11434',
|
||||||
|
embeddingParamsJson: '{"keep_alive":"5m"}',
|
||||||
|
sourceDir: '/src',
|
||||||
|
})
|
||||||
|
const paths = workspacePaths('/home/dsh', '/work', config)
|
||||||
|
assert.equal(paths.sourceDir, '/src')
|
||||||
|
const problems = []
|
||||||
|
const slopoConfig = toSlopoConfig(config, paths, problems)
|
||||||
|
assert.deepEqual(problems, [])
|
||||||
|
assert.equal(slopoConfig.embedding_model, 'ollama/example:latest')
|
||||||
|
assert.equal(slopoConfig.embedding_dimensions, 768)
|
||||||
|
assert.equal(slopoConfig.embedding_request_delay, 25)
|
||||||
|
assert.deepEqual(slopoConfig.embedding_params, { keep_alive: '5m', api_base: 'http://192.0.2.10:11434' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the same helpers still take a plain object, which is how they are called elsewhere', () => {
|
||||||
|
const paths = workspacePaths('/home/dsh', '/work', { sourceDir: '/src', stateDir: '/state' })
|
||||||
|
assert.equal(paths.stateDir, '/state')
|
||||||
|
assert.equal(paths.sourceDir, '/src')
|
||||||
|
const slopoConfig = toSlopoConfig({ embeddingModel: 'ollama/plain:latest', embeddingDimensions: 1024 }, paths)
|
||||||
|
assert.equal(slopoConfig.embedding_model, 'ollama/plain:latest')
|
||||||
|
assert.equal(slopoConfig.embedding_dimensions, 1024)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a snapshot taken per call sees a live edit, because the reference is written in place', () => {
|
||||||
|
const config = resolved({ embeddingDimensions: 768 })
|
||||||
|
assert.equal(settingsSnapshot(config).embeddingDimensions, 768)
|
||||||
|
// This is what the Loader does on a volatile-only settings change: it writes
|
||||||
|
// the new value into the reference the running instance already holds, without
|
||||||
|
// re-applying the plugin. A snapshot cached at apply time would never see it,
|
||||||
|
// which is why the tool takes a fresh one per call.
|
||||||
|
const [write] = Object.getOwnPropertySymbols(config.embeddingDimensions)
|
||||||
|
config.embeddingDimensions[write](1024)
|
||||||
|
assert.equal(settingsSnapshot(config).embeddingDimensions, 1024)
|
||||||
|
})
|
||||||
+90
-5
@@ -101,9 +101,18 @@ writeFileSync(stubSlopo, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
|||||||
*
|
*
|
||||||
* `jobs` is deliberately absent, which forces the tool down its foreground path
|
* `jobs` is deliberately absent, which forces the tool down its foreground path
|
||||||
* so the assertions can see a result rather than a job handle.
|
* so the assertions can see a result rather than a job handle.
|
||||||
|
*
|
||||||
|
* `configFromSchema` hands `apply` the config the way Cordis actually builds it
|
||||||
|
* — through `Config['~standard'].validate`, so every volatile field arrives as a
|
||||||
|
* reference object rather than a value. Without it a test can only prove the
|
||||||
|
* plugin works on a shape no real profile ever produces.
|
||||||
*/
|
*/
|
||||||
function harness(overrides = {}, { withSettings = true, withJobs = false, withShell = true, sandboxMode, sandboxPolicyResolver, shellRun } = {}) {
|
function harness(
|
||||||
const config = { ...FULL_CONFIG, binary: stubSlopo, ...overrides }
|
overrides = {},
|
||||||
|
{ withSettings = true, withJobs = false, withShell = true, sandboxMode, sandboxPolicyResolver, shellRun, configFromSchema = false } = {},
|
||||||
|
) {
|
||||||
|
const plain = { ...FULL_CONFIG, binary: stubSlopo, ...overrides }
|
||||||
|
const config = configFromSchema ? resolveConfig(plain) : plain
|
||||||
// A per-harness shell, so a test can vary sandboxMode and every resolved
|
// A per-harness shell, so a test can vary sandboxMode and every resolved
|
||||||
// request is recorded for assertion.
|
// request is recorded for assertion.
|
||||||
const scopedShell = {
|
const scopedShell = {
|
||||||
@@ -187,6 +196,13 @@ function harness(overrides = {}, { withSettings = true, withJobs = false, withSh
|
|||||||
|
|
||||||
const execContext = (cwd) => ({ signal: new AbortController().signal, agent: { id: 'session-1', session: { header: { cwd } } } })
|
const execContext = (cwd) => ({ signal: new AbortController().signal, agent: { id: 'session-1', session: { header: { cwd } } } })
|
||||||
|
|
||||||
|
/** Resolve a fixture the way Cordis does, volatile references and all. */
|
||||||
|
function resolveConfig(patch) {
|
||||||
|
const result = Config['~standard'].validate(patch)
|
||||||
|
assert.equal(result.issues, undefined, `the schema refused the fixture: ${JSON.stringify(result.issues)}`)
|
||||||
|
return result.value
|
||||||
|
}
|
||||||
|
|
||||||
test('Config is a schemastery schema, which is what cordis requires', () => {
|
test('Config is a schemastery schema, which is what cordis requires', () => {
|
||||||
assert.equal(typeof Config, 'function')
|
assert.equal(typeof Config, 'function')
|
||||||
assert.ok(Config['~standard'] !== undefined, 'expected a Standard Schema')
|
assert.ok(Config['~standard'] !== undefined, 'expected a Standard Schema')
|
||||||
@@ -281,6 +297,42 @@ test('the embedding API key is a declared secret, so the settings wire never car
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a schema-resolved config reaches the tool as values, not as volatile references', async () => {
|
||||||
|
// The regression: Cordis hands `apply` a reference object per volatile field,
|
||||||
|
// so every plain-value read (`typeof settings.embeddingModel === 'string'`)
|
||||||
|
// was false and the configured settings were silently discarded. It looked
|
||||||
|
// healthy because `status` fell back to autodiscovery and to whatever stale
|
||||||
|
// slopo.conf.yaml was already on disk.
|
||||||
|
const { state } = harness(
|
||||||
|
{ embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
|
||||||
|
{ configFromSchema: true },
|
||||||
|
)
|
||||||
|
const value = await state.tools.get('slopo').execute({ action: 'status' }, execContext('/tmp'))
|
||||||
|
assert.deepEqual(value.details.missingSettings, [], 'the settings must not read as unset')
|
||||||
|
assert.equal(value.ok, true)
|
||||||
|
assert.equal(value.details.embeddingModel, 'ollama/example:latest')
|
||||||
|
assert.equal(value.details.embeddingDimensions, 768)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a settings edit lands in the running tool, because the snapshot is taken per call', async () => {
|
||||||
|
// A volatile-only write does not re-apply the plugin — the Loader writes the
|
||||||
|
// new value into the reference the instance already holds — so a snapshot
|
||||||
|
// cached in `apply` would freeze this tool at its composition values forever.
|
||||||
|
const { config, state } = harness(
|
||||||
|
{ embeddingModel: 'ollama/first:latest', embeddingDimensions: 768 },
|
||||||
|
{ configFromSchema: true },
|
||||||
|
)
|
||||||
|
const tool = state.tools.get('slopo')
|
||||||
|
const before = await tool.execute({ action: 'status' }, execContext('/tmp'))
|
||||||
|
assert.match(before.message, /ollama\/first:latest/)
|
||||||
|
|
||||||
|
const [write] = Object.getOwnPropertySymbols(config.embeddingModel)
|
||||||
|
config.embeddingModel[write]('ollama/second:latest')
|
||||||
|
|
||||||
|
const after = await tool.execute({ action: 'status' }, execContext('/tmp'))
|
||||||
|
assert.match(after.message, /ollama\/second:latest/)
|
||||||
|
})
|
||||||
|
|
||||||
test('status reports the missing settings instead of pretending to be ready', async () => {
|
test('status reports the missing settings instead of pretending to be ready', async () => {
|
||||||
const { state } = harness()
|
const { state } = harness()
|
||||||
const tool = state.tools.get('slopo')
|
const tool = state.tools.get('slopo')
|
||||||
@@ -477,7 +529,11 @@ test(
|
|||||||
sourceDir: join(repo, 'src'),
|
sourceDir: join(repo, 'src'),
|
||||||
embeddingModel: 'ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
|
embeddingModel: 'ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
|
||||||
embeddingDimensions: 768,
|
embeddingDimensions: 768,
|
||||||
embeddingApiBase: 'http://10.50.1.2:11434',
|
// Where the real embedding backend lives is a property of the machine
|
||||||
|
// running the E2E suite, not of this repository, so it is read from the
|
||||||
|
// environment rather than committed. The default is Ollama's own
|
||||||
|
// loopback address.
|
||||||
|
embeddingApiBase: process.env.DSH_SLOPO_E2E_API_BASE ?? 'http://127.0.0.1:11434',
|
||||||
})
|
})
|
||||||
const tool = state.tools.get('slopo')
|
const tool = state.tools.get('slopo')
|
||||||
|
|
||||||
@@ -637,8 +693,37 @@ test('status returns lossless JSON when the endpoint probe SUCCEEDS', async () =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('status returns lossless JSON when the endpoint probe FAILS', async () => {
|
test('status asks the endpoint how wide the model is when embeddingDimensions is unset', async () => {
|
||||||
const { state } = harness({
|
// The question a new user cannot answer from anywhere else. The probe used to
|
||||||
|
// be gated on every required setting being present, which meant it was
|
||||||
|
// unavailable in exactly the state that needs it — so `status` said "Missing:
|
||||||
|
// embedding_dimensions" and left you to guess.
|
||||||
|
const { createServer } = await import('node:http')
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' })
|
||||||
|
response.end(JSON.stringify({ data: [{ embedding: new Array(1024).fill(0.01) }] }))
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const { port } = server.address()
|
||||||
|
try {
|
||||||
|
const { state } = harness({
|
||||||
|
embeddingModel: 'ollama/example:latest',
|
||||||
|
embeddingDimensions: 0,
|
||||||
|
embeddingApiBase: `http://127.0.0.1:${port}`,
|
||||||
|
})
|
||||||
|
const value = await state.tools.get('slopo').execute({ action: 'status' }, execContext('/tmp'))
|
||||||
|
assertLossless('status (dimensions unset)', value)
|
||||||
|
assert.deepEqual(value.details.missingSettings, ['embedding_dimensions'])
|
||||||
|
assert.equal(value.details.embeddingEndpoint.checked, true, 'the probe must still run')
|
||||||
|
assert.equal(value.details.embeddingEndpoint.dimensions, 1024, 'the observed width is the answer')
|
||||||
|
assert.match(value.message, /returned 1024 dimensions/)
|
||||||
|
assert.match(value.message, /set embeddingDimensions to 1024/)
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('status returns lossless JSON when the endpoint probe FAILS', async () => { const { state } = harness({
|
||||||
embeddingModel: 'ollama/example:latest',
|
embeddingModel: 'ollama/example:latest',
|
||||||
embeddingDimensions: 768,
|
embeddingDimensions: 768,
|
||||||
// Nothing is listening on this port, so the fetch rejects.
|
// Nothing is listening on this port, so the fetch rejects.
|
||||||
|
|||||||
@@ -73,6 +73,22 @@ test('probeEmbeddingEndpoint catches a dimension mismatch, which is what slopo f
|
|||||||
assert.match(result.error, /returns 768 dimensions but embeddingDimensions is 1024/)
|
assert.match(result.error, /returns 768 dimensions but embeddingDimensions is 1024/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('probeEmbeddingEndpoint reports the width when dimensions is not set yet', async () => {
|
||||||
|
// This is the case a new user is in, and the reason the probe runs before
|
||||||
|
// every required setting exists: nobody can look up how wide an arbitrary
|
||||||
|
// model is, so `dimensions` in the result IS the answer.
|
||||||
|
const result = await probeEmbeddingEndpoint({
|
||||||
|
apiBase: 'http://host:11434',
|
||||||
|
model: 'ollama/m:latest',
|
||||||
|
dimensions: 0,
|
||||||
|
fetchImpl: fakeFetch({ body: { data: [{ embedding: new Array(768).fill(0) }] } }),
|
||||||
|
})
|
||||||
|
assert.equal(result.ok, false, 'unset is not a match')
|
||||||
|
assert.equal(result.dimensions, 768, 'the observed width is reported regardless')
|
||||||
|
assert.equal(result.expected, 0)
|
||||||
|
assert.match(result.error, /returns 768 dimensions and embeddingDimensions is not set/)
|
||||||
|
})
|
||||||
|
|
||||||
test('probeEmbeddingEndpoint reports an HTTP error instead of throwing', async () => {
|
test('probeEmbeddingEndpoint reports an HTTP error instead of throwing', async () => {
|
||||||
const result = await probeEmbeddingEndpoint({
|
const result = await probeEmbeddingEndpoint({
|
||||||
apiBase: 'http://host:11434',
|
apiBase: 'http://host:11434',
|
||||||
|
|||||||
+33
-24
@@ -17,7 +17,7 @@ import {
|
|||||||
slopoCandidates,
|
slopoCandidates,
|
||||||
uvInstallArgs,
|
uvInstallArgs,
|
||||||
} from '../lib/slopo.js'
|
} from '../lib/slopo.js'
|
||||||
import { managedSlopoPath, resolveDshHome, workspaceKey, workspaceStateDir } from '../lib/paths.js'
|
import { managedSlopoPath, resolveDshHome, userLocalSlopoPath, workspaceKey, workspaceStateDir } from '../lib/paths.js'
|
||||||
|
|
||||||
test('findSlopo prefers the managed install over PATH', () => {
|
test('findSlopo prefers the managed install over PATH', () => {
|
||||||
const home = '/dsh'
|
const home = '/dsh'
|
||||||
@@ -152,26 +152,35 @@ test('workspace state lives under the harness home, not next to the code', () =>
|
|||||||
assert.match(workspaceStateDir('/dsh', '/repo'), /^\/dsh\/dsh-slopo\/workspaces\/repo-[0-9a-f]{8}$/)
|
assert.match(workspaceStateDir('/dsh', '/repo'), /^\/dsh\/dsh-slopo\/workspaces\/repo-[0-9a-f]{8}$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the generated config is accepted by the real slopo', { skip: !existsSync(managedSlopoPath('/root/.dsh')) && !existsSync('/root/.local/bin/slopo') }, () => {
|
test(
|
||||||
const binary = findSlopo('/root/.dsh')
|
'the generated config is accepted by the real slopo',
|
||||||
assert.ok(binary, 'expected a slopo binary for this test')
|
{
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-slopo-conf-'))
|
// Discovery resolves from the RUNNING user's home, not from a committed
|
||||||
const configPath = join(dir, 'slopo.conf.yaml')
|
// absolute one: a hardcoded home directory only ever matched one machine,
|
||||||
writeFileSync(
|
// and it published that machine's layout to everyone else.
|
||||||
configPath,
|
skip: !existsSync(managedSlopoPath(resolveDshHome())) && !existsSync(userLocalSlopoPath()),
|
||||||
renderSlopoConfig({
|
},
|
||||||
source_dir: dir,
|
() => {
|
||||||
db_file: join(dir, 'slopo.db'),
|
const binary = findSlopo(resolveDshHome())
|
||||||
ignore_file: join(dir, 'slopo.ignore.txt'),
|
assert.ok(binary, 'expected a slopo binary for this test')
|
||||||
agent_log_file: join(dir, 'slopo.agent.log'),
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-slopo-conf-'))
|
||||||
embedding_model: 'ollama/example:latest',
|
const configPath = join(dir, 'slopo.conf.yaml')
|
||||||
embedding_dimensions: 768,
|
writeFileSync(
|
||||||
embedding_params: { api_base: 'http://127.0.0.1:11434' },
|
configPath,
|
||||||
source_dir_exclude: ['**/test/**'],
|
renderSlopoConfig({
|
||||||
}),
|
source_dir: dir,
|
||||||
)
|
db_file: join(dir, 'slopo.db'),
|
||||||
const out = execFileSync(binary, ['--config', configPath, 'show-config'], { encoding: 'utf8' })
|
ignore_file: join(dir, 'slopo.ignore.txt'),
|
||||||
assert.match(out, /embedding_model: ollama\/example:latest/)
|
agent_log_file: join(dir, 'slopo.agent.log'),
|
||||||
assert.match(out, /embedding_dimensions: 768/)
|
embedding_model: 'ollama/example:latest',
|
||||||
assert.match(out, /api_base: http:\/\/127\.0\.0\.1:11434/)
|
embedding_dimensions: 768,
|
||||||
})
|
embedding_params: { api_base: 'http://127.0.0.1:11434' },
|
||||||
|
source_dir_exclude: ['**/test/**'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const out = execFileSync(binary, ['--config', configPath, 'show-config'], { encoding: 'utf8' })
|
||||||
|
assert.match(out, /embedding_model: ollama\/example:latest/)
|
||||||
|
assert.match(out, /embedding_dimensions: 768/)
|
||||||
|
assert.match(out, /api_base: http:\/\/127\.0\.0\.1:11434/)
|
||||||
|
},
|
||||||
|
)
|
||||||
Reference in new issue
Block a user