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

@@ -0,0 +1,181 @@
// Web e2e scenario: the agent-preset settings section as copy-only authoring.
// The browser never edits composition text — a shipped preset opens in a
// read-only viewer, the copy dialog collects an id and an optional display
// name, and the host copies the whole directory. The section's other job is
// getting the user TO the files: this lane pins `nativeOpen: false` (see the
// overlay), so the location affordance answers the preset directory as text —
// the deterministic branch a golden can hold on every platform.
//
// 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 { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { Locator } from 'playwright'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url))
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')
/** 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))
const MODE = webSnapshotMode()
describe('web e2e: agent-preset authoring is a host-side copy', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let userRoot: string
/** The settings dialog, opened on the Agent-presets section. */
function settingsDialog(): Locator {
return page.getByRole('dialog', { name: '设置' })
}
/** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */
function withPresetRoot(snapshot: string): string {
return snapshot.split(userRoot).join('{{presetRoot}}')
}
beforeAll(async () => {
userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
scaffold = await launchWebScaffold({
extraOverlayPath: OVERLAY,
agentPresets: {
roots: [
{ path: SHIPPED_PRESETS, trust: 'system' },
{ path: userRoot, trust: 'user' },
],
default: 'standard',
},
})
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('offers the roster with copy as the only way to create', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = settingsDialog()
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
// The intro carries the guidance a create button used to imply, and the
// shipped rows offer view/copy but never delete or a location — their
// install is overwritten by upgrades and is not the user's to manage.
expect(snapshot).toContain('复制「极简模式」')
expect(snapshot).not.toContain('新建预设')
expect(snapshot).toContain('查看: 标准模式')
expect(snapshot).not.toContain('删除: 标准模式')
expect(snapshot).not.toContain('打开目录')
}, 60_000)
it('views a shipped composition read-only instead of editing it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '查看: 标准模式' }).click()
const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' })
await viewer.waitFor({ timeout: 10_000 })
// The real shipped composition, not a golden: the viewer shows whatever
// the deployment ships, and this lane only asserts it is shown read-only.
const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8')
expect(await viewer.locator('pre').textContent()).toBe(shipped)
expect(await viewer.getByRole('textbox').count()).toBe(0)
// The header X and the footer button share the 关闭 name; the footer one
// is last in the dialog.
await viewer.getByRole('button', { name: '关闭' }).last().click()
await viewer.waitFor({ state: 'detached', timeout: 10_000 })
}, 60_000)
it('copies 极简模式 whole under a new id and lands in its files', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
await copyDialog.waitFor({ timeout: 10_000 })
const dialogSnapshot = await captureStableAria(
page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE)
// Two fields and nothing else: the id is the directory name the host
// needs up front; description and composition live in the files.
expect(dialogSnapshot).toContain('标识符')
expect(dialogSnapshot).not.toContain('描述')
await copyDialog.getByPlaceholder('my-agent').fill('my-agent')
await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式')
await copyDialog.getByRole('button', { name: '创建' }).click()
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
// The new row lands in the custom group, and — with no desktop opener —
// its directory is revealed as text right away: landing in the files is
// the completion of a copy, not a follow-up.
await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 })
await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 })
// The copy dialog is detached, so the settings dialog is the only one
// left (it names itself via aria-labelledby, which a CSS attribute
// selector cannot address).
const snapshot = withPresetRoot(
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('{{presetRoot}}/my-agent')
// The host copied the whole directory and rewrote only the display
// metadata: the composition is byte-identical to the shipped source, the
// description rides along for the user to edit in place, and neither the
// source's name nor its roster order survives into the copy.
const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8')
expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
expect(metadata).toContain('name: 我的模式')
expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。')
expect(metadata).not.toContain('order:')
}, 60_000)
it('deletes the copy after confirmation and reclaims the roster', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete'))
const dialog = settingsDialog()
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, 'my-agent'))).toBe(false)
// Custom group gone with its only member; the shipped set stands.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
}, 60_000)
it('drove every surface without a page error or a stream warning', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})

View File

@@ -0,0 +1,12 @@
# The authoring lane drives the location affordance. A real desktop open
# would pop a file manager on the machine running the tests and the
# capability itself is platform-detected (macOS yes, headless Linux CI no),
# so the gateway is pinned headless: `hasDocument` is false everywhere and
# `openDocument` answers the directory as text — the same branch on every
# host, and the one whose rendering a golden can hold. A patch replaces the
# row's complete config, so the shipped routing defaults ride along.
- id: api-gateway
config:
provider: deepseek-official
model: deepseek-v4-flash
nativeOpen: false

View File

@@ -0,0 +1,14 @@
- dialog "复制预设 · 复制自 极简模式":
- heading "复制预设 · 复制自 极简模式" [level=2]
- button "关闭":
- img
- paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。
- text: 标识符
- textbox "标识符":
- /placeholder: my-agent
- text: 名称
- textbox "名称":
- /placeholder: 选择器中显示的名字,缺省用标识符
- alert: 请填写标识符。
- button "取消"
- button "创建" [disabled]

View File

@@ -0,0 +1,78 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 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 "设为默认: 我的模式"':
- text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor适合 benchmark 与最小复现。
- code: my-agent
- 'button "查看路径: 我的模式"':
- img
- text: 查看路径
- 'button "复制: 我的模式"':
- img
- text: 复制
- 'button "删除: 我的模式"':
- img
- text: 删除
- paragraph:
- text: 预设文件:
- code: {{presetRoot}}/my-agent

View File

@@ -0,0 +1,60 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 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: 复制