feat(web): author agent presets from a settings page

A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.

All four authoring methods are loopback-pinned. A composition names the
plugins a session runs, so reading one is reconnaissance, writing one is
arbitrary capability, and selecting one can move a session onto a preset
that edits the live runtime. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.

Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.

Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.

Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
This commit is contained in:
Yichen Jiang
2026-08-04 12:23:40 +08:00
parent 52607cab69
commit 6dfc568ec2
56 changed files with 3478 additions and 110 deletions
@@ -1339,6 +1339,17 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
// DeepSeek route so unrelated GUI journeys do not enter first-run setup.
['DEEPSEEK_API_KEY', true],
])
/**
* Preset compositions the fixture serves. Held as state rather than
* constants so the settings editor's save and delete are exercisable: the
* roster a GUI journey sees after writing is the text it wrote.
*/
const fixturePresets = new Map<string, { trust: 'system' | 'user'; content: string }>([
['standard', { trust: 'system', content: "- id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n" }],
['core-web', { trust: 'system', content: "- id: tool-web-search\n name: '@deepseek-ai/dsh-tool-web-search'\n" }],
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
])
let fixtureDefaultPreset = 'standard'
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
let nextSession = 1
let nextRpc = 1
@@ -2313,15 +2324,63 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
},
agentPresets: {
// Two rows so a picker has something to choose between, and so the
// trust distinction a surface must present is visible in the fixture.
// Both trusts appear, because a surface must present a locally authored
// preset differently from one the deployment vetted.
list: request => ok(request, {
presets: [
{ id: 'standard', trust: 'system' as const, isDefault: true },
{ id: 'core-web', trust: 'system' as const, isDefault: false },
],
presets: [...fixturePresets].map(([id, preset]) => ({
id,
trust: preset.trust,
isDefault: id === fixtureDefaultPreset,
})),
authorable: true,
}),
select: request => ok(request, { agentPreset: request.payload.agentPreset }),
select: (request) => {
fixtureDefaultPreset = request.payload.agentPreset
return ok(request, { agentPreset: request.payload.agentPreset })
},
read: (request) => {
const { agentPreset } = request.payload
const preset = fixturePresets.get(agentPreset)
if (preset === undefined) {
return err(request, {
code: 'agent-preset-not-found',
message: `unknown agent preset "${agentPreset}"`,
details: { agentPreset, available: [...fixturePresets.keys()] },
})
}
return ok(request, {
agentPreset,
trust: preset.trust,
content: preset.content,
writable: preset.trust === 'user',
})
},
write: (request) => {
const { agentPreset, content } = request.payload
const existing = fixturePresets.get(agentPreset)
if (existing?.trust === 'system') {
return err(request, {
code: 'agent-preset-read-only',
message: `agent preset "${agentPreset}" ships with the deployment`,
details: { agentPreset, reason: 'it ships with the deployment' },
})
}
fixturePresets.set(agentPreset, { trust: 'user', content })
return ok(request, { agentPreset })
},
remove: (request) => {
const { agentPreset } = request.payload
const existing = fixturePresets.get(agentPreset)
if (existing?.trust === 'system') {
return err(request, {
code: 'agent-preset-read-only',
message: `agent preset "${agentPreset}" ships with the deployment`,
details: { agentPreset, reason: 'it ships with the deployment' },
})
}
fixturePresets.delete(agentPreset)
return ok(request, {})
},
},
skills: {
@@ -2623,6 +2682,9 @@ export class FixtureApiClient extends AbstractApiClient {
case 'skill.list': return this.api.skills.list(request)
case 'agentPreset.list': return this.api.agentPresets.list(request)
case 'agentPreset.select': return this.api.agentPresets.select(request)
case 'agentPreset.read': return this.api.agentPresets.read(request)
case 'agentPreset.write': return this.api.agentPresets.write(request)
case 'agentPreset.remove': return this.api.agentPresets.remove(request)
case 'goal.create': return this.api.goals.create(request)
case 'goal.edit': return this.api.goals.edit(request)
case 'goal.pause': return this.api.goals.pause(request)
+11
View File
@@ -55,6 +55,17 @@ export const Config: z<ConnectionConfig> = z.object({
* keys, or key state — and a LAN client's model picker legitimately needs it.
*/
const PRIVILEGED_METHODS = new Set([
// A preset composition names the plugins a session runs, so reading one is
// reconnaissance and writing one is arbitrary capability — strictly more than
// the settings document beside it. `agentPreset.select` joins them because
// it can move a session from a two-tool preset onto one that edits the live
// runtime, which is a real escalation even though every candidate is already
// installed. `agentPreset.list` deliberately stays out: it carries ids and
// trust only, like the model catalog, and a LAN client's picker needs it.
'agentPreset.select',
'agentPreset.read',
'agentPreset.write',
'agentPreset.remove',
'host.pickDirectory',
'host.openPath',
'settings.describe',