feat: set a conversation's scope from the composer, where the agent cannot reach
Release / release (push) Successful in 6s

The per-session scope the host half now reads had no writer. This is it: a
crosshair on the composer tool row, present only in sessions running the facet
preset, opening a panel that sets the focus and the paths allowed outside it.

The gate is the session's own `agentPreset` projection rather than anything
inferred from the URL or from the plugin being installed, so a control for a
plugin that is not running never appears.

It writes through the settings document — the same one the plugin card edits —
because `settings.mutate` accepts a path under a volatile node and refuses
anything else. That is what makes the split real rather than aspirational: the
agent has no write path to `scopes`, and the panel's write is the only one that
can widen a session.

The panel also carries the mode details and the usage notes, which the preset
picker's guide dialog cannot show for a preset no core package ships — its
guide table holds four shipped ids and is looked up only for built-ins.

Also adds `@deepseek-ai/dsh-client-ui-conversation` to the client half's inject
list: registering into `conversation.input.left` is a dependency on the package
that declares it, and `slots.inject` waits for a slot rather than creating one,
so naming another package's slot without loading it first registers nothing —
silently, because nothing errors.
This commit is contained in:
NotExpectedYet committed 2026-09-27 06:15:53 +01:00
1 parent d9caf4eeec
commit 266e90aa2d
4 files changed
+589 -37

No files matched your search

+173 -14
View File
@@ -31,6 +31,7 @@ async function loadClient() {
const staged = []
const primitives = {
Modal: 'Modal',
SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl',
@@ -60,7 +61,15 @@ async function loadClient() {
}
const require = (id) => {
if (id === 'react') return { createElement: (type, props, ...children) => ({ type, props, children }) }
if (id === 'react') {
return {
createElement: (type, props, ...children) => ({ type, props, children }),
// Single-pass stand-ins: the tests render a component once and read the
// tree, so a hook only has to answer, not to re-render.
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
}
}
if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives
throw new Error(`client.js required an unexpected module: ${id}`)
}
@@ -76,8 +85,9 @@ async function loadClient() {
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
function drive(mod, { served = true, value = {} } = {}) {
const seen = { formNs: undefined, slot: undefined, entry: undefined, card: undefined, watched: undefined }
const seen = { formNs: undefined, watched: undefined, registrations: [] }
const disposers = []
let currentSlot
const ctx = {
inject: (names, scope) => {
@@ -85,7 +95,7 @@ function drive(mod, { served = true, value = {} } = {}) {
configForms: {
get: (ns) => {
seen.formNs = ns
return { value }
return { value, getSnapshot: () => ({ value, writable: true }), subscribe: () => () => {}, mutate: () => true }
},
whileServed: (namespaces, register) => {
seen.watched = namespaces
@@ -97,12 +107,12 @@ function drive(mod, { served = true, value = {} } = {}) {
},
slots: {
inject: (name, register) => {
seen.slot = name
currentSlot = name
register()
currentSlot = undefined
},
register: (options, component) => {
seen.entry = options
seen.card = component
seen.registrations.push({ slot: currentSlot, options, component })
return () => {}
},
},
@@ -120,6 +130,13 @@ function drive(mod, { served = true, value = {} } = {}) {
return { seen, disposers }
}
/** The one registration a test cares about, by the slot it went into. */
function registrationFor(seen, slot) {
const found = seen.registrations.filter((entry) => entry.slot === slot)
assert.equal(found.length, 1, `expected exactly one registration into ${slot}`)
return found[0]
}
test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => {
const { module: mod } = await loadClient()
assert.equal(mod.name, 'dsh-facet')
@@ -130,34 +147,51 @@ test('the module is the one the Plugins page loads, and asks for the slots it re
test('the form binds to the profile entry id while the card registers under the package name', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const card = registrationFor(seen, 'plugins.bundle.config')
// These two are deliberately different strings. `facet-policy` is the `id` of
// this bundle's row in cordis.patch.yml — what `Config` is filed under. The
// card is dispatched by the BUNDLE's package name.
assert.equal(seen.formNs, 'facet-policy')
assert.equal(seen.entry.key, 'dsh-facet')
assert.notEqual(seen.formNs, seen.entry.key, 'if these ever coincide, the trap this test guards has moved')
assert.equal(card.options.key, 'dsh-facet')
assert.notEqual(seen.formNs, card.options.key, 'if these ever coincide, the trap this test guards has moved')
assert.equal(seen.slot, 'plugins.bundle.config')
assert.equal(card.slot, 'plugins.bundle.config')
assert.deepEqual(seen.watched, ['facet-policy'])
})
test('the context button rides the composer tool row, gated on the same namespace', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const button = registrationFor(seen, 'conversation.input.left')
// An id, not a key: `conversation.input.left` is a list, and the row shows one
// entry per id.
assert.equal(button.options.id, 'facet-context')
assert.equal(typeof button.options.inject, 'function')
// It has to receive the settings form, because the panel writes through the
// very same document the plugin card does.
assert.equal(typeof button.options.inject().form, 'object')
})
test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => {
const patch = await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const card = registrationFor(seen, 'plugins.bundle.config')
// A rename in the patch without a rename here is a card that writes nowhere,
// so the id is read out of the patch rather than trusted to the test.
assert.match(patch, new RegExp(`id:\\s*${seen.formNs}\\b`))
assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, seen.entry.key)
assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, card.options.key)
})
test('nothing is registered while the Host does not serve the namespace', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod, { served: false })
assert.equal(seen.formNs, 'facet-policy', 'the form is still bound')
assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves')
// Neither the card nor the button: each would be a control that writes nowhere.
assert.deepEqual(seen.registrations, [])
})
test('the card stages exactly the deployment fields the host marks volatile', async () => {
@@ -212,11 +246,12 @@ test('the list field clears rather than storing an empty array that shadows the
test('the card renders both views without reaching for anything it was not given', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const { component: card } = registrationFor(seen, 'plugins.bundle.config')
assert.equal(typeof seen.card, 'function')
assert.equal(seen.card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.')
assert.equal(typeof card, 'function')
assert.equal(card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.')
const tree = seen.card({
const tree = card({
view: 'page',
useFacetCard: (select) =>
select({
@@ -244,3 +279,127 @@ test('the card renders both views without reaching for anything it was not given
assert.equal(controls[0].children[1].props.value, 'deny')
assert.equal(controls[1].props.text, 'src/shared')
})
test('the client half loads the packages whose slots it registers into', async () => {
const pkg = (await import('../package.json', { with: { type: 'json' } })).default
const inject = pkg.dsh.client.inject
// Registering into a slot is a dependency on the package that declares it.
// `slots.inject` waits for a slot rather than creating one, so a half that
// names a slot belonging to a package the boot graph has not loaded yet
// registers nothing at all — and silently, because nothing errors.
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms')
})
// ── the context button and panel ─────────────────────────────────────────────
test('the context button appears for a facet session and nowhere else', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
const { component: button, options } = registrationFor(seen, 'conversation.input.left')
const { form } = options.inject()
const render = (preset) =>
button({
sessionId: 'session-1',
useSessions: (select) => select({ byId: { 'session-1': { projectionValues: { agentPreset: preset } } } }),
locked: false,
form,
})
assert.notEqual(render('facet'), null)
// Every other preset in the deployment is untouched: a control for a plugin
// that is not running would be worse than no control.
assert.equal(render('standard'), null)
assert.equal(render(undefined), null)
})
test('declaredFor normalises whatever the document holds', async () => {
const { module: mod } = await loadClient()
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 'src/x', extras: ['p'] } } } }, 'a'), {
focus: 'src/x',
extras: ['p'],
})
// A session with no entry, a document with no scopes, and junk in either all
// answer with the same empty declaration rather than throwing in a render.
const empty = { focus: '', extras: [] }
assert.deepEqual(mod.declaredFor({ value: {} }, 'a'), empty)
assert.deepEqual(mod.declaredFor({}, 'a'), empty)
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: 'nonsense' } } }, 'a'), empty)
assert.deepEqual(mod.declaredFor({ value: { scopes: { a: { focus: 7, extras: ['ok', 3, null] } } } }, 'a'), {
focus: '',
extras: ['ok'],
})
})
test('writeDeclaration writes one whole declaration, and removes an empty one', async () => {
const { module: mod } = await loadClient()
const calls = []
const form = { mutate: (ops) => (calls.push(ops), true) }
mod.writeDeclaration(form, 'session-1', { focus: ' src/x ', extras: ['p', ' ', 'q'] })
mod.writeDeclaration(form, 'session-1', { focus: '', extras: [] })
// One path, one write: a queued edit can never half-apply.
assert.deepEqual(calls[0], [{ op: 'set', path: ['scopes', 'session-1'], value: { focus: 'src/x', extras: ['p', 'q'] } }])
// Both fields empty means "the session directory", which is the schema default
// — so it is removed rather than stored as an empty override.
assert.deepEqual(calls[1], [{ op: 'unset', path: ['scopes', 'session-1'] }])
})
test('the panel renders the scope, the guidance, and a way to add and remove', async () => {
const { module: mod } = await loadClient()
const written = []
const form = {
getSnapshot: () => ({
status: 'ready',
writable: true,
revision: 4,
value: { mode: 'deny', scopes: { 'session-1': { focus: 'src/room', extras: ['CONTROLLER.md'] } } },
}),
subscribe: () => () => {},
mutate: (ops) => (written.push(ops), true),
}
const tree = mod.FacetContextPanel({ form, sessionId: 'session-1', mode: 'deny', onClose: () => {} })
assert.equal(tree.type, 'Modal')
// Walk the tree for the text and controls a user would see.
const texts = []
const buttons = []
const inputs = []
const walk = (node) => {
if (typeof node === 'string') return texts.push(node)
if (node === null || node === undefined || typeof node !== 'object') return
if (Array.isArray(node)) return node.forEach(walk)
if (node.type === 'button') buttons.push(node)
if (node.type === 'input') inputs.push(node)
// Children arrive either as createElement arguments or, as the card does it,
// through the `children` prop.
if (Array.isArray(node.children)) node.children.forEach(walk)
walk(node.props?.children)
}
walk(tree)
const labelOf = (node) => (node.children ?? [])[0]
const prose = texts.join('\n')
assert.match(prose, /What this conversation may see/)
// The two tabs the preset picker cannot show for a custom preset live here.
assert.match(prose, /Mode details/)
assert.match(prose, /How to use/)
// The honest bit about this deployment, so the panel does not promise a
// question no approval channel can carry.
assert.match(prose, /no approval channel/)
assert.match(prose, /danger-full-access/)
// The declared scope is on screen, and the existing extra has its own control.
assert.equal(inputs[0].props.value, 'src/room')
assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1)
assert.ok(buttons.some((button) => labelOf(button) === 'Add'))
assert.ok(buttons.some((button) => labelOf(button) === 'Clear'))
assert.ok(buttons.some((button) => labelOf(button) === 'Set'))
})