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
|
||||
|
||||
@@ -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: 用「创造模式」创作自定义预设
|
||||
Reference in New Issue
Block a user