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

@@ -27,10 +27,17 @@ usePinnedBrowserLanguages('zh-CN')
const ROSTER_ONE = {
rpcId: 'r',
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
result: {
ok: true as const,
value: {
presets: [{ id: 'standard', trust: 'system', isDefault: true }],
authorable: true,
hasDocument: true,
},
},
}
/** The roster after this browser authored one preset of its own. */
/** The roster after this browser copied one preset of its own. */
const ROSTER_AUTHORED = {
rpcId: 'r',
result: {
@@ -41,6 +48,7 @@ const ROSTER_AUTHORED = {
{ id: 'mine', trust: 'user', isDefault: false },
],
authorable: true,
hasDocument: true,
},
},
}
@@ -56,6 +64,7 @@ const ROSTER_MOVED = {
{ id: 'minimal', trust: 'system', isDefault: true },
],
authorable: true,
hasDocument: true,
},
},
}
@@ -76,15 +85,19 @@ async function bench() {
list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
read: () => Promise.resolve({
rpcId: 'r',
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '', writable: false } },
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } },
}),
write: (payload: { agentPreset: string }) => {
calls.push(`write:${payload.agentPreset}`)
copy: (payload: { from: string; agentPreset: string }) => {
calls.push(`copy:${payload.agentPreset}`)
// The host's roster now contains it, which is the whole point of the
// write and what every surface must converge on.
// copy and what every surface must converge on.
ROSTER = ROSTER_AUTHORED
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
},
openDocument: (payload: { agentPreset: string }) => {
calls.push(`openDocument:${payload.agentPreset}`)
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } })
},
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
select: (payload: { agentPreset: string }) => {
calls.push(`select:${payload.agentPreset}`)
@@ -195,23 +208,29 @@ describe('ui-agent-preset apply', () => {
})
it('routes the section actions to one controller', async () => {
const { ctx, slots } = await bench()
const { ctx, slots, calls } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
section.setId('mine')
section.setContent('- id: x\n')
section.setName('我的模式')
section.setDescription('只做检索。')
section.beginCopy('standard')
section.cancelCopy()
section.beginCopy('standard')
section.setCopyId('mine')
section.setCopyName('我的模式')
await section.confirmCopy()
await section.view('standard')
section.closeView()
section.confirmDelete('mine')
section.close()
await Promise.all([section.open('standard'), section.createFrom(), section.save(), section.remove()])
await Promise.all([section.openLocation('mine'), section.remove()])
// One controller behind every action: the delete the section confirmed is
// the one its remove() sees.
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(1)
// One controller behind every action: the copy the dialog named is the
// one the roster re-read reflects, and the delete the section confirmed
// is the one its remove() sees.
expect(calls).toContain('copy:mine')
expect(calls.filter(call => call === 'openDocument:mine').length).toBeGreaterThan(0)
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2)
})
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
@@ -329,14 +348,14 @@ describe('ui-agent-preset apply', () => {
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
await section.createFrom()
section.setId('mine')
section.setName('我的模式')
await section.save()
section.beginCopy('standard')
section.setCopyId('mine')
section.setCopyName('我的模式')
await section.confirmCopy()
// Authoring writes a file rather than a setting, so nothing on the wire
// announces it: a preset authored to be used must appear on the one screen
// that starts sessions, without a reload.
// Authoring copies a directory rather than writing a setting, so nothing
// on the wire announces it: a preset created to be used must appear on
// the one screen that starts sessions, without a reload.
await vi.waitFor(() => {
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine'])
})