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:
@@ -14,7 +14,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
|
||||
import { UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
|
||||
import {
|
||||
InvalidCompositionError, InvalidPresetIdError, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { GoalId } from '@deepseek-ai/dsh-goal'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -52,6 +54,19 @@ function roster(ids: readonly string[]): unknown {
|
||||
const perAgent = services.get(String(agent.id))
|
||||
return perAgent?.[name]
|
||||
},
|
||||
authorable: true,
|
||||
read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`),
|
||||
write: (id: string, content: string) => {
|
||||
if (!ids.includes(id) && !/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
||||
return Promise.reject(new InvalidPresetIdError(id))
|
||||
}
|
||||
if (!content.trimStart().startsWith('-')) return Promise.reject(new InvalidCompositionError('not a list'))
|
||||
return Promise.resolve()
|
||||
},
|
||||
remove: (id: string) => {
|
||||
if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
|
||||
return Promise.resolve()
|
||||
},
|
||||
recompose: (_ctx: Context, id: string) => {
|
||||
if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
|
||||
return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
|
||||
@@ -252,6 +267,7 @@ describe('agentPreset.list', () => {
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'core-web', trust: 'system', isDefault: false },
|
||||
])
|
||||
expect(response.result.value.authorable).toBe(true)
|
||||
})
|
||||
|
||||
it('answers with an empty roster when the deployment composes no presets', async () => {
|
||||
@@ -264,6 +280,9 @@ describe('agentPreset.list', () => {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.value.presets).toEqual([])
|
||||
// Nothing to write to either, so a surface offering "new preset" knows to
|
||||
// stay hidden rather than offering a button whose save always fails.
|
||||
expect(response.result.value.authorable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -319,3 +338,59 @@ describe('agentPreset.select', () => {
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring over the wire', () => {
|
||||
it('reads a composition and reports whether it may be edited', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.read(request({ agentPreset: 'standard' }))
|
||||
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
// The shipped set is readable but not writable: it belongs to the
|
||||
// deployment, and it is what a broken local preset is compared against.
|
||||
expect(response.result.value.trust).toBe('system')
|
||||
expect(response.result.value.writable).toBe(false)
|
||||
expect(response.result.value.content).toContain('- id: x')
|
||||
})
|
||||
|
||||
it('rejects an id that could escape the preset root', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.write(request({ agentPreset: '../escape', content: '- id: x\n' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-invalid')
|
||||
})
|
||||
|
||||
it('rejects content that is not an entry list', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.write(request({ agentPreset: 'mine', content: 'tools: []\n' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-invalid')
|
||||
})
|
||||
|
||||
it('reports a deployment that composes no presets', async () => {
|
||||
const { api } = await harness()
|
||||
|
||||
const response = await api.agentPresets.read(request({ agentPreset: 'anything' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
|
||||
it('reports an unknown id on delete rather than succeeding silently', async () => {
|
||||
const { api } = await harness(['standard'])
|
||||
|
||||
const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' }))
|
||||
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('unreachable')
|
||||
expect(response.result.error.code).toBe('agent-preset-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,8 +88,11 @@ function scriptedApi(overrides: {
|
||||
},
|
||||
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
|
||||
agentPresets: {
|
||||
list: r => ok(r, { presets: [] }),
|
||||
list: r => ok(r, { presets: [], authorable: false }),
|
||||
select: r => ok(r, { agentPreset: r.payload.agentPreset }),
|
||||
read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '', writable: true }),
|
||||
write: r => ok(r, { agentPreset: r.payload.agentPreset }),
|
||||
remove: r => ok(r, {}),
|
||||
...overrides.agentPresets,
|
||||
},
|
||||
goals: {
|
||||
|
||||
@@ -195,12 +195,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
},
|
||||
agentPresets: {
|
||||
list(request: RpcRequest<{}>) {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { presets: [] } } })
|
||||
return Promise.resolve({
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true as const, value: { presets: [], authorable: false } },
|
||||
})
|
||||
},
|
||||
select(request: RpcRequest<{ agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
read(request: RpcRequest<{ agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '', writable: true }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
write(request: RpcRequest<{ agentPreset: string }>) {
|
||||
const value = { agentPreset: request.payload.agentPreset }
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
|
||||
},
|
||||
remove(request: RpcRequest<{ agentPreset: string }>) {
|
||||
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: {} } })
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
async list(request) {
|
||||
|
||||
@@ -513,6 +513,9 @@ describe('agent-preset schemas', () => {
|
||||
})
|
||||
|
||||
it('accepts an empty roster', () => {
|
||||
expect(agentPresetListValueSchema.parse({ presets: [] })).toEqual({ presets: [] })
|
||||
// A deployment composing no presets still reports whether one could be
|
||||
// written, so a surface knows to offer creation rather than nothing at all.
|
||||
expect(agentPresetListValueSchema.parse({ presets: [], authorable: false }))
|
||||
.toEqual({ presets: [], authorable: false })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user