feat: set a conversation's scope from the composer, where the agent cannot reach
Release / release (push) Successful in 6s
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:
1 parent
d9caf4eeec
commit
266e90aa2d
4 files changed
+589
-37
No files matched your search
+173
-14
@@ -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'))
|
||||
})
|
||||
Reference in new issue
Block a user