7 Commits
Author SHA1 Message Date
gitea-actions 3d60f03220 Release v0.1.2 [skip release] [skip actions] 2026-09-26 06:14:58 +00:00
dsh-slopo agent 34b8a8bdaf Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-slopo
Release / release (push) Successful in 6s
2026-09-26 07:14:44 +01:00
dsh-slopo agent 8f68ef4e37 fix: read the settings values, not the references they arrive as
Every field is `volatile()`, which is what puts the settings card on the
Plugins page — and what makes the resolved config carry a reference per field
rather than a value. Every read was a plain-value test (`typeof x === 'string'`,
`Number(x)`, `x !== true`), so all of them were false and the plugin behaved as
if nothing had been configured.

The card displayed and persisted correctly the whole time, because the settings
wire unwraps references for display; only the plugin's own reads were broken.
Reads now go through `configValue()`, and the tool takes a fresh snapshot per
call, which is what lets an edit reach a running instance.
2026-09-26 07:13:36 +01:00
gitea-actions 48e5238877 Release v0.1.1 [skip release] [skip actions] 2026-09-26 06:06:57 +00:00
dsh-slopo agent 9ebd484e60 fix: draw the API key once, and let status answer the dimensions question
Release / release (push) Successful in 6s
Two things a user setting this up hit immediately.

The card drew `embeddingApiKey` twice. The render loop walked every entry in
FIELDS, and the secret — which is drawn by hand, as a write-only password
control — was one of them, so a second control appeared beside it: an ordinary
text input, echoing the key in clear as you typed. One key, two boxes, one of
them visible. The loop skips secrets now, and the tests count controls rather
than checking that each expected label is present, because a duplicate beside
the real control passed the old assertions.

`status` could not answer the one question a new user cannot look up: how wide
is this model? The probe that reports it was gated on every required setting
being present, so in exactly the state that needs the answer — dimensions
unset — it reported nothing and said "Missing: embedding_dimensions". It now
runs whenever the endpoint can be named (a model and a base URL), and the
status message names the width it observed:

  Missing: embedding_dimensions. The endpoint is reachable and returned 768
  dimensions, so set embeddingDimensions to 768.

The probe also tells "not set yet" apart from "set wrong", because the two have
different remedies.

Also documents both ways to read the width by hand, since a wrong value is
frozen into the index at first use and costs a full re-embed to correct.
2026-09-26 07:05:36 +01:00
dsh-slopo agent 85c0d4dccd Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-slopo 2026-09-26 07:05:36 +01:00
dsh-slopo agent 6beb4c0d39 fix: keep local home paths and addresses out of the test fixtures
Three test-only spots had baked in the machine this repository was developed
on, and the repository is publicly readable, so they shipped that machine's
shape to anyone who cloned it:

  * the end-to-end embedding backend was pinned to a private LAN address;
    it is now read from DSH_SLOPO_E2E_API_BASE, defaulting to Ollama's own
    loopback address, so the machine running the suite supplies its own;
  * the real-slopo test resolved discovery from a hardcoded root home, which
    meant it could only ever run for one user — it resolves from the running
    user's home now, which is also the fix for everyone else;
  * the harness-slot contract tests read the global npm tree by absolute
    path; they go through this repository's own node_modules symlink, which
    is already relative to the checkout.

No user-facing behaviour changes: the test files are not in the published
package, and the default configuration was already free of machine-specific
values — every address, path and key defaults to empty and is resolved per
user at runtime.
2026-09-26 07:05:33 +01:00
11 changed files with 436 additions and 49 deletions

No files matched your search

+15
View File
@@ -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
+47
View File
@@ -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
+4
View File
@@ -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))
+100 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+93
View File
@@ -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
View File
@@ -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.
+16
View File
@@ -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
View File
@@ -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/)
},
)