refactor(agent-presets,web): copy-only preset authoring with a path to the files

The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.

The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.

With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.

New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
This commit is contained in:
Yichen Jiang
2026-08-08 22:35:26 +08:00
parent 2cea99409f
commit b77fb9036c
60 changed files with 2253 additions and 1336 deletions

View File

@@ -1,16 +1,17 @@
/**
* 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.
* The agent-preset management controller: a copy dialog is the only way a
* preset is created, the shipped compositions open in a read-only viewer, and
* the way into a custom preset's files is the location action — opened on a
* desktop, revealed as a path where the host has none. Every mutation
* re-reads the roster because a copy changes 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'
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
interface FakePreset { trust: 'system' | 'user'; content: string }
interface FakePreset { trust: 'system' | 'user'; content: string; name?: string }
interface Recorded { method: string; payload: unknown }
interface FakeOptions {
@@ -20,16 +21,26 @@ interface FakeOptions {
failList?: string
/** Reject `read` with this message. */
failRead?: string
/** Reject `write` with this message. */
failWrite?: string
/** Reject `copy` with this message. */
failCopy?: string
/** Reject `openDocument` with this message. */
failOpen?: 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
/** Throw from `read`, as a dead transport does. */
throwRead?: boolean
/** Throw from `copy`, as a dead transport does. */
throwCopy?: boolean
/** Throw from `openDocument`, as a dead transport does. */
throwOpen?: boolean
/** Whether the deployment configures a writable root. */
authorable?: boolean
/** Whether the host can open a preset directory on a desktop. */
hasDocument?: boolean
/** Hold `remove` until this resolves, to observe the in-flight state. */
holdRemove?: Promise<void>
}
@@ -39,8 +50,8 @@ 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.
* A wire face over an in-memory preset store: copies land, so the roster the
* controller re-reads after a copy is the one the copy 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.
@@ -61,12 +72,15 @@ function fakeApi(
return ok({
presets: [...presets].map(([id, preset]) => ({
id, trust: preset.trust, isDefault: id === defaultId.id,
...preset.name === undefined ? {} : { name: preset.name },
})),
authorable: options.authorable ?? true,
hasDocument: options.hasDocument ?? true,
})
},
read: (payload: { agentPreset: string }) => {
record('read', payload)
if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
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 */
@@ -75,15 +89,31 @@ function fakeApi(
agentPreset: payload.agentPreset,
trust: preset.trust,
content: preset.content,
writable: preset.trust === 'user',
...preset.name === undefined ? {} : { name: preset.name },
})
},
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 })
copy: (payload: { from: string; agentPreset: string; name?: string }) => {
record('copy', payload)
if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
if (options.failCopy !== undefined) return fail(options.failCopy)
const source = presets.get(payload.from)
/* v8 ignore next -- every test copies a source the fake store holds */
if (source === undefined) return fail(`unknown preset ${payload.from}`)
presets.set(payload.agentPreset, {
trust: 'user',
content: source.content,
...payload.name === undefined ? {} : { name: payload.name },
})
return ok({ agentPreset: payload.agentPreset })
},
openDocument: (payload: { agentPreset: string }) => {
record('openDocument', payload)
if (options.throwOpen === true) return Promise.reject(new Error('socket closed'))
if (options.failOpen !== undefined) return fail(options.failOpen)
return (options.hasDocument ?? true)
? ok({ opened: true })
: ok({ opened: false, path: `/presets/${payload.agentPreset}` })
},
remove: async (payload: { agentPreset: string }) => {
record('remove', payload)
await options.holdRemove
@@ -106,7 +136,7 @@ function fakeApi(
function seed(): Map<string, FakePreset> {
return new Map<string, FakePreset>([
['standard', { trust: 'system', content: '- id: tool-bash\n' }],
['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }],
['mine', { trust: 'user', content: '- id: tool-read\n' }],
])
}
@@ -115,466 +145,436 @@ function harness(options: FakeOptions = {}) {
const presets = seed()
const defaultId = { id: 'standard' }
const calls: Recorded[] = []
let rosterChanges = 0
const controller = new AgentPresetSectionController(
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
() => { rosterChanges += 1 },
)
return { controller, presets, defaultId, calls }
return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
}
function draftOf(controller: AgentPresetSectionController): PresetDraft {
const { draft } = controller.store.getSnapshot()
if (draft === null) throw new Error('expected an open draft')
return draft
function copyOf(controller: AgentPresetSectionController): CopyDraft {
const { copy } = controller.store.getSnapshot()
if (copy === null) throw new Error('expected an open copy dialog')
return copy
}
describe('loading the roster', () => {
it('reports the presets, their trust, the default, and whether authoring is possible', async () => {
const { controller } = harness()
it('maps the roster onto rows with the capability flags', async () => {
const { controller } = harness({ authorable: true, hasDocument: false })
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 },
])
expect(state.hasDocument).toBe(false)
expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' })
})
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 }))
it('reports an empty roster as unavailable, not as an error', async () => {
const { controller, presets } = harness()
presets.clear()
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 () => {
it('keeps one load in flight rather than stacking reads', async () => {
const { controller, calls } = harness()
await Promise.all([controller.load(), controller.load()])
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
})
it('surfaces a refusal as the page error', async () => {
const { controller } = harness({ failList: 'not for you' })
await controller.load()
const state = controller.store.getSnapshot()
expect(state.status).toBe('error')
expect(state.error).toBe('not for you')
})
it('folds a dead transport into the same error surface', async () => {
const { controller } = harness({ throwList: true })
await controller.load()
expect(controller.store.getSnapshot().status).toBe('error')
expect(controller.store.getSnapshot().error).toContain('socket closed')
})
})
describe('opening a composition', () => {
it('opens a locally authored preset for editing', async () => {
describe('the read-only viewer', () => {
it('opens a shipped composition under its display name', async () => {
const { controller } = harness()
await controller.load()
await controller.open('mine')
await controller.view('standard')
expect(draftOf(controller)).toMatchObject({
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n', writable: true,
expect(controller.store.getSnapshot().view).toEqual({
id: 'standard', title: '标准模式', content: '- id: tool-bash\n',
})
})
it('opens a shipped preset read-only', async () => {
it('falls back to the id when the preset published no name', async () => {
const { controller } = harness()
await controller.load()
await controller.open('standard')
await controller.view('mine')
// 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' })
expect(controller.store.getSnapshot().view?.title).toBe('mine')
})
it('surfaces a rejected read on the page rather than opening an empty editor', async () => {
const { controller } = harness({ failRead: 'permission denied' })
it('closes without touching the list', async () => {
const { controller } = harness()
await controller.load()
await controller.view('standard')
controller.closeView()
expect(controller.store.getSnapshot().view).toBeNull()
expect(controller.store.getSnapshot().rows).toHaveLength(2)
})
it('puts a read refusal on the page rather than opening empty', async () => {
const { controller } = harness({ failRead: 'no peeking' })
await controller.load()
await controller.open('mine')
await controller.view('standard')
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'permission denied' })
expect(controller.store.getSnapshot().view).toBeNull()
expect(controller.store.getSnapshot().error).toBe('no peeking')
})
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')) },
it('folds a dead transport into the same error surface', async () => {
const { controller } = harness({ throwRead: true })
await controller.load()
await controller.view('standard')
expect(controller.store.getSnapshot().error).toContain('socket closed')
})
})
describe('the copy dialog', () => {
it('opens over the source with its display name in the title', async () => {
const { controller } = harness()
await controller.load()
controller.beginCopy('standard')
expect(copyOf(controller)).toMatchObject({
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false,
})
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('starts blank when no source is named', async () => {
const { controller, calls } = harness()
await controller.load()
const before = calls.length
await controller.createFrom()
// Copying is its own action on the row being copied, so this one is a copy
// of nothing: no source to name, and no read to make.
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, writable: true, content: '' })
expect(draftOf(controller).source).toBeUndefined()
expect(calls).toHaveLength(before)
})
it('copies a named preset', async () => {
it('falls back to the source id when it published no name', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom('mine')
controller.beginCopy('mine')
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
expect(copyOf(controller).fromTitle).toBe('mine')
})
it('opens the blank editor without the roster, which it no longer reads', async () => {
it('cancel discards whatever was typed', async () => {
const { controller } = harness()
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('half-typed')
controller.cancelCopy()
expect(controller.store.getSnapshot().copy).toBeNull()
})
it('ignores field edits and submits with no dialog open', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.createFrom()
controller.setCopyId('typed-into-nothing')
controller.setCopyName('nameless')
await controller.confirmCopy()
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, content: '' })
expect(calls).toHaveLength(0)
expect(controller.store.getSnapshot().copy).toBeNull()
expect(calls.some(call => call.method === 'copy')).toBe(false)
})
it('typing clears the previous failure', async () => {
const { controller } = harness({ failCopy: 'disk full' })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(copyOf(controller).error).toBe('disk full')
controller.setCopyName('renamed')
expect(copyOf(controller).error).toBeNull()
})
})
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 }]
describe('the copy blocker', () => {
const rows: PresetRow[] = [
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'mine', trust: 'user', isDefault: false },
]
const draft = (id: string): CopyDraft =>
({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null })
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()
it('requires an id, a containable shape, and a free name', () => {
expect(draftBlocker(draft(''), rows)).toBe('idRequired')
expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid')
expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid')
expect(draftBlocker(draft('mine'), rows)).toBe('idTaken')
expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined()
})
})
describe('saving', () => {
it('creates the preset and re-reads the roster', async () => {
const { controller, presets } = harness()
describe('submitting a copy', () => {
it('copies, re-reads the roster, announces the change, and opens the files', async () => {
const { controller, calls, rosterChanges } = harness()
await controller.load()
await controller.createFrom()
controller.setId('my-agent')
controller.setContent('- id: tool-web-search\n')
controller.beginCopy('standard')
controller.setCopyId('my-copy')
controller.setCopyName('我的模式')
await controller.save()
await controller.confirmCopy()
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')
const state = controller.store.getSnapshot()
expect(state.copy).toBeNull()
expect(state.rows.map(row => row.id)).toContain('my-copy')
expect(rosterChanges()).toBe(1)
expect(calls.find(call => call.method === 'copy')?.payload)
.toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' })
// A preset is its files from here on, so landing in them completes the
// copy rather than following it.
expect(calls.find(call => call.method === 'openDocument')?.payload)
.toEqual({ agentPreset: 'my-copy' })
})
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 () => {
it('omits an empty name so the copy falls back to its id', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.createFrom()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
controller.setCopyName(' ')
await controller.save()
await controller.confirmCopy()
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(controller.store.getSnapshot().draft).not.toBeNull()
expect(calls.find(call => call.method === 'copy')?.payload)
.toEqual({ from: 'standard', agentPreset: 'my-copy' })
})
it('refuses to write a read-only draft', async () => {
it('reveals the new directory as text where the host has no desktop', async () => {
const { controller } = harness({ hasDocument: false })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy')
})
it('keeps the dialog open with the refusal on it', async () => {
const { controller, rosterChanges } = harness({ failCopy: 'id already exists' })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' })
expect(rosterChanges()).toBe(0)
})
it('folds a dead transport into the dialog error', async () => {
const { controller } = harness({ throwCopy: true })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(copyOf(controller).error).toContain('socket closed')
})
it('refuses to submit while blocked or already saving', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('standard')
controller.beginCopy('standard')
controller.setCopyId('mine')
await controller.save()
await controller.confirmCopy()
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(calls.some(call => call.method === 'copy')).toBe(false)
})
})
it('does nothing without an open draft', async () => {
describe('the location action', () => {
it('opens the directory and leaves the page alone on a desktop host', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.save()
await controller.openLocation('mine')
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' })
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
})
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()
it('reveals the path on the row where the host has none', async () => {
const { controller } = harness({ hasDocument: false })
await controller.load()
await controller.createFrom('mine')
await controller.openLocation('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')
expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' })
})
it('ignores an edit with no draft open', () => {
const { controller } = harness()
it('drops a revealed path once its preset leaves the roster', async () => {
const { controller, presets } = harness({ hasDocument: false })
await controller.load()
await controller.openLocation('mine')
presets.delete('mine')
controller.setId('x')
controller.setContent('y')
controller.setName('n')
controller.setDescription('d')
await controller.load()
expect(controller.store.getSnapshot().draft).toBeNull()
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
})
it('surfaces a refusal as the page error', async () => {
const { controller } = harness({ failOpen: 'not yours' })
await controller.load()
await controller.openLocation('mine')
expect(controller.store.getSnapshot().error).toBe('not yours')
})
it('folds a dead transport into the same error surface', async () => {
const { controller } = harness({ throwOpen: true })
await controller.load()
await controller.openLocation('mine')
expect(controller.store.getSnapshot().error).toContain('socket closed')
})
})
describe('deleting', () => {
it('deletes the confirmed preset and re-reads the roster', async () => {
const { controller, presets } = harness()
it('asks first, then deletes, re-reads, and announces the change', async () => {
const { controller, rosterChanges } = harness()
await controller.load()
controller.confirmDelete('mine')
expect(controller.store.getSnapshot().pendingDelete).toBe('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 })
const state = controller.store.getSnapshot()
expect(state.pendingDelete).toBeNull()
expect(state.rows.map(row => row.id)).not.toContain('mine')
expect(rosterChanges()).toBe(1)
})
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()])
controller.confirmDelete(null)
await controller.remove()
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine')
expect(calls.some(call => call.method === 'remove')).toBe(false)
})
it('ignores a second confirmation while one delete is in flight', async () => {
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const { controller, calls } = harness({ holdRemove: gate })
await controller.load()
controller.confirmDelete('mine')
const removal = controller.remove()
controller.confirmDelete('standard')
await controller.remove()
release()
await removal
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 }),
)
it('surfaces a refusal and clears the confirmation', async () => {
const { controller } = harness({ failRemove: 'shipped preset' })
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
await controller.remove()
expect(presets.has('mine')).toBe(false)
const state = controller.store.getSnapshot()
expect(state.error).toBe('shipped preset')
expect(state.pendingDelete).toBeNull()
expect(state.deleting).toBe(false)
})
it('folds a dead transport into the same error surface', async () => {
const { controller, presets } = harness()
await controller.load()
presets.clear()
const broken = new AgentPresetSectionController({
agentPresets: {
list: () => Promise.reject(new Error('gone')),
remove: () => Promise.reject(new Error('socket closed')),
},
settings: {},
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>)
broken.confirmDelete('mine')
await broken.remove()
expect(broken.store.getSnapshot().error).toContain('socket closed')
})
})
describe('a controller with no roster listener', () => {
it('completes a delete without anyone to notify', async () => {
// The rosterChanged callback is optional wiring, not a requirement: a
// page composed without sibling surfaces still deletes cleanly.
const presets = seed()
const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' }))
await alone.load()
alone.confirmDelete('mine')
await alone.remove()
expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine')
})
})
describe('the default preset', () => {
it('writes the settings field and re-reads the roster', async () => {
const { controller, calls, defaultId } = harness()
it('writes the setting and re-reads the roster', async () => {
const { controller, 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')
expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true)
})
it('reports a refused write and leaves the roster alone', async () => {
const { controller, defaultId } = harness({ failSettings: 'settings are read-only' })
it('surfaces a settings refusal as the page error', async () => {
const { controller } = harness({ failSettings: 'read-only settings' })
await controller.load()
await controller.makeDefault('mine')
expect(controller.store.getSnapshot().error).toBe('settings are read-only')
expect(defaultId.id).toBe('standard')
expect(controller.store.getSnapshot().error).toContain('read-only settings')
})
})