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:
Yichen Jiang
2026-08-09 02:17:56 +08:00
parent bf31797e95
commit c56f23ae1f
36 changed files with 720 additions and 87 deletions

View File

@@ -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

View File

@@ -0,0 +1,93 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
- heading "内置" [level=3]
- list:
- listitem:
- 'button "当前使用: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 当前使用 完整的编码 agent文件读写、shell、检索、计划、委派与工作流。
- code: standard
- 'button "查看: 标准模式"':
- img
- text: 查看
- 'button "复制: 标准模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 代码模式"':
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK一次执行代替多轮工具调用。
- code: code
- 'button "查看: 代码模式"':
- img
- text: 查看
- 'button "复制: 代码模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。
- code: minimal
- 'button "查看: 极简模式"':
- img
- text: 查看
- 'button "复制: 极简模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
- code: cordis
- 'button "查看: 创造模式"':
- img
- text: 查看
- 'button "复制: 创造模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- list:
- listitem:
- 'button "已损坏: broken-yaml" [disabled]':
- text: broken-yaml 已损坏 自定义 暂无描述。
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
- code: broken-yaml
- 'button "查看路径: broken-yaml"':
- img
- text: 查看路径
- 'button "复制: broken-yaml" [disabled]':
- img
- text: 预设已损坏,无法复制
- 'button "删除: broken-yaml"':
- img
- text: 删除
- listitem:
- 'button "已损坏: 幽灵预设" [disabled]':
- text: 幽灵预设 已损坏 自定义 composition 已被手动删除。
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
- code: ghost
- 'button "查看路径: 幽灵预设"':
- img
- text: 查看路径
- 'button "复制: 幽灵预设" [disabled]':
- img
- text: 预设已损坏,无法复制
- 'button "删除: 幽灵预设"':
- img
- text: 删除
- button "用「创造模式」创作自定义预设":
- img
- text: 用「创造模式」创作自定义预设