feat(settings): serve every registered namespace and key plugin cards on it

A plugin that registered a settings namespace could not reach the browser
configuration page: the api-proxy filtered every read and gated every
write through two hardcoded namespace lists, and the plugin configuration
section rendered an unordered list of cards carrying an opaque id rather
than the namespace they edit. Both gates lived in this repository, so a
user-authored plugin was configurable only by hand-editing settings.yaml.

The proxy now serves whatever ctx.settings.describe() returns and adds no
boundary of its own; a name no registration answers folds into the seam's
own settings-rejected, and the settings-not-exposed code retires. The
settings seam is untouched: which client may read a namespace, and which
page renders it, are facts about consumers.

settings.plugin.item becomes a keyed slot whose key is the namespace a
card edits, following tool.call.toolview. The section reads describe once
and dispatches the intersection of the slot ledger and the served set, so
a namespace another surface owns renders nothing without declaring
anything, and a card for an uncomposed plugin is never dispatched.
This commit is contained in:
Yichen Jiang
2026-08-12 21:07:57 +08:00
parent 4bc526f40b
commit 4366528a38
36 changed files with 745 additions and 197 deletions

View File

@@ -13,12 +13,31 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
async function bench() {
/**
* @param served - namespaces the Host describes; omitted answers a failed read,
* which is what most of these specs want (no card has anything to render).
*/
async function bench(served?: string[]) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } }))
const describeSettings = vi.fn(() => Promise.resolve(served === undefined
? { rpcId: 's', result: { ok: false, error: {} } }
: {
rpcId: 's',
result: {
ok: true,
value: {
writable: true,
hasDocument: true,
namespaces: served.map(ns => ({
ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
})),
},
},
}))
// The section binds its scopes through the Settings surface's service, and
// forwarded Host events reach it through the same `$dispatch` handoff the
// connection sink makes.
@@ -26,12 +45,12 @@ async function bench() {
ctx.provide('connection', {
isLoopback: true,
api: {
settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) },
settings: { describe: describeSettings },
credentials: { describe: describeCredentials },
},
} as never)
await ctx.plugin(SettingsScopeService).await()
return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials }
return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials, describeSettings }
}
function declareRoot(slots: SlotsService): () => void {
@@ -56,26 +75,40 @@ describe('ui-plugin-config apply', () => {
expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
// The nav label is a locale-following thunk; owners resolve it at read time.
expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' })
})
it('registers one card per host-plane section it ships, in a stable order', async () => {
it('keys each card it ships on the settings namespace that card edits', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('settings.plugin.item').map(entry => entry.options.id))
.toEqual(['bash', 'agent-loop', 'web-search'])
expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
.toEqual(['bash', 'agent-loop', 'web-search-deepseek'])
})
it('injects a live card count and one business face per card', async () => {
const { ctx, slots } = await bench()
it('dispatches the served namespaces its cards claim, and no others', async () => {
// ui-theme is served but belongs to another surface, and a deployment
// composing no PowerShell/POSIX executor serves no `bash` at all.
const { ctx, slots } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek'])
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = slots.entries('settings.section')[0]!
expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
const face = (section as { inject?: () => unknown })
.inject?.() as { hooks: { pluginConfigSection: { getSnapshot: () => { namespaces: string[] } } } }
await vi.waitFor(() => {
expect(face.hooks.pluginConfigSection.getSnapshot().namespaces)
.toEqual(['agent-loop', 'web-search-deepseek'])
})
})
it('injects one business face per card', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
for (const entry of slots.entries('settings.plugin.item')) {
const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
// Each card injects exactly one snapshot store plus its own actions.

View File

@@ -20,6 +20,7 @@ import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
import type { BashCardState } from '../src/client/bash-store.ts'
import type { CardFieldState, CardShell } from '../src/client/card-store.ts'
import type { PluginConfigSectionState } from '../src/client/section-store.ts'
import type { WebSearchCardState } from '../src/client/web-search-store.ts'
import { en } from '../src/client/locales.ts'
@@ -46,11 +47,20 @@ function cardActions() {
return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
}
function renderSection(cardCount: number, cards = 'cards') {
/**
* Render the section over the namespaces it was told to dispatch, with `cards`
* standing in for the slot ledger: a key it names renders that text, and one
* it does not renders nothing, exactly as an unclaimed key does.
*/
function renderSection(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
const store = createSnapshotStore<PluginConfigSectionState>({ loaded, namespaces })
const props = {
t,
cardCount,
renderSlot: () => <li>{cards}</li>,
usePluginConfigSection: bindSnapshotSelector(store),
renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => {
const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
return card === undefined ? null : <li>{card}</li>
},
} as unknown as PluginConfigSectionProps
render(<PluginConfigSection {...props} />)
}
@@ -70,21 +80,30 @@ function renderBash(state: Partial<BashCardState> = {}) {
describe('PluginConfigSection', () => {
it('says so when no plugin contributed a card', () => {
renderSection(0)
renderSection([], { bash: 'shell' })
expect(screen.getByText(en.empty)).toBeTruthy()
expect(screen.queryByText('cards')).toBeNull()
expect(screen.queryByText('shell')).toBeNull()
})
it('renders the card list once a plugin contributed one', () => {
renderSection(1)
it('withholds the empty line until the Host has answered once', () => {
// An unanswered read is not the statement that this deployment configures
// no plugin; saying it anyway would flash a wrong answer on every open.
renderSection([], { bash: 'shell' }, false)
expect(screen.getByText('cards')).toBeTruthy()
expect(screen.queryByText(en.empty)).toBeNull()
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
})
it('dispatches one card per namespace, keyed by it', () => {
renderSection(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' })
expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop'])
expect(screen.queryByText(en.empty)).toBeNull()
})
it('leads with its own heading and intro', () => {
renderSection(1)
renderSection(['bash'], { bash: 'shell' })
expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
expect(screen.getByText(en.intro)).toBeTruthy()

View File

@@ -8,6 +8,7 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie
import { CardForm, numberField, textField } from '../src/client/card-store.ts'
import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts'
import { BashCardController, type BashSettings } from '../src/client/bash-store.ts'
import { PluginConfigSectionController } from '../src/client/section-store.ts'
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts'
/** Make the stub behave like a Host that accepts every write. */
@@ -538,3 +539,96 @@ describe('WebSearchCardController', () => {
expect(credentials.set).not.toHaveBeenCalled()
})
})
describe('PluginConfigSectionController', () => {
function settingsApi(namespaces: string[]) {
const describe = vi.fn(() => Promise.resolve({
rpcId: 's-1' as never,
result: {
ok: true as const,
value: {
writable: true,
hasDocument: true,
namespaces: namespaces.map(ns => ({
ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
})),
},
},
}))
return { api: { settings: { describe } } as never, describe }
}
/** Slot ledger stand-in: one stored entry per registered card key. */
function ledger(...keys: string[]) {
return keys.map(key => ({ component: null, options: { key } }))
}
it('dispatches the served namespaces a card claims, in card registration order', async () => {
const settings = settingsApi(['bash', 'ui-theme', 'agent-loop'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('agent-loop', 'bash'))
await controller.load()
// ui-theme is served but claimed by no card here — another surface owns
// it. The order is the cards', not the Host's: plugin activation can
// reorder the description between boots.
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces)
.toEqual(['agent-loop', 'bash'])
})
it('never dispatches a card whose namespace this deployment does not serve', async () => {
const settings = settingsApi(['bash'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash', 'web-search-deepseek'))
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
})
it('takes a card registered after the read without asking the Host again', async () => {
const settings = settingsApi(['bash'])
let entries = ledger()
const controller = new PluginConfigSectionController(settings.api, () => entries)
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual([])
entries = ledger('bash')
controller.refresh()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
expect(settings.describe).toHaveBeenCalledOnce()
})
it('keeps the namespaces it knew when a read fails', async () => {
const settings = settingsApi(['bash'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash'))
await controller.load()
settings.describe.mockRejectedValueOnce(new Error('offline') as never)
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash'])
})
it('publishes nothing once disposed, and never claims it was answered', async () => {
const settings = settingsApi(['bash'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash'))
controller.dispose()
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot())
.toEqual({ loaded: false, namespaces: [] })
expect(settings.describe).not.toHaveBeenCalled()
})
it('reports the Host answered even when it serves nothing this section shows', async () => {
const settings = settingsApi(['ui-theme'])
const controller = new PluginConfigSectionController(settings.api, () => ledger('bash'))
await controller.load()
expect(controller.inject().hooks.pluginConfigSection.getSnapshot())
.toEqual({ loaded: true, namespaces: [] })
})
})