feat(web): expose the agent-preset roster over the API

`agentPreset.list` gives a browser the deployment's roster so it can offer a
choice when starting a session. Each row carries the id, its `trust`, and
whether it is the current default.

`trust` is on the wire deliberately: a `user` preset is exactly as privileged
as the plugins it names, so a surface that offers one alongside a shipped
preset can say which is which rather than presenting both as vetted.

The domain is read-only. A preset is a composition on disk, so authoring one
is a filesystem act rather than an RPC; and a deployment composing no presets
answers with an empty roster rather than an error, because sharing the host
composition is a valid deployment.

The RPC map made every registration site a type error, so the route, the
response-schema table, the service delegate, and the browser fixture are all
wired rather than only the ones I remembered.
This commit is contained in:
Yichen Jiang
2026-08-07 00:38:11 +08:00
parent 8d06b2d576
commit e6fe32b3c3
16 changed files with 159 additions and 2 deletions
@@ -2312,6 +2312,17 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return ok(request, { matched: true as const, commandId })
},
},
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.
list: request => ok(request, {
presets: [
{ id: 'standard', trust: 'system' as const, isDefault: true },
{ id: 'core-web', trust: 'system' as const, isDefault: false },
],
}),
},
skills: {
list: (request) => {
const missing = requireSession(request)
@@ -2609,6 +2620,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'command.list': return this.api.commands.list(request)
case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request)
case 'agentPreset.list': return this.api.agentPresets.list(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)
+2 -2
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/host/apiproxy/README.md
README.md: 0963476a767801b465a6ead24feb0ecc9988b5f5
README.zh.md: e3634c5f92f3a3723eb3c14e39223d9d9550c6f9
README.md: 9484fadcc798652979f998c81f84444c1ebdbf52
README.zh.md: 44e1d4b563e52c2491e07854469bbb283e30b28b
+2
View File
@@ -34,6 +34,8 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names) and whether it is the current default. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
+2
View File
@@ -34,6 +34,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust``user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。该领域只读——preset 是磁盘上的一份组装,创作它是文件系统行为而非 RPC。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
`settings.*``credentials.*``llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected``llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate``credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}``settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission``ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
+18
View File
@@ -2484,6 +2484,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
agentPresets: {
// A deployment with no roster answers with an empty list rather than an
// error: composing no presets is a valid deployment, and the browser
// simply offers no choice.
async list(request) {
const presets = ctx.get('agentPresets')
if (presets === undefined) return ok(request, { presets: [] })
const defaultId = presets.defaultId
return ok(request, {
presets: (await presets.list()).map(preset => ({
id: preset.id,
trust: preset.trust,
isDefault: preset.id === defaultId,
})),
})
},
},
skills: {
// Skill lookup never touches the Agent registry: the session address
// resolves to a canonical cwd from the host-resident session header, so
@@ -0,0 +1,25 @@
/**
* agent-presets domain zod schemas (names derived from map keys:
* agentPresetListRequestSchema / agentPresetListValueSchema).
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { AgentPresetEntry } from './agent-presets.ts'
/** AgentPresetEntry row of agentPreset.list. */
export const agentPresetEntrySchema = z.object({
id: z.string().min(1),
trust: z.union([z.literal('system'), z.literal('user')]),
isDefault: z.boolean(),
}) satisfies z.ZodType<Wire<AgentPresetEntry>>
/** agentPreset.list request payload. */
export const agentPresetListRequestSchema = z.object({
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.list'>>>
/** agentPreset.list response value. */
export const agentPresetListValueSchema = z.object({
presets: z.array(agentPresetEntrySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
@@ -0,0 +1,31 @@
/**
* agent-presets domain contract: the roster a browser offers when starting a
* session. Read-only — a preset is a composition on disk, and authoring one is
* a filesystem act rather than an RPC.
*/
import type { RpcRequest, RpcResponse } from './rpc.ts'
/** One preset the deployment can compose a session's agent from. */
export interface AgentPresetEntry {
/** Stable identifier, also the display name until presets carry metadata. */
readonly id: string
/**
* Whether the preset ships with the deployment or was authored locally.
* A `user` preset is exactly as privileged as the plugins it names, so a
* surface offering one should say so rather than present it as vetted.
*/
readonly trust: 'system' | 'user'
/** Whether a session that names no preset gets this one. */
readonly isDefault: boolean
}
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
export interface AgentPresetsApi {
/**
* Lists every preset the deployment currently supplies, ordered by id.
* An empty roster means the deployment composes no presets at all, and
* every session shares the host composition.
*/
list(request: RpcRequest<{}>): Promise<RpcResponse<{ presets: readonly AgentPresetEntry[] }>>
}
+3
View File
@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts'
import type { SubagentsApi } from './subagents.ts'
import type { EventsApi } from './events.ts'
@@ -25,6 +26,7 @@ export interface ApiProxy {
workspace: WorkspaceApi
commands: CommandsApi
skills: SkillsApi
agentPresets: AgentPresetsApi
events: EventsApi
goals: GoalsApi
settings: SettingsApi
@@ -47,6 +49,7 @@ export type {
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { AgentPresetsApi } from './agent-presets.ts'
import type { SkillsApi } from './skills.ts'
import type { GoalsApi } from './goals.ts'
import type { SettingsApi } from './settings.ts'
@@ -50,6 +51,7 @@ export interface RpcMethodMap {
'command.list': CommandsApi['list']
'command.execute': CommandsApi['execute']
'skill.list': SkillsApi['list']
'agentPreset.list': AgentPresetsApi['list']
'goal.create': GoalsApi['create']
'goal.edit': GoalsApi['edit']
'goal.pause': GoalsApi['pause']
@@ -40,6 +40,7 @@ import {
} from '../api/workspace.schema.ts'
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
import { skillListValueSchema } from '../api/skills.schema.ts'
import { agentPresetListValueSchema } from '../api/agent-presets.schema.ts'
import {
goalCreateValueSchema,
goalEditValueSchema,
@@ -119,6 +120,9 @@ export interface IApiClient {
skills: {
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
}
readonly agentPresets: {
list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>>
}
events: {
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
@@ -185,6 +189,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'command.list': commandListValueSchema,
'command.execute': commandExecuteValueSchema,
'skill.list': skillListValueSchema,
'agentPreset.list': agentPresetListValueSchema,
'goal.create': goalCreateValueSchema,
'goal.edit': goalEditValueSchema,
'goal.pause': goalPauseValueSchema,
@@ -443,6 +448,11 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
}
readonly agentPresets = {
list: (payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal) =>
this.callUnary('agentPreset.list', payload, signal),
}
readonly goals: IApiClient['goals'] = {
create: (payload, signal) => this.callUnary('goal.create', payload, signal),
edit: (payload, signal) => this.callUnary('goal.edit', payload, signal),
@@ -42,6 +42,7 @@ import {
} from '../api/workspace.schema.ts'
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
import { skillListRequestSchema } from '../api/skills.schema.ts'
import { agentPresetListRequestSchema } from '../api/agent-presets.schema.ts'
import {
goalCreateRequestSchema,
goalEditRequestSchema,
@@ -109,6 +110,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
+2
View File
@@ -63,6 +63,7 @@ export class ApiProxyService extends Service implements ApiProxy {
readonly commands: ApiProxy['commands']
readonly goals: ApiProxy['goals']
readonly skills: ApiProxy['skills']
readonly agentPresets: ApiProxy['agentPresets']
readonly settings: ApiProxy['settings']
readonly credentials: ApiProxy['credentials']
readonly llm: ApiProxy['llm']
@@ -85,6 +86,7 @@ export class ApiProxyService extends Service implements ApiProxy {
this.commands = api.commands
this.goals = api.goals
this.skills = api.skills
this.agentPresets = api.agentPresets
this.settings = api.settings
this.credentials = api.credentials
this.llm = api.llm
@@ -235,3 +235,30 @@ describe('a capability the session\'s preset mounts', () => {
expect(failure.error.message).toContain('neither this session')
})
})
describe('agentPreset.list', () => {
it('marks the default and carries each preset\'s trust', async () => {
const { api } = await harness(['standard', 'core-web'])
const response = await api.agentPresets.list(request({}))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.presets).toEqual([
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'core-web', trust: 'system', isDefault: false },
])
})
it('answers with an empty roster when the deployment composes no presets', async () => {
const { api } = await harness()
const response = await api.agentPresets.list(request({}))
// Composing no presets is a valid deployment, not an error: every session
// then shares the host composition and the browser offers no choice.
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.presets).toEqual([])
})
})
@@ -23,6 +23,7 @@ function scriptedApi(overrides: {
host?: Partial<ApiProxy['host']>
commands?: Partial<ApiProxy['commands']>
skills?: Partial<ApiProxy['skills']>
agentPresets?: Partial<ApiProxy['agentPresets']>
events?: Partial<ApiProxy['events']>
goals?: Partial<ApiProxy['goals']>
settings?: Partial<ApiProxy['settings']>
@@ -86,6 +87,7 @@ function scriptedApi(overrides: {
...overrides.commands,
},
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
agentPresets: { list: r => ok(r, { presets: [] }), ...overrides.agentPresets },
goals: {
create: err,
edit: err,
@@ -193,6 +193,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { matched: false } } }
},
},
agentPresets: {
list(request: RpcRequest<{}>) {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { presets: [] } } })
},
},
skills: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } }
@@ -32,6 +32,7 @@ import {
commandListRequestSchema, commandListValueSchema,
} from '../src/api/commands.schema.ts'
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
import { agentPresetEntrySchema, agentPresetListValueSchema } from '../src/api/agent-presets.schema.ts'
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
@@ -502,3 +503,16 @@ describe('respond payload schemas', () => {
expect(payload.sessionId).toBe('s')
})
})
describe('agent-preset schemas', () => {
it('accepts a roster row and rejects an unknown trust', () => {
expect(agentPresetEntrySchema.parse({ id: 'standard', trust: 'system', isDefault: true }))
.toEqual({ id: 'standard', trust: 'system', isDefault: true })
expect(() => agentPresetEntrySchema.parse({ id: 'x', trust: 'root', isDefault: false })).toThrow()
expect(() => agentPresetEntrySchema.parse({ id: '', trust: 'user', isDefault: false })).toThrow()
})
it('accepts an empty roster', () => {
expect(agentPresetListValueSchema.parse({ presets: [] })).toEqual({ presets: [] })
})
})