Files
deepseek-harness/packages/client/ui-agent-preset/tests/section-store.spec.ts
Yichen Jiang 7281615d44 feat(agent-presets): give a preset a name and a description
A picker showed directory names, so the settings page could only ever list
`standard` / `core-web` / `cordis` and hope the reader knew what they meant.
A preset may now publish display text in an optional `preset.yml` beside
its composition, and the section renders cards — name, description, and the
one in use — instead of rows.

The file carries display text ONLY. `id` is the directory name and `trust`
comes from the root a preset was discovered under, so neither is writable
there: otherwise a locally authored preset could name itself into the
shipped set. It is a separate file because a composition is a top-level list
of plugin rows — YAML cannot carry sibling keys beside it, and a fake
metadata row would hand the Loader something to load.

Every read failure degrades to no metadata; absent, malformed, wrongly
typed, and blank all mean the same thing and the picker falls back to the
id. Presentation is not capability: a preset whose name is broken still
mounts.

The editor gained name and description fields above the YAML, and clearing
both removes the file rather than storing a blank name.
2026-08-07 00:41:50 +08:00

579 lines
20 KiB
TypeScript

/**
* The agent-preset management controller: it holds one draft at a time, opens
* a shipped preset read-only, treats "new" as a copy of an existing
* composition, and re-reads the roster after every mutation because a save can
* change more than the row it targeted.
*/
import { describe, expect, it } from 'vitest'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
import type { PresetDraft, PresetRow } from '../src/client/section-store.ts'
interface FakePreset { trust: 'system' | 'user'; content: string }
interface Recorded { method: string; payload: unknown }
interface FakeOptions {
/** Every call the controller made, in order. */
calls?: Recorded[]
/** Reject `list` with this message. */
failList?: string
/** Reject `read` with this message. */
failRead?: string
/** Reject `write` with this message. */
failWrite?: string
/** Reject `remove` with this message. */
failRemove?: string
/** Reject `settings.update` with this message. */
failSettings?: string
/** Throw from `list` rather than answering, as a dead transport does. */
throwList?: boolean
/** Whether the deployment configures a writable root. */
authorable?: boolean
/** Hold `remove` until this resolves, to observe the in-flight state. */
holdRemove?: Promise<void>
}
const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } })
const fail = (message: string) =>
Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } })
/**
* A wire face over an in-memory preset store: writes land, so the roster the
* controller re-reads after a save is the one the save produced.
* @param presets - the starting compositions by id.
* @param defaultId - the preset a session with no choice gets.
* @param options - failure injection and call recording.
* @returns the fake client.
*/
function fakeApi(
presets: Map<string, FakePreset>,
defaultId: { id: string },
options: FakeOptions = {},
): Pick<IApiClient, 'agentPresets' | 'settings'> {
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
return {
agentPresets: {
list: () => {
record('list', {})
if (options.throwList === true) return Promise.reject(new Error('socket closed'))
if (options.failList !== undefined) return fail(options.failList)
return ok({
presets: [...presets].map(([id, preset]) => ({
id, trust: preset.trust, isDefault: id === defaultId.id,
})),
authorable: options.authorable ?? true,
})
},
read: (payload: { agentPreset: string }) => {
record('read', payload)
if (options.failRead !== undefined) return fail(options.failRead)
const preset = presets.get(payload.agentPreset)
/* v8 ignore next -- every test reads an id the fake store holds */
if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`)
return ok({
agentPreset: payload.agentPreset,
trust: preset.trust,
content: preset.content,
writable: preset.trust === 'user',
})
},
write: (payload: { agentPreset: string; content: string }) => {
record('write', payload)
if (options.failWrite !== undefined) return fail(options.failWrite)
presets.set(payload.agentPreset, { trust: 'user', content: payload.content })
return ok({ agentPreset: payload.agentPreset })
},
remove: async (payload: { agentPreset: string }) => {
record('remove', payload)
await options.holdRemove
if (options.failRemove !== undefined) return await fail(options.failRemove)
presets.delete(payload.agentPreset)
return await ok({})
},
},
settings: {
update: (payload: { ns: string; patch: { default?: string } }) => {
record('settings.update', payload)
if (options.failSettings !== undefined) return fail(options.failSettings)
/* v8 ignore next -- the controller only ever patches `default` */
defaultId.id = payload.patch.default ?? defaultId.id
return ok({})
},
},
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>
}
function seed(): Map<string, FakePreset> {
return new Map<string, FakePreset>([
['standard', { trust: 'system', content: '- id: tool-bash\n' }],
['mine', { trust: 'user', content: '- id: tool-read\n' }],
])
}
function harness(options: FakeOptions = {}) {
const presets = seed()
const defaultId = { id: 'standard' }
const calls: Recorded[] = []
const controller = new AgentPresetSectionController(
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
)
return { controller, presets, defaultId, calls }
}
function draftOf(controller: AgentPresetSectionController): PresetDraft {
const { draft } = controller.store.getSnapshot()
if (draft === null) throw new Error('expected an open draft')
return draft
}
describe('loading the roster', () => {
it('reports the presets, their trust, the default, and whether authoring is possible', async () => {
const { controller } = harness()
await controller.load()
const state = controller.store.getSnapshot()
expect(state.status).toBe('ready')
expect(state.authorable).toBe(true)
expect(state.rows).toEqual([
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'mine', trust: 'user', isDefault: false },
])
})
it('treats an empty roster as a deployment that composes no presets', async () => {
const presets = new Map<string, FakePreset>()
const controller = new AgentPresetSectionController(fakeApi(presets, { id: '' }, { authorable: false }))
await controller.load()
// Not an error: every session then shares the host composition, and the
// section renders nothing rather than an empty management page.
expect(controller.store.getSnapshot().status).toBe('unavailable')
expect(controller.store.getSnapshot().authorable).toBe(false)
})
it('surfaces a rejected roster call', async () => {
const { controller } = harness({ failList: 'roster unavailable' })
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'roster unavailable' })
})
it('surfaces a transport that rejects rather than answering', async () => {
const { controller } = harness({ throwList: true })
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
})
it('ignores a load while one is already in flight', async () => {
const { controller, calls } = harness()
await Promise.all([controller.load(), controller.load()])
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
})
})
describe('opening a composition', () => {
it('opens a locally authored preset for editing', async () => {
const { controller } = harness()
await controller.load()
await controller.open('mine')
expect(draftOf(controller)).toMatchObject({
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n', writable: true,
})
})
it('opens a shipped preset read-only', async () => {
const { controller } = harness()
await controller.load()
await controller.open('standard')
// Readable on purpose: it is the known-good composition a local preset is
// written against, and duplicating it is how authoring starts.
expect(draftOf(controller)).toMatchObject({ writable: false, content: '- id: tool-bash\n' })
})
it('surfaces a rejected read on the page rather than opening an empty editor', async () => {
const { controller } = harness({ failRead: 'permission denied' })
await controller.load()
await controller.open('mine')
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'permission denied' })
})
it('surfaces a transport failure on the page', async () => {
const presets = seed()
const api = fakeApi(presets, { id: 'standard' })
const controller = new AgentPresetSectionController({
...api,
agentPresets: { ...api.agentPresets, read: () => Promise.reject(new Error('socket closed')) },
})
await controller.load()
await controller.open('mine')
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'socket closed' })
})
it('closes the editor without writing anything', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('mine')
controller.setContent('- id: changed\n')
controller.close()
expect(controller.store.getSnapshot().draft).toBeNull()
expect(calls.some(call => call.method === 'write')).toBe(false)
})
})
describe('creating a preset', () => {
it('copies the default composition when no source is named', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom()
// The default is the composition a new session gets, so it is the one
// worth starting from — and a copy is writable wherever it came from.
expect(draftOf(controller)).toMatchObject({ id: '', source: 'standard', creating: true, writable: true })
expect(draftOf(controller).content).toBe('- id: tool-bash\n')
})
it('copies a named preset', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom('mine')
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
})
it('does nothing before the roster loaded', async () => {
const { controller, calls } = harness()
await controller.createFrom()
expect(controller.store.getSnapshot().draft).toBeNull()
expect(calls).toHaveLength(0)
})
})
describe('the save blocker', () => {
const base: PresetDraft = {
id: '', source: 'standard', creating: true, content: '', writable: true,
name: '', description: '', saving: false, error: null,
}
const rows: readonly PresetRow[] = [{ id: 'mine', trust: 'user', isDefault: false }]
it('never blocks an edit of an existing preset', () => {
expect(draftBlocker({ ...base, id: 'mine', creating: false }, rows)).toBeUndefined()
})
it('requires a name', () => {
expect(draftBlocker(base, rows)).toBe('idRequired')
})
it.each(['Upper', 'has space', '-leading', 'a/b', '../escape'])('rejects the unusable id %j', (id) => {
// The id becomes a directory name, so the client mirrors the host's own
// containment rule instead of letting the save round-trip to find out.
expect(draftBlocker({ ...base, id }, rows)).toBe('idInvalid')
})
it('rejects a name already in use', () => {
// Replacing is what Edit is for; a create landing on an existing name
// would overwrite a preset the user never opened.
expect(draftBlocker({ ...base, id: 'mine' }, rows)).toBe('idTaken')
})
it('accepts an unused, containable id', () => {
expect(draftBlocker({ ...base, id: 'my-agent2' }, rows)).toBeUndefined()
})
})
describe('saving', () => {
it('creates the preset and re-reads the roster', async () => {
const { controller, presets } = harness()
await controller.load()
await controller.createFrom()
controller.setId('my-agent')
controller.setContent('- id: tool-web-search\n')
await controller.save()
expect(presets.get('my-agent')).toEqual({ trust: 'user', content: '- id: tool-web-search\n' })
expect(controller.store.getSnapshot().draft).toBeNull()
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('my-agent')
})
it('replaces an existing composition', async () => {
const { controller, presets } = harness()
await controller.load()
await controller.open('mine')
controller.setContent('- id: tool-edit\n')
await controller.save()
expect(presets.get('mine')?.content).toBe('- id: tool-edit\n')
})
it('refuses to write a blocked draft', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.createFrom()
await controller.save()
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(controller.store.getSnapshot().draft).not.toBeNull()
})
it('refuses to write a read-only draft', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('standard')
await controller.save()
expect(calls.some(call => call.method === 'write')).toBe(false)
})
it('does nothing without an open draft', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.save()
expect(calls.some(call => call.method === 'write')).toBe(false)
})
it('keeps the draft open and reports a rejected save', async () => {
const { controller } = harness({ failWrite: 'composition is not an entry list' })
await controller.load()
await controller.open('mine')
await controller.save()
// The text stays in the editor: it is the only copy, and the message says
// what to fix.
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'composition is not an entry list' })
})
it('reports a transport that rejects mid-save', async () => {
const presets = seed()
const api = fakeApi(presets, { id: 'standard' })
const controller = new AgentPresetSectionController({
...api,
agentPresets: { ...api.agentPresets, write: () => Promise.reject(new Error('socket closed')) },
})
await controller.load()
await controller.open('mine')
await controller.save()
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'socket closed' })
})
it('ignores a second save while one is in flight', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('mine')
await Promise.all([controller.save(), controller.save()])
expect(calls.filter(call => call.method === 'write')).toHaveLength(1)
})
it('clears a save failure when the text changes', async () => {
const { controller } = harness({ failWrite: 'invalid' })
await controller.load()
await controller.open('mine')
await controller.save()
controller.setContent('- id: fixed\n')
expect(draftOf(controller).error).toBeNull()
})
it('carries the display name and description through a save', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('mine')
controller.setName('我的模式')
controller.setDescription('只做检索。')
await controller.save()
expect(calls.find(call => call.method === 'write')?.payload)
.toMatchObject({ agentPreset: 'mine', name: '我的模式', description: '只做检索。' })
})
it('leaves a copy unnamed so two rows cannot present themselves alike', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom('mine')
// The composition is copied verbatim; the display name is not.
expect(draftOf(controller)).toMatchObject({ id: '', name: '' })
expect(draftOf(controller).content).toBe('- id: tool-read\n')
})
it('ignores an edit with no draft open', () => {
const { controller } = harness()
controller.setId('x')
controller.setContent('y')
controller.setName('n')
controller.setDescription('d')
expect(controller.store.getSnapshot().draft).toBeNull()
})
})
describe('deleting', () => {
it('deletes the confirmed preset and re-reads the roster', async () => {
const { controller, presets } = harness()
await controller.load()
controller.confirmDelete('mine')
await controller.remove()
expect(presets.has('mine')).toBe(false)
expect(controller.store.getSnapshot()).toMatchObject({ pendingDelete: null, deleting: false })
expect(controller.store.getSnapshot().rows.map(row => row.id)).toEqual(['standard'])
})
it('closes an editor open on the deleted preset', async () => {
const { controller } = harness()
await controller.load()
await controller.open('mine')
controller.confirmDelete('mine')
await controller.remove()
// The file is gone; leaving its text in an editor whose Save would
// resurrect it is worse than closing.
expect(controller.store.getSnapshot().draft).toBeNull()
})
it('leaves a copy-in-progress open when its source is deleted', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom('mine')
controller.setId('mine')
controller.confirmDelete('mine')
await controller.remove()
// The draft is a new preset that happens to be named after the one just
// deleted; its text is unsaved work.
expect(draftOf(controller)).toMatchObject({ id: 'mine', creating: true })
})
it('dismisses the confirmation without deleting', async () => {
const { controller, calls, presets } = harness()
await controller.load()
controller.confirmDelete('mine')
controller.confirmDelete(null)
await controller.remove()
expect(calls.some(call => call.method === 'remove')).toBe(false)
expect(presets.has('mine')).toBe(true)
})
it('reports a refused delete on the page', async () => {
const { controller } = harness({ failRemove: 'it ships with the deployment' })
await controller.load()
controller.confirmDelete('standard')
await controller.remove()
expect(controller.store.getSnapshot()).toMatchObject({
pendingDelete: null, deleting: false, error: 'it ships with the deployment',
})
})
it('reports a transport that rejects mid-delete', async () => {
const presets = seed()
const api = fakeApi(presets, { id: 'standard' })
const controller = new AgentPresetSectionController({
...api,
agentPresets: { ...api.agentPresets, remove: () => Promise.reject(new Error('socket closed')) },
})
await controller.load()
controller.confirmDelete('mine')
await controller.remove()
expect(controller.store.getSnapshot()).toMatchObject({ deleting: false, error: 'socket closed' })
})
it('ignores a second delete while one is in flight', async () => {
const { controller, calls } = harness()
await controller.load()
controller.confirmDelete('mine')
await Promise.all([controller.remove(), controller.remove()])
expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
})
it('ignores a confirmation change while a delete is in flight', async () => {
let release = (): void => {}
const held = new Promise<void>((resolve) => { release = resolve })
const presets = seed()
const controller = new AgentPresetSectionController(
fakeApi(presets, { id: 'standard' }, { holdRemove: held }),
)
await controller.load()
controller.confirmDelete('mine')
const pending = controller.remove()
// Dismissing mid-flight cannot un-delete the file, so the confirmation
// stays put rather than the page claiming nothing is happening.
controller.confirmDelete(null)
expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
release()
await pending
expect(presets.has('mine')).toBe(false)
})
})
describe('the default preset', () => {
it('writes the settings field and re-reads the roster', async () => {
const { controller, calls, defaultId } = harness()
await controller.load()
await controller.makeDefault('mine')
expect(calls.find(call => call.method === 'settings.update')?.payload)
.toEqual({ ns: 'agent-presets', patch: { default: 'mine' } })
expect(defaultId.id).toBe('mine')
expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
})
it('reports a refused write and leaves the roster alone', async () => {
const { controller, defaultId } = harness({ failSettings: 'settings are read-only' })
await controller.load()
await controller.makeDefault('mine')
expect(controller.store.getSnapshot().error).toBe('settings are read-only')
expect(defaultId.id).toBe('standard')
})
})