fix: show the settings card, and make an edit actually take effect
`mode` and `extraRoots` carried no `volatile()` marker, so the framework's
form projection dropped the whole entry from `settings.describe` and the plugin
had no card under Plugins at all. The marker is what creates the card.
It is also what turns each field into a reference rather than a value: the
resolved config hands `apply` a `{get(), [write]}` per volatile field, which is
how a settings write lands in a running plugin without re-applying it. So the
policy and the guard now read both fields at the point of use instead of
snapshotting them at mount, and `sectionOrder`/`contextOrder` stay unmarked
because a prompt section cannot be re-ordered without a remount.
A section is also registered once as a plain string, so the rule could not
follow a live `mode` without freezing at load. The mode-specific sentence moved
into the binding, which is a callback.
This commit is contained in:
1 parent
c41c555400
commit
28f20a8aac
8 files changed
+283
-59
No files matched your search
+30
-2
@@ -18,6 +18,15 @@ import { after, before, test } from 'node:test'
|
||||
|
||||
import { apply as guard } from '../lib/guard.js'
|
||||
import { createFacetPolicy } from '../lib/policy.js'
|
||||
import { Config } from '../index.js'
|
||||
|
||||
/**
|
||||
* The symbol cosmokit marks a volatile config reference with. A settings write
|
||||
* reaches a running plugin as `target[write](value)` — that is literally what
|
||||
* `updateVolatile` does — so a test can commit an edit the way the framework
|
||||
* does, without a settings service.
|
||||
*/
|
||||
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
|
||||
|
||||
/** A filesystem provider with `dsh-fs-local`'s two relevant behaviours. */
|
||||
const fsLikeLocal = {
|
||||
@@ -50,8 +59,8 @@ const fsLikeLocal = {
|
||||
}
|
||||
|
||||
/** Capture what `apply` registered, so the test can drive it. */
|
||||
function harness({ mode = 'ask', extraRoots = [], tool = true } = {}) {
|
||||
const policy = createFacetPolicy({ mode, extraRoots })
|
||||
function harness({ mode = 'ask', extraRoots = [], tool = true, config } = {}) {
|
||||
const policy = createFacetPolicy(config ?? { mode, extraRoots })
|
||||
const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined }
|
||||
const ctx = {
|
||||
facetPolicy: policy,
|
||||
@@ -217,6 +226,25 @@ test('off mode registers nothing', () => {
|
||||
assert.equal(captured.tool, undefined)
|
||||
})
|
||||
|
||||
test('a live mode edit decides the next call, without the guard being remounted', async () => {
|
||||
// The loader hands the guard the schema-resolved config, so `mode` arrives as
|
||||
// a volatile reference rather than a string. This drives that exact shape.
|
||||
const config = Config({ mode: 'ask' })
|
||||
const { captured } = harness({ config })
|
||||
const outside = { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent() }
|
||||
|
||||
assert.equal((await dispatch(captured, outside)).kind, 'ask')
|
||||
|
||||
// What a settings write does: commit into the reference in place, and
|
||||
// deliberately do not re-apply the plugin. One mount, three answers.
|
||||
config.mode[VOLATILE_WRITE]('deny')
|
||||
assert.equal((await dispatch(captured, outside)).kind, 'deny')
|
||||
|
||||
// `off` silences the guard entirely, which is the whole meaning of the mode.
|
||||
config.mode[VOLATILE_WRITE]('off')
|
||||
assert.equal((await dispatch(captured, outside)).kind, 'allow')
|
||||
})
|
||||
|
||||
test('the guard adds to a decision, never overrides one', async () => {
|
||||
const { captured } = harness()
|
||||
const denied = await dispatch(captured, {
|
||||
|
||||
+61
-2
@@ -10,6 +10,18 @@ import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { apply, Config } from '../index.js'
|
||||
import { configValue } from '../lib/policy.js'
|
||||
|
||||
/**
|
||||
* The symbol cosmokit marks a volatile config reference with, and the writer it
|
||||
* carries. A settings write reaches a running plugin through exactly this
|
||||
* function — `updateVolatile(target, source)` is `target[write](source.get())` —
|
||||
* so a test can commit an edit the same way the framework does.
|
||||
*/
|
||||
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
|
||||
|
||||
/** Whether a field arrived as a live reference rather than a plain value. */
|
||||
const isReference = (value) => typeof value === 'object' && value !== null && VOLATILE_WRITE in value
|
||||
|
||||
/** The smallest context this row touches. */
|
||||
function harness() {
|
||||
@@ -28,12 +40,59 @@ function harness() {
|
||||
|
||||
test('the schema fills every default, so a bare row is a working row', () => {
|
||||
const config = Config({})
|
||||
assert.equal(config.mode, 'ask')
|
||||
assert.deepEqual(config.extraRoots, [])
|
||||
assert.equal(configValue(config.mode), 'ask')
|
||||
assert.deepEqual(configValue(config.extraRoots), [])
|
||||
assert.equal(config.sectionOrder, 100)
|
||||
assert.equal(config.contextOrder, 105)
|
||||
})
|
||||
|
||||
test('the editable fields are volatile, which is the only reason they have a settings card', () => {
|
||||
const config = Config({})
|
||||
// The framework's form projection keeps only fields under a volatile ancestor
|
||||
// and drops an entry with no such field from `settings.describe` entirely, so
|
||||
// dropping either of these markers silently removes the card from the UI.
|
||||
assert.equal(isReference(config.mode), true)
|
||||
assert.equal(isReference(config.extraRoots), true)
|
||||
|
||||
// Placement is settled once at load: not settings anyone edits live, and not
|
||||
// on the card.
|
||||
assert.equal(isReference(config.sectionOrder), false)
|
||||
assert.equal(isReference(config.contextOrder), false)
|
||||
})
|
||||
|
||||
test('a settings edit is answered on the next call, without the plugin being re-applied', () => {
|
||||
const provided = []
|
||||
let applies = 0
|
||||
const config = Config({ mode: 'ask', extraRoots: ['/w/src/shared'] })
|
||||
apply(
|
||||
{
|
||||
provide: (name, value) => {
|
||||
applies += 1
|
||||
provided.push({ name, value })
|
||||
},
|
||||
on: () => {},
|
||||
},
|
||||
config,
|
||||
)
|
||||
const policy = provided[0].value
|
||||
assert.equal(policy.mode, 'ask')
|
||||
assert.deepEqual(policy.standing, ['/w/src/shared'])
|
||||
|
||||
// What the framework does on a settings write: it commits into the running
|
||||
// references and deliberately does NOT re-apply the plugin. Everything the
|
||||
// policy reports has to follow from the reference alone.
|
||||
config.mode[VOLATILE_WRITE]('deny')
|
||||
config.extraRoots[VOLATILE_WRITE](['/w/src/shared', '/w/src/types'])
|
||||
|
||||
assert.equal(applies, 1)
|
||||
assert.equal(policy.mode, 'deny')
|
||||
assert.deepEqual(policy.standing, ['/w/src/shared', '/w/src/types'])
|
||||
assert.deepEqual(
|
||||
policy.scopeFor({ session: { id: 's1', header: { cwd: '/w/src/room' } } }).extras,
|
||||
['/w/src/shared', '/w/src/types'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a bad mode fails loudly at load rather than at the first file call', () => {
|
||||
assert.throws(() => Config({ mode: 'maybe' }))
|
||||
})
|
||||
|
||||
+26
-12
@@ -51,33 +51,47 @@ test('isWideningCall is true only for the one act that needs the user', () => {
|
||||
})
|
||||
|
||||
test('facetRuleText states the rule and never the binding', () => {
|
||||
const ask = facetRuleText({ mode: 'ask' })
|
||||
assert.match(ask, /put to the user as a question/)
|
||||
// The two modes must not read alike: ask must not claim the call is refused.
|
||||
assert.doesNotMatch(ask, /A call outside it is refused/)
|
||||
const rule = facetRuleText()
|
||||
|
||||
const deny = facetRuleText({ mode: 'deny' })
|
||||
assert.match(deny, /A call outside it is refused/)
|
||||
// Mode-neutral by construction. A prompt section is registered once as a plain
|
||||
// string, and a `volatile()` edit updates the config reference without
|
||||
// re-applying the plugin — so a `mode` interpolated here would freeze at load
|
||||
// while the guard went on enforcing the new one. The mode-specific sentence
|
||||
// lives in the binding, which is a callback.
|
||||
assert.match(rule, /Work inside the facet\./)
|
||||
assert.doesNotMatch(rule, /refused|put to the user as a question/)
|
||||
assert.match(rule, /Never widen the scope on your own initiative/)
|
||||
|
||||
// The rule must not name a path: a changing system prompt invalidates the
|
||||
// prompt cache for the whole conversation.
|
||||
assert.doesNotMatch(ask, /\/|src\//)
|
||||
assert.doesNotMatch(rule, /\/|src\//)
|
||||
|
||||
assert.equal(facetRuleText({ mode: 'off' }), '')
|
||||
assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '')
|
||||
})
|
||||
|
||||
test('facetContextText carries the binding that does change', () => {
|
||||
const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' })
|
||||
assert.equal(
|
||||
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }),
|
||||
'facet: /w/src/room\noutside the facet: ask the user',
|
||||
ask,
|
||||
'facet: /w/src/room\n' +
|
||||
'outside the facet: put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again',
|
||||
)
|
||||
|
||||
const deny = facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' })
|
||||
assert.equal(
|
||||
facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }),
|
||||
'facet: /w/src/room\ngranted outside it: src/shared\noutside the facet: refused',
|
||||
deny,
|
||||
'facet: /w/src/room\n' +
|
||||
'granted outside it: src/shared\n' +
|
||||
'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope',
|
||||
)
|
||||
|
||||
// The binding is the only half that can follow a live edit, so the two modes
|
||||
// have to read differently here — they no longer differ in the rule.
|
||||
assert.notEqual(ask, deny)
|
||||
|
||||
// Unscoped sessions contribute nothing rather than an empty heading.
|
||||
assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '')
|
||||
assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '')
|
||||
})
|
||||
|
||||
test('displayPath prefers the shortest unambiguous form', () => {
|
||||
|
||||
Reference in new issue
Block a user