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

+54 -19
View File
@@ -103,23 +103,22 @@ first turn has run, so choose it when you start the conversation.
### Configure it in your profile, not in the package ### Configure it in your profile, not in the package
The host row's config is where this deployment's facts belong — the profile's own The host row's config is where this *deployment's* facts belong — the profile's
`cordis.patch.yml`: own `cordis.patch.yml`:
```yaml ```yaml
- id: facet-policy - id: facet-policy
name: dsh-facet name: dsh-facet
config: config:
mode: ask # ask | deny | off mode: ask # ask | deny | off
extraRoots: # always in scope, in every session extraRoots: [] # optional: a seed of paths always in scope, every session
- ../../shared # relative to each session's own directory
``` ```
Both fields are also on the plugin's card under **Plugins** in the Web UI, which Both are also on the plugin's card under **Plugins** in the Web UI, which is the
is the same layer reached a different way: a settings write rewrites this entry in same layer reached a different way: a settings write rewrites this entry in the
the profile patch rather than storing anything new. An edit there is live — the profile patch rather than storing anything new. An edit there is live — the guard
guard reads `mode` and `extraRoots` on every call, so a change decides the next reads `mode` and `extraRoots` on every call, so a change decides the next file
file call without a restart. call without a restart.
The one exception is `off`. Choosing it while a session is running stops the guard The one exception is `off`. Choosing it while a session is running stops the guard
immediately and empties the binding on the next request, because the binding is a immediately and empties the binding on the next request, because the binding is a
@@ -132,11 +131,37 @@ patch. `ask` and `deny` swap freely in both directions.
place the rule and the binding among the other prompt sections, which is decided place the rule and the binding among the other prompt sections, which is decided
once at load, and no section is re-ordered without a remount. once at load, and no section is re-ordered without a remount.
`extraRoots` is for code a module legitimately always depends on. Anything you ### Per-session scope, and who gets to decide it
grant by hand is per-conversation and is never persisted — "this session may
also see `src/shared`" is a statement about one conversation, and writing it down Scope has two layers, and the split is the point:
would quietly widen every future one, which is the failure this plugin exists to
prevent. | layer | what it is | who can write it |
|---|---|---|
| **the user's declaration** | `scopes[<session id>]` in this row's config — a `focus` and a list of `extras` | only the context panel, through the settings service |
| **the agent's** | a narrowing `focus` and any session grants, held in memory | the agent, and only ever to *reduce* |
The effective root is the agent's narrowing while it still sits inside your root,
and your root otherwise. So an agent that goes wrong cannot widen its own scope,
and `facet action=reset` returns it to what you declared rather than to the
session directory — landing on the directory would have made `reset` a widening.
You set the declaration from the **crosshair button on the composer tool row**,
which appears only in sessions running the `facet` preset. It shows the focus and
the allowed paths, adds and removes them, and carries the mode details and usage
notes that the preset picker's guide dialog cannot show for a preset no core
package ships. Writes go to the profile patch, so reopening a chat gives back the
scope it had.
Because the panel owns widening, `facet action=add` is the *other* path and the
narrower one: it is gated on an approval channel, and a deployment with none — the
`danger-full-access` permission preset sets `approval: never` — refuses it before
anyone is asked. In that deployment the panel is the only way to widen, which is
exactly what the binding tells the agent.
`extraRoots` is only a seed now. Before per-session scope it was the *only* way to
widen anything, which meant "this conversation needs the entity folder" had to be
written as a fact about every conversation in every workspace. It is for paths a
deployment genuinely always wants, and it is empty by default.
## The compaction row ## The compaction row
@@ -188,13 +213,15 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified: Verified:
- 53 tests (`npm test`) over the scope algebra, the policy state, the guard, and - 73 tests (`npm test`) over the scope algebra, the policy state, the guard, and
the browser half — including that containment is decided on canonical targets the browser half — including that containment is decided on canonical targets
(a symlink inside the facet pointing outside it is refused), that a prefix is (a symlink inside the facet pointing outside it is refused), that a prefix is
not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
call never makes the guard throw, that a live `mode` edit decides the next call call never makes the guard throw, that a live `mode` edit decides the next call
without the guard being remounted, and that every `facet` result satisfies the without the guard being remounted, that an agent narrowing the user has since
tool's own declared output schema. narrowed past is dropped rather than honoured, that `reset` lands on the
declared scope rather than the session directory, and that every `facet` result
satisfies the tool's own declared output schema.
- The bundle's patch layers parse, merge and land correctly against the real - The bundle's patch layers parse, merge and land correctly against the real
profile: `dsh --profile web --patch ./cordis.patch.yml --patch profile: `dsh --profile web --patch ./cordis.patch.yml --patch
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`, ./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
@@ -264,13 +291,13 @@ it.
``` ```
index.js host row: the policy, published as ctx.facetPolicy index.js host row: the policy, published as ctx.facetPolicy
client/client.js browser half: the settings card on the Plugins page client/client.js browser half: the settings card, and the context panel
lib/policy.js the stateful half — scope resolution, grants lib/policy.js the stateful half — scope resolution, grants
lib/scope.js the pure half — argument extraction, text, reasons lib/scope.js the pure half — argument extraction, text, reasons
lib/guard.js preset row: rule, binding, tool, pre-execute guard lib/guard.js preset row: rule, binding, tool, pre-execute guard
presets/facet.patch.yml the `facet` agent preset presets/facet.patch.yml the `facet` agent preset
presets/standard-compaction.patch.yml optional: the same budget for `standard` presets/standard-compaction.patch.yml optional: the same budget for `standard`
test/ 53 tests test/ 73 tests
``` ```
`client/client.js` is a browser half rather than a module the host imports, and `client/client.js` is a browser half rather than a module the host imports, and
@@ -287,6 +314,14 @@ entry id (`facet-policy`, the row's `id` in `cordis.patch.yml`) while the
happens to name them identically, so binding the form to the wrong one produces happens to name them identically, so binding the form to the wrong one produces
a card that renders, saves, and writes nowhere. `test/client.test.js` pins both. a card that renders, saves, and writes nowhere. `test/client.test.js` pins both.
The same file carries the context panel, registered into
`conversation.input.left` — the composer tool row — and gated on the session's own
`agentPreset` projection, so it appears only where the facet is actually in force.
That gate is why `scopes` is `volatile()` in the host schema: `settings.mutate`
refuses a path that is not under a volatile node, and refusing is exactly right —
the panel is the only writer, and a plugin that could not serve the namespace
would be a control that silently stored nothing.
## Also in this repository ## Also in this repository
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from, - [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
+360 -3
View File
@@ -39,7 +39,7 @@ window.__ModuleLoader__.load({
const react = require('react') const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives') const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
const { SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives const { Modal, SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives
const h = react.createElement const h = react.createElement
@@ -248,6 +248,328 @@ window.__ModuleLoader__.load({
) )
} }
/**
* The preset id whose sessions get a context button.
*
* A session records its preset as a projection value, so the button can
* appear exactly where the facet is in force and stay out of the way
* everywhere else. Rendering it unconditionally would put a control for a
* plugin that is not running on every conversation in the deployment.
*/
const FACET_PRESET = 'facet'
/** The composer tool row's entry id, and the button's accessible name. */
const CONTEXT_ENTRY_ID = 'facet-context'
const CONTEXT_LABEL = 'Facet context'
/** The scope one session has declared, normalised, or an empty declaration. */
function declaredFor(snapshot, sessionId) {
const all = snapshot?.value?.scopes
const mine = all !== null && typeof all === 'object' ? all[sessionId] : undefined
const entry = mine !== null && typeof mine === 'object' ? mine : {}
return {
focus: typeof entry.focus === 'string' ? entry.focus : '',
extras: Array.isArray(entry.extras) ? entry.extras.filter((path) => typeof path === 'string') : [],
}
}
/**
* Write one session's whole declaration.
*
* One path, one write: the panel always supplies the complete declaration it
* wants, so a queued edit can never half-apply and leave a session scoped to
* something nobody chose. An empty declaration is removed rather than stored,
* so a session the user has finished with leaves no residue in the profile
* patch — and so clearing both fields means "the session directory", which is
* the schema's own default rather than a stored empty override.
*
* @returns {Promise<boolean>} whether the Host accepted the write.
*/
function writeDeclaration(form, sessionId, next) {
const focus = typeof next.focus === 'string' ? next.focus.trim() : ''
const extras = (next.extras ?? []).map((path) => String(path).trim()).filter((path) => path !== '')
const value = {
...(focus === '' ? {} : { focus }),
...(extras.length === 0 ? {} : { extras }),
}
const op =
Object.keys(value).length === 0
? { op: 'unset', path: ['scopes', sessionId] }
: { op: 'set', path: ['scopes', sessionId], value }
return Promise.resolve(form.mutate([op]))
}
/** Shared control styling, so the panel reads as part of the product. */
const controlStyle = {
boxSizing: 'border-box',
height: '32px',
padding: '0 10px',
fontSize: '13px',
fontFamily: 'inherit',
color: 'var(--dsw-alias-label-primary)',
background: 'var(--dsw-alias-bg-layer-3)',
border: '0.5px solid var(--dsw-alias-border-l4)',
borderRadius: 'var(--dsw-radius-md)',
}
const quietButtonStyle = {
...controlStyle,
cursor: 'pointer',
color: 'var(--dsw-alias-label-secondary)',
background: 'transparent',
}
const sectionTitleStyle = {
margin: '0 0 6px',
fontSize: '13px',
fontWeight: 600,
color: 'var(--dsw-alias-label-primary)',
}
const bodyStyle = { margin: '0 0 14px', fontSize: '12.5px', lineHeight: '19px', color: 'var(--dsw-alias-label-secondary)' }
const listedStyle = { ...bodyStyle, margin: '4px 0 0', fontFamily: 'var(--dsw-font-mono, ui-monospace, monospace)' }
/** The crosshair that marks the button: a scope, not a setting. */
const CONTEXT_GLYPH = h(
'svg',
{ width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
h('circle', { cx: 8, cy: 8, r: 3.2, stroke: 'currentColor', strokeWidth: 1.3 }),
h('path', {
d: 'M8 1.2v2.6M8 12.2v2.6M1.2 8h2.6M12.2 8h2.6',
stroke: 'currentColor',
strokeWidth: 1.3,
strokeLinecap: 'round',
}),
)
/**
* The facet context panel: what this conversation may see, and who decides.
*
* This is the only surface that writes per-session scope. The agent has no
* write path to it — not a narrow one, none — which is what makes "the user
* decides what is in scope" a property of the system rather than of the
* agent's instructions.
*
* @param {object} props - the bound form, the session, and the close command.
*/
function FacetContextPanel(props) {
const { form, sessionId, mode, onClose } = props
// `form` is the settings document for `facet-policy`, already a store: the
// panel renders whatever the Host last accepted, including a write from
// another tab.
const snapshot = react.useSyncExternalStore(
(listener) => form.subscribe(listener),
() => form.getSnapshot(),
)
const declared = declaredFor(snapshot, sessionId)
const [focusDraft, setFocusDraft] = react.useState(declared.focus)
const [extraDraft, setExtraDraft] = react.useState('')
const [failure, setFailure] = react.useState('')
const disabled = snapshot.writable !== true
const write = (next) => {
writeDeclaration(form, sessionId, next).then(
(ok) => setFailure(ok ? '' : 'The deployment did not accept that change.'),
() => setFailure('The deployment did not accept that change.'),
)
}
const rows = declared.extras.map((path) =>
h(
'li',
{ key: path, style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' } },
h('code', { style: { ...listedStyle, margin: 0, flex: 1, wordBreak: 'break-all' } }, path),
h(
'button',
{
type: 'button',
style: { ...quietButtonStyle, height: '26px' },
disabled,
onClick: () => write({ ...declared, extras: declared.extras.filter((other) => other !== path) }),
},
'Remove',
),
),
)
return h(
Modal,
{ open: true, onClose, title: CONTEXT_LABEL, closeLabel: 'Close', description: `Session ${sessionId}` },
h(
'div',
null,
h('h3', { style: sectionTitleStyle }, 'What this conversation may see'),
h(
'p',
{ style: bodyStyle },
'The agent works inside the focus folder. Anything it needs beyond that is listed below, and the agent can narrow the focus on its own but can never widen it — only you can, here.',
),
h('label', { style: sectionTitleStyle, htmlFor: 'facet-focus' }, 'Focus'),
h(
'div',
{ style: { display: 'flex', gap: '8px' } },
h('input', {
id: 'facet-focus',
type: 'text',
style: { ...controlStyle, flex: 1 },
placeholder: 'the folder the session started in',
value: focusDraft,
disabled,
'data-modal-autofocus': true,
onChange: (event) => setFocusDraft(event.target.value),
onKeyDown: (event) => {
if (event.key !== 'Enter') return
event.preventDefault()
write({ ...declared, focus: focusDraft })
},
}),
h(
'button',
{ type: 'button', style: quietButtonStyle, disabled, onClick: () => write({ ...declared, focus: focusDraft }) },
'Set',
),
h(
'button',
{
type: 'button',
style: quietButtonStyle,
disabled,
onClick: () => {
setFocusDraft('')
write({ ...declared, focus: '' })
},
},
'Clear',
),
),
h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute. Empty means the session folder itself.'),
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'),
declared.extras.length === 0
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder go here.')
: h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows),
h(
'div',
{ style: { display: 'flex', gap: '8px', marginTop: '10px' } },
h('input', {
id: 'facet-extra',
type: 'text',
style: { ...controlStyle, flex: 1 },
placeholder: 'src/shared, ../../CONTROLLER.md',
value: extraDraft,
disabled,
onChange: (event) => setExtraDraft(event.target.value),
onKeyDown: (event) => {
if (event.key !== 'Enter') return
event.preventDefault()
if (extraDraft.trim() === '') return
write({ ...declared, extras: [...declared.extras, extraDraft] })
setExtraDraft('')
},
}),
h(
'button',
{
type: 'button',
style: quietButtonStyle,
disabled,
onClick: () => {
if (extraDraft.trim() === '') return
write({ ...declared, extras: [...declared.extras, extraDraft] })
setExtraDraft('')
},
},
'Add',
),
),
h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute — anything the session needs beyond the focus.'),
failure === '' ? null : h('p', { style: { ...bodyStyle, color: 'var(--dsw-alias-state-error-primary)' } }, failure),
h(
'p',
{ style: { ...bodyStyle, marginTop: '18px' } },
`Changes take effect on the next tool call. Deployment mode: ${mode ?? 'ask'}.`,
),
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Mode details'),
h(
'p',
{ style: bodyStyle },
'The mode is set on the plugin card under Plugins, and applies to every session: ' +
'"ask" puts a call outside the facet to you as a question; "deny" refuses it outright; ' +
'"off" disables enforcement while leaving the preset mounted. Where the deployment has no approval ' +
'channel — as here, under the danger-full-access permission preset — "ask" cannot actually reach you, ' +
'so an out-of-facet call is refused and this panel is the way to widen the scope.',
),
h('h3', { style: sectionTitleStyle }, 'How to use'),
h(
'ol',
{ style: { ...bodyStyle, paddingLeft: '18px' } },
h('li', { style: { marginBottom: '6px' } }, 'Start the chat in the repository you want to work in.'),
h('li', { style: { marginBottom: '6px' } }, 'Set Focus to the one folder this conversation is about.'),
h(
'li',
{ style: { marginBottom: '6px' } },
'Add what it needs beyond that — shared types, project docs, a folder it will create a sibling in.',
),
h('li', null, 'Let the agent narrow as the work settles; it will ask you before it needs anything more.'),
),
),
)
}
/**
* The composer tool row's button, and the panel it opens.
*
* Present only for a session running the facet preset, which is what "shows
* up when in facet mode" means mechanically: the session's own projection
* says which preset it runs, so nothing has to be inferred from the URL or
* from the plugin being installed.
*
* @param {object} props - slot props: the session, `useSessions`, and the form.
*/
function FacetContextButton(props) {
const { sessionId, useSessions, form } = props
// Called before the early return, so the hook order is stable across the
// sessions that do and do not run the facet.
const preset = useSessions((state) => state.byId[sessionId]?.projectionValues?.agentPreset)
const [open, setOpen] = react.useState(false)
if (preset !== FACET_PRESET) return null
return h(
react.Fragment,
null,
h(
'button',
{
type: 'button',
'aria-label': CONTEXT_LABEL,
'aria-expanded': open,
title: `${CONTEXT_LABEL} — what this conversation may see`,
onClick: () => setOpen((value) => !value),
style: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
padding: 0,
cursor: 'pointer',
color: open ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
background: open ? 'var(--dsw-alias-interactive-bg-hover)' : 'transparent',
border: 0,
borderRadius: 'var(--dsw-radius-sm)',
},
},
CONTEXT_GLYPH,
),
// Mounted only while open, so each visit starts from what the Host holds
// rather than from a draft left over from the last one.
open ? h(FacetContextPanel, { ...props, onClose: () => setOpen(false) }) : null,
)
}
/** /**
* Mount the facet settings card while the Host serves its namespace. * Mount the facet settings card while the Host serves its namespace.
* *
@@ -263,7 +585,8 @@ window.__ModuleLoader__.load({
// no settings provider loses the card and keeps everything else this bundle // no settings provider loses the card and keeps everything else this bundle
// contributes. // contributes.
ctx.inject(['configForms'], (scoped) => { ctx.inject(['configForms'], (scoped) => {
const card = new FacetCardController(scoped.configForms.get(SETTINGS_ENTRY)) const form = scoped.configForms.get(SETTINGS_ENTRY)
const card = new FacetCardController(form)
scoped.effect(() => () => card.dispose(), 'dsh-facet: settings form') scoped.effect(() => () => card.dispose(), 'dsh-facet: settings form')
scoped.effect( scoped.effect(
@@ -282,10 +605,44 @@ window.__ModuleLoader__.load({
), ),
'dsh-facet: settings page', 'dsh-facet: settings page',
) )
// The context button rides the same form, so the panel and the plugin
// card can never disagree about what the document holds.
scoped.effect(
() =>
scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
scoped.slots.inject('conversation.input.left', () =>
scoped.slots.register(
{
name: 'conversation.input.left',
id: CONTEXT_ENTRY_ID,
order: 20,
inject: () => ({ form }),
},
FacetContextButton,
),
),
),
'dsh-facet: context button',
)
}) })
} }
module.exports = { name: 'dsh-facet', inject: ['slots'], apply, SETTINGS_ENTRY, BUNDLE_CONFIG_KEY, FacetCard, FacetCardController } module.exports = {
name: 'dsh-facet',
inject: ['slots'],
apply,
SETTINGS_ENTRY,
BUNDLE_CONFIG_KEY,
FACET_PRESET,
CONTEXT_ENTRY_ID,
FacetCard,
FacetCardController,
FacetContextButton,
FacetContextPanel,
declaredFor,
writeDeclaration,
}
return module.exports return module.exports
}, },
}) })
+2 -1
View File
@@ -49,7 +49,8 @@
"platform": "web", "platform": "web",
"inject": [ "inject": [
"@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager" "@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-conversation"
] ]
} }
}, },
+173 -14
View File
@@ -31,6 +31,7 @@ async function loadClient() {
const staged = [] const staged = []
const primitives = { const primitives = {
Modal: 'Modal',
SettingsForm: 'SettingsForm', SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField', SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl', SegmentedControl: 'SegmentedControl',
@@ -60,7 +61,15 @@ async function loadClient() {
} }
const require = (id) => { 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 if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives
throw new Error(`client.js required an unexpected module: ${id}`) 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. */ /** Drive `apply` against a fake slot/configForms surface and report what it did. */
function drive(mod, { served = true, value = {} } = {}) { 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 = [] const disposers = []
let currentSlot
const ctx = { const ctx = {
inject: (names, scope) => { inject: (names, scope) => {
@@ -85,7 +95,7 @@ function drive(mod, { served = true, value = {} } = {}) {
configForms: { configForms: {
get: (ns) => { get: (ns) => {
seen.formNs = ns seen.formNs = ns
return { value } return { value, getSnapshot: () => ({ value, writable: true }), subscribe: () => () => {}, mutate: () => true }
}, },
whileServed: (namespaces, register) => { whileServed: (namespaces, register) => {
seen.watched = namespaces seen.watched = namespaces
@@ -97,12 +107,12 @@ function drive(mod, { served = true, value = {} } = {}) {
}, },
slots: { slots: {
inject: (name, register) => { inject: (name, register) => {
seen.slot = name currentSlot = name
register() register()
currentSlot = undefined
}, },
register: (options, component) => { register: (options, component) => {
seen.entry = options seen.registrations.push({ slot: currentSlot, options, component })
seen.card = component
return () => {} return () => {}
}, },
}, },
@@ -120,6 +130,13 @@ function drive(mod, { served = true, value = {} } = {}) {
return { seen, disposers } 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 () => { test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => {
const { module: mod } = await loadClient() const { module: mod } = await loadClient()
assert.equal(mod.name, 'dsh-facet') 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 () => { test('the form binds to the profile entry id while the card registers under the package name', async () => {
const { module: mod } = await loadClient() const { module: mod } = await loadClient()
const { seen } = drive(mod) const { seen } = drive(mod)
const card = registrationFor(seen, 'plugins.bundle.config')
// These two are deliberately different strings. `facet-policy` is the `id` of // 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 // this bundle's row in cordis.patch.yml — what `Config` is filed under. The
// card is dispatched by the BUNDLE's package name. // card is dispatched by the BUNDLE's package name.
assert.equal(seen.formNs, 'facet-policy') assert.equal(seen.formNs, 'facet-policy')
assert.equal(seen.entry.key, 'dsh-facet') assert.equal(card.options.key, 'dsh-facet')
assert.notEqual(seen.formNs, seen.entry.key, 'if these ever coincide, the trap this test guards has moved') 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']) 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 () => { 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 patch = await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
const { module: mod } = await loadClient() const { module: mod } = await loadClient()
const { seen } = drive(mod) 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, // 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. // 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.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 () => { test('nothing is registered while the Host does not serve the namespace', async () => {
const { module: mod } = await loadClient() const { module: mod } = await loadClient()
const { seen } = drive(mod, { served: false }) const { seen } = drive(mod, { served: false })
assert.equal(seen.formNs, 'facet-policy', 'the form is still bound') 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 () => { 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 () => { test('the card renders both views without reaching for anything it was not given', async () => {
const { module: mod } = await loadClient() const { module: mod } = await loadClient()
const { seen } = drive(mod) const { seen } = drive(mod)
const { component: card } = registrationFor(seen, 'plugins.bundle.config')
assert.equal(typeof seen.card, 'function') assert.equal(typeof 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(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', view: 'page',
useFacetCard: (select) => useFacetCard: (select) =>
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[0].children[1].props.value, 'deny')
assert.equal(controls[1].props.text, 'src/shared') 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'))
})