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

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