fix(agent-presets,web): broken presets are roster rows, not gaps
A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
This commit is contained in:
@@ -258,11 +258,36 @@ describe('display metadata beside a composition', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a stray file beside the preset directories', () => {
|
||||
it('does not become a preset', async () => {
|
||||
await mkdir(join(userRoot, 'not-a-preset'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'not-a-preset', 'README.txt'), 'nope\n')
|
||||
describe('the on-disk occupancy backstop', () => {
|
||||
it('refuses a directory the roster cannot see', async () => {
|
||||
// The service's roster check sees every id-shaped directory now, so this
|
||||
// is the race backstop: a directory appearing between the roster read and
|
||||
// the copy still gets the readable refusal, not a filesystem error code.
|
||||
await mkdir(join(userRoot, 'raced'), { recursive: true })
|
||||
const source = await ctx.agentPresets.resolve('standard')
|
||||
|
||||
expect((await ctx.agentPresets.list()).some(preset => preset.id === 'not-a-preset')).toBe(false)
|
||||
await expect(copyComposition(
|
||||
[{ path: userRoot, trust: 'user' as const }], source, 'raced',
|
||||
)).rejects.toThrow(/already exists/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a ghost directory under the user root', () => {
|
||||
it('lists broken, blocks its id, and clears through remove', async () => {
|
||||
// The classic hand-edit: the composition file was deleted, the directory
|
||||
// stayed. It must not vanish from the roster — its id is still taken, so
|
||||
// there has to be something to see and delete.
|
||||
await mkdir(join(userRoot, 'ghost'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'ghost', 'README.txt'), 'composition deleted by hand\n')
|
||||
|
||||
const ghost = (await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')
|
||||
expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/)
|
||||
await expect(ctx.agentPresets.copy('standard', 'ghost')).rejects.toThrow(/already exists/)
|
||||
|
||||
// remove is the way out the roster row offers; the id is claimable again.
|
||||
await ctx.agentPresets.remove('ghost')
|
||||
expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
|
||||
await ctx.agentPresets.copy('standard', 'ghost')
|
||||
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'ghost')?.broken).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { chmod, mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -57,10 +57,28 @@ describe('preset discovery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a directory that holds no composition file', async () => {
|
||||
it('reports a directory with no composition as a broken preset slot', async () => {
|
||||
const found = await scanRoot(USER)
|
||||
|
||||
expect(found.map(preset => preset.id)).not.toContain('not-a-preset')
|
||||
// The directory still occupies its id — a copy to that name is refused —
|
||||
// so hiding it would leave nothing to see or delete. It surfaces broken.
|
||||
const ghost = found.find(preset => preset.id === 'not-a-preset')
|
||||
expect(ghost?.broken).toMatch(/agent\.cordis\.yml is missing/)
|
||||
})
|
||||
|
||||
it('skips a directory whose name no preset id could ever claim', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-'))
|
||||
await mkdir(join(root, '.hidden'))
|
||||
await mkdir(join(root, 'Has_Caps'))
|
||||
await mkdir(join(root, 'usable'))
|
||||
await writeFile(join(root, 'usable', COMPOSITION_FILE), '[]\n')
|
||||
|
||||
const found = await scanRoot({ path: root, trust: 'user' })
|
||||
|
||||
// `.hidden` and `Has_Caps` cannot collide with any copy target, so
|
||||
// reporting tool residue as broken presets would only train users to
|
||||
// ignore the marker.
|
||||
expect(found.map(preset => preset.id)).toEqual(['usable'])
|
||||
})
|
||||
|
||||
it('records the root trust on every preset it discovers', async () => {
|
||||
@@ -111,3 +129,69 @@ describe('preset discovery', () => {
|
||||
expect(found).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('composition health', () => {
|
||||
/** One directory under a fresh root holding `composition`, scanned. */
|
||||
async function scanned(composition: string): Promise<string | undefined> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-'))
|
||||
await mkdir(join(root, 'probe'))
|
||||
await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' })
|
||||
return preset?.broken
|
||||
}
|
||||
|
||||
it('reports unparsable YAML with the parser\'s reason', async () => {
|
||||
expect(await scanned('- id: x\n name: [unclosed\n')).toMatch(/not valid YAML/)
|
||||
})
|
||||
|
||||
it('reports a composition that is not a list of rows', async () => {
|
||||
expect(await scanned('name: not-a-list\n')).toMatch(/top-level list of plugin rows/)
|
||||
})
|
||||
|
||||
it('reports the first row that names no plugin, by position', async () => {
|
||||
expect(await scanned('- id: ok\n name: some-plugin\n- id: broken\n'))
|
||||
.toMatch(/row 2 names no plugin/)
|
||||
})
|
||||
|
||||
it('reports a row that is not a map at all', async () => {
|
||||
expect(await scanned('- just-a-string\n')).toMatch(/row 1 is not a plugin row/)
|
||||
})
|
||||
|
||||
it('descends into a group\'s own row list', async () => {
|
||||
const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n'
|
||||
expect(await scanned(composition)).toMatch(/row 1 row 1 names no plugin/)
|
||||
})
|
||||
|
||||
it('reports a group whose config is not a list', async () => {
|
||||
const composition = '- id: grp\n name: cordis:group\n group: true\n config: not-a-list\n'
|
||||
expect(await scanned(composition)).toMatch(/group row 1 must hold a list/)
|
||||
})
|
||||
|
||||
it('accepts a group whose own list is healthy', async () => {
|
||||
const composition = '- id: grp\n name: cordis:group\n group: true\n config:\n - id: inner\n name: some-plugin\n'
|
||||
expect(await scanned(composition)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a composition that stats but cannot be read', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-'))
|
||||
await mkdir(join(root, 'sealed'))
|
||||
const path = join(root, 'sealed', COMPOSITION_FILE)
|
||||
await writeFile(path, '[]\n')
|
||||
await chmod(path, 0o000)
|
||||
|
||||
const [preset] = await scanRoot({ path: root, trust: 'user' })
|
||||
|
||||
expect(preset?.broken).toMatch(/cannot be read/)
|
||||
})
|
||||
|
||||
it('accepts the loader dialect, !!js scalars included', async () => {
|
||||
// Health must never call a composition broken that the loader accepts:
|
||||
// `!!js` is the loader's own extension, so it parses here too.
|
||||
const composition = '- id: x\n name: some-plugin\n config:\n value: !!js "1 + 1"\n'
|
||||
expect(await scanned(composition)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts an empty list', async () => {
|
||||
expect(await scanned('[]\n')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -238,9 +238,11 @@ describe('the preset roster', () => {
|
||||
it('lists every root\'s presets with the earlier root winning', async () => {
|
||||
const listed = await ctx.agentPresets.list()
|
||||
|
||||
// `not-a-preset` is the fixture ghost: no composition file, listed broken.
|
||||
expect(listed.map(preset => preset.id).sort())
|
||||
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard', 'two-broken'])
|
||||
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'not-a-preset', 'pending', 'standard', 'two-broken'])
|
||||
expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system')
|
||||
expect(listed.find(preset => preset.id === 'not-a-preset')?.broken).toMatch(/is missing/)
|
||||
})
|
||||
|
||||
it('exposes the configured default id', () => {
|
||||
@@ -248,6 +250,41 @@ describe('the preset roster', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('composing from a broken preset', () => {
|
||||
/** A roster whose only user preset carries `composition`. */
|
||||
async function rosterWith(composition: string): Promise<Context> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
|
||||
await mkdir(join(root, 'damaged'))
|
||||
await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
|
||||
return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] })
|
||||
}
|
||||
|
||||
it('refuses the mount up front with the discovery-reported reason', async () => {
|
||||
const scoped = await rosterWith('- id: x\n name: [unclosed\n')
|
||||
|
||||
// The refusal happens before the loader ever sees the file, so every
|
||||
// unloadable shape gets the same early PresetMountError — and a rejected
|
||||
// setup rolls the whole agent creation back.
|
||||
await expect(agentOn(scoped, 'sess-broken', 'damaged')).rejects.toThrow(PresetMountError)
|
||||
await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/)
|
||||
expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses the standing key a cold reader would mount by', async () => {
|
||||
const scoped = await rosterWith('rows: not-a-list\n')
|
||||
|
||||
await expect(scoped.agentPresets.standingKeyFor('damaged'))
|
||||
.rejects.toThrow(/top-level list of plugin rows/)
|
||||
})
|
||||
|
||||
it('still resolves the broken row for the surfaces that manage it', async () => {
|
||||
const scoped = await rosterWith('- id: x\n name: [unclosed\n')
|
||||
|
||||
// Deleting and reporting need the row; only composing refuses it.
|
||||
expect((await scoped.agentPresets.resolve('damaged')).broken).toMatch(/not valid YAML/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a roster with nothing in it', () => {
|
||||
it('says so instead of naming an empty list of candidates', async () => {
|
||||
const bare = new Context()
|
||||
|
||||
Reference in New Issue
Block a user