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:
@@ -8,7 +8,7 @@
|
||||
//
|
||||
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, realpath } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring',
|
||||
const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
|
||||
const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md')
|
||||
const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md')
|
||||
const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md')
|
||||
/** The shipped roster, beside the composition that names it. */
|
||||
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
|
||||
const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url))
|
||||
@@ -174,6 +175,63 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
|
||||
}, 60_000)
|
||||
|
||||
it('marks damaged presets broken and clears a ghost through delete', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged'))
|
||||
// The two hand-edit damage shapes: a composition that no longer parses,
|
||||
// and a directory whose composition file was deleted outright.
|
||||
await mkdir(join(userRoot, 'broken-yaml'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n')
|
||||
await mkdir(join(userRoot, 'ghost'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n')
|
||||
|
||||
// The section reads the roster when it mounts; hop away and back.
|
||||
const dialog = settingsDialog()
|
||||
await dialog.getByRole('button', { name: '通用设置' }).click()
|
||||
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 })
|
||||
|
||||
const snapshot = withPresetRoot(
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
|
||||
await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
|
||||
// Both damage shapes surface as marked, unselectable, uncopyable cards
|
||||
// that still carry their metadata and the discovery-reported reason.
|
||||
expect(snapshot).toContain('已损坏: broken-yaml')
|
||||
expect(snapshot).toContain('已损坏: 幽灵预设')
|
||||
expect(snapshot).toContain('not valid YAML')
|
||||
expect(snapshot).toContain('agent.cordis.yml is missing')
|
||||
expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
|
||||
// A broken card offers no "set default" affordance at all — the aria name
|
||||
// IS the broken marking, so the picking name must not exist.
|
||||
expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0)
|
||||
|
||||
// The ghost's way out is the card's own delete — and the id it blocked
|
||||
// is claimable again immediately afterwards.
|
||||
await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click()
|
||||
const confirm = page.getByRole('dialog', { name: '删除该预设?' })
|
||||
await confirm.waitFor({ timeout: 10_000 })
|
||||
await confirm.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await confirm.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
|
||||
|
||||
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
|
||||
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
|
||||
await copyDialog.waitFor({ timeout: 10_000 })
|
||||
await copyDialog.getByPlaceholder('my-agent').fill('ghost')
|
||||
await copyDialog.getByRole('button', { name: '创建' }).click()
|
||||
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
// Leave the roster as the earlier tests shaped it.
|
||||
await dialog.getByRole('button', { name: '删除: ghost' }).click()
|
||||
const cleanup = page.getByRole('dialog', { name: '删除该预设?' })
|
||||
await cleanup.waitFor({ timeout: 10_000 })
|
||||
await cleanup.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await cleanup.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true })
|
||||
}, 60_000)
|
||||
|
||||
it('starts a creator-mode session from the section', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator'))
|
||||
// Without a workspace the flow only stages (there is no session to land
|
||||
|
||||
Reference in New Issue
Block a user