/** * 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 } 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, defaultId: { id: string }, options: FakeOptions = {}, ): Pick { 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 } function seed(): Map { return new Map([ ['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() 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((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') }) })