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

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

View File

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

View File

@@ -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、信任级别与两个不含路径的能力标志而局域网客户端的选择器需要它。

View File

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

View File

@@ -177,49 +177,67 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
<h3 className={css.groupHead}>{heading}</h3>
<ul className={css.cards}>
{group.map(row => (
<li key={row.id} className={row.isDefault ? `${css.card} ${css.cardActive}` : css.card}>
<li
key={row.id}
className={row.broken !== undefined
? `${css.card} ${css.cardBroken}`
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
>
{/* 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. */}
<button
type="button"
className={css.cardMain}
aria-pressed={row.isDefault}
disabled={row.isDefault}
disabled={row.isDefault || row.broken !== undefined}
// Without this the name is the whole card read aloud —
// title, badge, description, id.
aria-label={`${row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`}
title={row.isDefault ? t('inUse') : t('setDefault')}
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${row.name ?? row.id}`}
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
onClick={() => { void props.makeDefault(row.id) }}
>
<span className={css.cardHead}>
<span className={css.cardName}>{row.name ?? row.id}</span>
{row.broken !== undefined
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
: null}
<span className={css.badge}>
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
</span>
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
</span>
<span className={css.cardDesc}>{row.description ?? t('noDescription')}</span>
{row.broken === undefined
? null
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
<code className={css.cardId}>{row.id}</code>
</button>
<div className={css.cardFoot}>
{/* 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'
? (
<button
type="button"
className={css.iconButton}
data-tip={t('view')}
aria-label={`${t('view')}: ${row.name ?? row.id}`}
onClick={() => { void props.view(row.id) }}
>
<IconBrowseOutline16 />
</button>
)
? row.broken === undefined
? (
<button
type="button"
className={css.iconButton}
data-tip={t('view')}
aria-label={`${t('view')}: ${row.name ?? row.id}`}
onClick={() => { void props.view(row.id) }}
>
<IconBrowseOutline16 />
</button>
)
: null
: (
<button
type="button"
@@ -234,8 +252,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
<button
type="button"
className={css.iconButton}
disabled={!state.authorable}
data-tip={state.authorable ? t('duplicate') : t('duplicateUnavailable')}
disabled={!state.authorable || row.broken !== undefined}
data-tip={row.broken !== undefined
? t('brokenNoCopy')
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
aria-label={`${t('duplicate')}: ${row.name ?? row.id}`}
onClick={() => { props.beginCopy(row.id) }}
>

View File

@@ -7,6 +7,7 @@ export type AgentPresetSettingsKey =
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
| 'displayName' | 'displayNamePlaceholder'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
| 'brokenBadge' | 'brokenNoCopy'
| 'composition' | 'cancel' | 'close' | 'retry'
| 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft'
| 'openLocation' | 'showLocation' | 'revealedPathLabel'
@@ -40,6 +41,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
builtInGroup: 'Built-in',
customGroup: 'Custom',
noDescription: 'No description.',
brokenBadge: 'Broken',
brokenNoCopy: 'Broken presets cannot be duplicated',
copyOf: 'Copied from',
composition: 'Composition (agent.cordis.yml)',
cancel: 'Cancel',
@@ -90,6 +93,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
builtInGroup: '内置',
customGroup: '自定义',
noDescription: '暂无描述。',
brokenBadge: '已损坏',
brokenNoCopy: '预设已损坏,无法复制',
copyOf: '复制自',
composition: '组装agent.cordis.yml',
cancel: '取消',

View File

@@ -33,6 +33,13 @@ export interface PresetRow {
trust: 'system' | 'user'
/** Whether a session that names no preset gets this one. */
isDefault: boolean
/**
* Why the preset cannot compose a session, absent when it can. A broken
* row renders marked and unselectable — its directory still occupies the
* id, so deleting it (or fixing the files) is the way out, and this page
* is where both of those live.
*/
broken?: string
}
/** The copy dialog: a new id and optional display name over a fixed source. */

View File

@@ -73,6 +73,8 @@ export interface RosterPreset {
name?: string
/** One sentence on what the preset is for. */
description?: string
/** Why the preset cannot compose a session, absent when it can. */
broken?: string
}
/** The roster the host answered with. */
@@ -134,19 +136,24 @@ export async function beginRosterRead<S extends { status: string; error: string
}
/**
* The roster entries as every surface renders them.
* The roster entries as the pickers render them: healthy presets only.
*
* The chip, the row, and the management section all show the same three
* facts, and `exactOptionalPropertyTypes` makes "absent" and "present as
* undefined" different shapes — so the spread dance belongs in one place
* rather than once per store.
* The chip and the row exist to choose the NEXT session's composition, and a
* broken preset cannot compose one — offering it would defer the discovery
* of that fact to a failed session start. The management section renders the
* full roster (broken rows included) from its own store instead.
*
* The chip, the row, and the management section all show the same facts, and
* `exactOptionalPropertyTypes` makes "absent" and "present as undefined"
* different shapes — so the spread dance belongs in one place rather than
* once per store.
* @param presets - the roster the host answered with.
* @returns one option per preset, in roster order.
* @returns one option per selectable preset, in roster order.
*/
export function presetOptions(
presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string }[],
presets: readonly { id: string; trust: 'system' | 'user'; name?: string; description?: string; broken?: string }[],
): AgentPresetOption[] {
return presets.map(preset => ({
return presets.filter(preset => preset.broken === undefined).map(preset => ({
id: preset.id,
trust: preset.trust,
...preset.name === undefined ? {} : { name: preset.name },

View File

@@ -172,6 +172,43 @@ describe('the preset list', () => {
expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable)
})
it('marks a broken custom preset: unselectable, uncopyable, still deletable', () => {
const actions = renderSection({
rows: [
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'ghost', trust: 'user', isDefault: false, name: '幽灵预设', broken: 'the composition file agent.cordis.yml is missing' },
],
})
const ghost = rowFor('ghost')
// The reason is on the card, and the body cannot pick what cannot mount.
expect(within(ghost).getByText(en.brokenBadge)).toBeTruthy()
expect(within(ghost).getByRole('alert').textContent).toContain('is missing')
const body = within(ghost).getByRole('button', { name: `${en.brokenBadge}: 幽灵预设` })
expect(body).toHaveProperty('disabled', true)
fireEvent.click(body)
expect(actions.makeDefault).not.toHaveBeenCalled()
// Copying a broken preset would only mint another broken one; deleting
// and the location remain — the files are where it gets fixed.
const duplicate = within(ghost).getByRole('button', { name: `${en.duplicate}: 幽灵预设` })
expect(duplicate).toHaveProperty('disabled', true)
expect(duplicate.getAttribute('data-tip')).toBe(en.brokenNoCopy)
expect(within(ghost).getByRole('button', { name: `${en.delete}: 幽灵预设` })).toBeTruthy()
expect(within(ghost).getByRole('button', { name: `${en.openLocation}: 幽灵预设` })).toBeTruthy()
})
it('withholds the viewer on a broken shipped preset', () => {
renderSection({
rows: [{ id: 'standard', trust: 'system', isDefault: false, name: '标准模式', broken: 'the composition is not valid YAML' }],
})
// There is no readable composition to offer; the reason on the card is
// the whole story a shipped row can tell.
const standard = rowFor('standard')
expect(within(standard).queryByRole('button', { name: `${en.view}: 标准模式` })).toBeNull()
expect(within(standard).getByRole('alert').textContent).toContain('not valid YAML')
})
it('labels the location by what it will do without a desktop', () => {
renderSection({ hasDocument: false })

View File

@@ -90,6 +90,20 @@ describe('the agent-preset settings controller', () => {
])
})
it('offers no broken preset: the pickers choose the NEXT session\'s composition', async () => {
const controller = new AgentPresetSettingsController(fakeApi([
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition is not valid YAML' },
] as never))
await controller.load()
// A broken preset cannot compose a session; listing it here would defer
// that discovery to a failed session start. The management section shows
// (and deletes) it from its own store instead.
expect(controller.store.getSnapshot().options.map(option => option.id)).toEqual(['standard'])
})
it('carries the display metadata a preset published', async () => {
const controller = new AgentPresetSettingsController(fakeApi([
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },