From c56f23ae1fc1dd27ba7414c3dd6d33ff4cbabaa7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 9 Aug 2026 02:17:56 +0800 Subject: [PATCH] fix(agent-presets,web): broken presets are roster rows, not gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...-08-09-broken-preset-roster-rows.i18n.yaml | 6 ++ .../2026-08-09-broken-preset-roster-rows.md | 33 +++++++ ...2026-08-09-broken-preset-roster-rows.zh.md | 33 +++++++ .../agent-presets/cordis/agent.cordis.yml | 2 + .../editing-cordis-compositions/SKILL.md | 15 ++- apps/web/tests/agent-preset-authoring.e2e.ts | 60 +++++++++++- .../damaged.expected.md | 93 ++++++++++++++++++ docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 4 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 + packages/client/ui-agent-preset/README.zh.md | 2 + .../src/client/AgentPresetSection.module.css | 29 ++++++ .../src/client/AgentPresetSection.tsx | 58 +++++++---- .../ui-agent-preset/src/client/locales.ts | 5 + .../src/client/section-store.ts | 7 ++ .../src/client/settings-store.ts | 23 +++-- .../ui-agent-preset/tests/section.spec.tsx | 37 ++++++++ .../tests/settings-store.spec.ts | 14 +++ .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 1 + .../apiproxy/src/api/agent-presets.schema.ts | 1 + .../host/apiproxy/src/api/agent-presets.ts | 7 ++ .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 19 ++-- packages/preset/agent-presets/README.zh.md | 19 ++-- .../preset/agent-presets/src/authoring.ts | 11 +-- .../preset/agent-presets/src/discovery.ts | 95 ++++++++++++++++++- packages/preset/agent-presets/src/index.ts | 28 +++++- packages/preset/agent-presets/src/types.ts | 17 ++++ .../agent-presets/tests/authoring.spec.ts | 35 ++++++- .../agent-presets/tests/discovery.spec.ts | 90 +++++++++++++++++- .../preset/agent-presets/tests/mount.spec.ts | 39 +++++++- 36 files changed, 720 insertions(+), 87 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md create mode 100644 apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml new file mode 100644 index 0000000000..9ad1682b62 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md +2026-08-09-broken-preset-roster-rows.md: fef6a183b10f98b8ae9d2b42701380c69bc83462 +2026-08-09-broken-preset-roster-rows.zh.md: 196bcf4ef16325a1d7692d2ea13d9fa683d500f4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md new file mode 100644 index 0000000000..fef6a183b1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md @@ -0,0 +1,33 @@ +# Agent Note: Broken presets are roster rows, not gaps + +Status: implemented + +English | [中文](2026-08-09-broken-preset-roster-rows.zh.md) + +## Problem + +With files as the only composition editor, hand-edit damage had two failure shapes and both were silent until the worst moment. A preset whose `agent.cordis.yml` no longer parsed listed as a perfectly ordinary row — selectable, copyable, settable as the default — and failed only when the next session tried to mount it; set as default, every new session failed to start. A directory whose composition file was deleted outright vanished from the roster while still occupying its id on disk: `copy` refused the name with "delete the existing preset first" and `remove` answered "not found" — two contradictory errors with no way out short of hand-deleting the directory. + +## Decision + +Discovery owns health, and a damaged directory is a **roster row carrying a `broken` reason**, never a gap. `scanRoot` treats every directory whose name is a usable preset id as a preset slot: composition missing → broken ("still occupies the id; delete it or restore the file"), composition unreadable/unparsable/not-a-list-of-named-rows → broken with the parser's first line. The shape check parses with the loader's own `entryListSchema` (the `!!js` dialect), so health can never call broken what the loader would accept; directories whose names fail `PRESET_ID` are skipped outright, because no copy could ever collide with them. `broken` rides `AgentPreset`, the `agentPreset.list` wire entry, and the UI row. Mounting paths (`mount`/`recompose`/`standingKeyFor`) refuse a broken preset up front via `resolveMountable` with the discovery-reported reason; `resolve` still answers (delete/read/report need the row), and `copy`'s roster check now sees ghosts, which turns the "already exists" refusal actionable — the broken card to delete is on the same page. + +Surfaces split by their job: the management section renders broken rows as marked cards (red border, Broken badge, verbatim reason, body and duplicate disabled, location/delete kept on custom rows — the files are the fix, delete is the ghost's way out; shipped broken rows lose the viewer too), while both pickers (General row, new-session chip) drop broken presets entirely via `presetOptions` — they choose the NEXT session's composition, and offering one that cannot compose only defers the failure. + +## Consequences + +- The ghost dead end is gone end to end: the directory lists broken, its delete clears it, and the freed id is immediately claimable (covered by unit, component, and e2e tests). +- A default that later breaks still fails the session start loudly — the pickers hide broken rows, but nothing rewrites a stored default; `resolveMountable`'s early refusal is the same message every unloadable shape gets, instead of loader-dependent errors. +- Health runs on every `list()`: one read+parse per preset per roster read, accepted for the same reason unmemoized discovery was — rosters are small and freshness is the contract. +- Copying broken is refused in the UI only (disabled with reason); the host keeps `copy` shape-agnostic. A broken source yields an equally broken, equally visible copy — no capability is gained, and the host-side refusal would have needed its own error vocabulary for no journey that survives the disabled button. + +## Load-bearing details + +- **`PRESET_ID` moved to `types.ts`** so discovery and authoring share one containment vocabulary; authoring re-exports it unchanged. +- **The reason is one line.** js-yaml appends a multi-line code-frame snippet; the roster card is not a terminal, so `compositionProblem` keeps the first line. +- **Two mount.spec races were left untouched deliberately**: `ensureStanding` is still reachable with a preset resolved just before deletion (the private-path tests), and its stamp/unstampable semantics are unchanged — the health check happens before, in the public route. +- **Creator-mode guidance rides the same PR**: the `cordis` preset's persona now forbids editing the shipped install (corrupting `cordis` would disable the mode itself) and points authoring at `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`; its skill teaches `preset.yml` metadata, the copy-first workflow, the one-escalation sandbox reality (the preset root lies outside the session workspace), and honest verification (the agent cannot start sessions; the settings page's red marking is the user's check). Verified live: asked to edit the shipped `cordis` composition directly, the composed agent refuses citing both rules and offers the copy path; asked for a real preset, it lands it under `$DSH_HOME`, batches writes into one escalation, self-checks with the loader dialect, and hands verification to the user. + +## Alternatives considered + +Hiding broken presets but refusing the id at copy time with a better message: still no way to clear the ghost from any surface. Validating deep (resolving every row's module at list time): the mount already owns that failure with rollback, and per-row imports on every roster read would be neither cheap nor more actionable. Blocking `settings` writes naming a broken default: the settings domain is generic and the roster is a live directory — a name absent or broken now may be valid by the next session, and the mount's loud failure is the enforcement that owns the moment. diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md new file mode 100644 index 0000000000..196bcf4ef1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md @@ -0,0 +1,33 @@ +# Agent Note:损坏的 preset 是名单行,不是空缺 + +Status: implemented + +[English](2026-08-09-broken-preset-roster-rows.md) | 中文 + +## 问题 + +文件成为唯一的组装编辑器之后,手动编辑造成的损坏有两种形态,且都要拖到最糟的时刻才暴露。`agent.cordis.yml` 解析不了的 preset 在名单上是一张完全正常的行——可选择、可复制、可设为默认——直到下一个会话尝试挂载才失败;一旦被设为默认,所有新会话都无法启动。组装文件被整个删掉的目录则从名单上消失,却仍在磁盘上占着它的 id:`copy` 以「先删除既有 preset」拒绝这个名字,`remove` 却回答「找不到」——两条互相矛盾的错误,除了手动删目录别无出路。 + +## 决定 + +发现过程负责健康,受损目录是**携带 `broken` 原因的名单行**,绝不是空缺。`scanRoot` 把名字是可用 preset id 的每个目录都当作一个 preset 槽位:组装缺失 → broken(「仍占着该 id;删除目录或恢复文件」),组装不可读/解析失败/不是具名行列表 → broken 并携带解析器的首行。形状检查用加载器自己的 `entryListSchema`(含 `!!js` 的方言)解析,因此健康检查绝不会把加载器接受的组装叫作损坏;名字不符合 `PRESET_ID` 的目录直接跳过,因为复制永远不可能与之相撞。`broken` 依次落在 `AgentPreset`、`agentPreset.list` 的线上条目和 UI 行上。挂载路径(`mount`/`recompose`/`standingKeyFor`)经 `resolveMountable` 用发现时记下的原因在前置拒绝;`resolve` 照样应答(删除/读取/上报都需要这一行),而 `copy` 的名单检查现在看得见幽灵,让「已存在」的拒绝变得可操作——要删的损坏卡片就在同一页上。 + +界面按职责分开:管理区把损坏行渲染为标记卡片(红边、「已损坏」徽记、原样展示原因、卡片主体与复制禁用,自定义行保留位置与删除——文件正是修复处,删除正是幽灵的出路;损坏的内置行连查看器也不给),而两个选择器(通用设置行、新会话 chip)经 `presetOptions` 完全不列损坏的 preset——它们选的是下一个会话的组装,端出无法组装的选项只会推迟失败。 + +## 后果 + +- 幽灵死路端到端消除:目录以损坏行列出,删除即清掉,释放的 id 立刻可用(单测、组件测试与 e2e 各自覆盖)。 +- 事后才损坏的默认值仍会在会话启动处大声失败——选择器隐藏损坏行,但没有任何东西改写已存的默认;`resolveMountable` 的前置拒绝让每种不可加载形态得到同一条消息,而不是依赖加载器内部的报错。 +- 健康检查随每次 `list()` 运行:每次读名单对每个 preset 一次读取加解析,接受的理由与不做缓存的发现相同——名单很小,新鲜是契约。 +- 复制损坏 preset 只在 UI 层拒绝(按钮禁用并给出原因);宿主的 `copy` 保持形状无关。损坏来源产出同样损坏、同样可见的副本——没有能力增益,而宿主侧拒绝需要为一条被禁用按钮挡住的路径专门发明错误词汇。 + +## 关键细节 + +- **`PRESET_ID` 移到 `types.ts`**,让发现与创作共享同一份包含边界词汇;authoring 原样转发导出。 +- **原因只留一行。** js-yaml 会附上多行代码框摘录;名单卡片不是终端,`compositionProblem` 只保留首行。 +- **mount.spec 的两个竞态用例特意不动**:`ensureStanding` 仍可能拿到删除前一刻解析出的 preset(私有路径测试),其 stamp/unstampable 语义不变——健康检查发生在此之前的公开路径上。 +- **创造模式的引导随同一 PR 落地**:`cordis` preset 的 persona 现在禁止编辑随附安装(损坏 `cordis` 会禁用这一模式本身),并把创作指向 `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`;其技能新教了 `preset.yml` 元信息、先复制再改的流程、一次升级的沙箱现实(preset 根目录在会话工作区之外)与诚实的验证方式(agent 无法自己启动会话;设置页的红色标记是用户的检查项)。已实测:被要求直接改随附 `cordis` 组装时,组装出的 agent 援引两条规则拒绝并给出复制路径;被要求真正创建 preset 时,它落在 `$DSH_HOME` 下、把写入合并为一次升级、用加载器方言自查、并把验证交还用户。 + +## 曾考虑的替代方案 + +隐藏损坏 preset 但在复制时用更好的报错拒绝该 id:幽灵仍然无法从任何界面清除。深度校验(读名单时解析每一行的模块):挂载已经拥有这一失败并带回滚,每次读名单逐行 import 既不便宜也不更可操作。阻止 `settings` 写入指向损坏默认值:settings 领域是通用的,而名单是活目录——此刻缺失或损坏的名字到下一个会话可能已经有效,挂载的响亮失败才是拥有那一刻的强制点。 diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index c1eecd1d27..be6ed33555 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -24,6 +24,8 @@ Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it. + Presets you author live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`, one directory per preset. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy. + Load the `editing-cordis-compositions` skill before writing or changing a composition. - id: workspace-context diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index c7cc2f5f28..f054e759ed 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -17,7 +17,16 @@ Two planes, and the choice is not about how "agent-related" something feels — **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. -A preset is a directory holding one `agent.cordis.yml`. The shipped ones live beside the deployment's composition; locally authored ones live under `$DSH_HOME/.agent-presets//`. +A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. The shipped presets live beside the deployment's composition; locally authored ones live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`. + +## Authoring a preset + +1. **Start from a copy.** Read a shipped composition close to what you want (the `standard` preset is the full coding agent) and copy its whole directory into `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` — the id must be lowercase letters, digits, and hyphens, because it becomes the directory name. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. +2. **Expect the file sandbox.** The preset root lies outside the session workspace, so under the default `workspace-write` policy the first write is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. +3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster. +4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above. + +The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete. ## The rule that catches people @@ -48,9 +57,9 @@ A consumer left outside the group resolves the host's registry, which the preset ## Verifying a change -Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. +Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it. -After editing a preset, start a new session on it and confirm the tool list is what you intended. A preset is read at session creation, so an edit never affects a session already running; the file is never written back either, so your composition is exactly what you wrote. +To check a preset you authored, re-read the files you wrote and walk the shape: a top-level YAML list, every row a map with a `name`, every group carrying its own list, service-publishing rows behind an `isolate` realm. The settings page's preset roster runs the same shape check and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 65057c5255..53b0a406ce 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -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 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md new file mode 100644 index 0000000000..8269dc2993 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -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: 用「创造模式」创作自定义预设 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6f6a9b804f..a2bd575bc1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -139,7 +139,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -Source: [`packages/preset/agent-presets/src/types.ts:35`](../packages/preset/agent-presets/src/types.ts) +Source: [`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/agent-presets/src/types.ts) ## `@deepseek-ai/dsh-agent-spine-demo` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 80024313fb..45428cc295 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -61,6 +61,10 @@ async list(): Promise /** * Resolve one preset by id. + * + * A broken preset resolves — deleting one, reading one, and reporting one + * all need the row — and the mounting paths refuse it AFTER resolution + * through {@link resolveMountable}. * @param id - the preset id, or `undefined` for {@link defaultId}. * @returns the resolved preset. * @throws when no configured root supplies that id. diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index ba2e79326c..6943e47673 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 35358b1cbecffa3ad8214375ca2f929fed2d4b60 -README.zh.md: c6cce1aaf19748e44f21fde7b3f3fb3c8319e5a7 +README.md: 32a4e7d9e25d3c70d2cc2e8a01c94d093d19659c +README.zh.md: b65a1bdf926f7a34bc3813833ca5ac2d3b6dfabd diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 35358b1cbe..32a4e7d9e2 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -42,6 +42,8 @@ The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and re Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file. +A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start. + Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget). `agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index c6cce1aaf1..b65a1bdf92 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -42,6 +42,8 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 +名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。 + 设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。 `agentPreset.read`、`copy`、`openDocument` 与 `remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index ed8a860078..f29bf7cdf5 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -67,6 +67,35 @@ border-color: var(--dsw-alias-label-primary); } +/* A broken preset reads as damaged before anything else: the card cannot be + picked, so its border carries the warning the disabled body cannot. */ +.cardBroken { + border-color: var(--dsw-alias-state-error-primary); +} + +.cardBroken:hover { + border-color: var(--dsw-alias-state-error-primary); +} + +.brokenBadge { + border-radius: 999px; + padding: 1px 8px; + font-size: 11px; + line-height: 17px; + white-space: nowrap; + font-weight: 500; + background: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-bg-layer-3); +} + +/* The discovery-reported reason, verbatim: it names the file and the fix. */ +.cardBrokenReason { + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} + /* The card body is the control that picks the preset. */ .cardMain { flex: 1; diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index d0ee3ac4c3..3a9d0b960a 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -177,49 +177,67 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {

{heading}

    {group.map(row => ( -
  • +
  • {/* The card body IS the control: picking a preset is the common act, so it should not hide behind a small button. The action row sits outside it — nesting buttons is - invalid, and these act on the card rather than select it. */} + invalid, and these act on the card rather than select it. + A broken preset cannot compose a session, so its body is + disabled and the card says why instead of offering it. */}
    {/* Shipped presets are the compositions a copy starts from, so READING one is the point; a custom preset is edited in its files instead, which the location action - leads to. */} + leads to. A broken shipped preset has no readable + composition to offer, so its viewer is withheld; a + broken custom one keeps the location action — the + files are where it gets fixed. */} {row.trust === 'system' - ? ( - - ) + ? row.broken === undefined + ? ( + + ) + : null : (