diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 20af221f2d..814796f0fa 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 38c79f4617..990a055b43 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0963476a76..963a590f46 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e3634c5f92..21574c84e7 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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` 错误应答这些领域。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8e391c16b8..f47b9c8f16 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -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 diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts new file mode 100644 index 0000000000..0d4881d8cd --- /dev/null +++ b/packages/host/apiproxy/src/api/agent-presets.schema.ts @@ -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> + +/** agentPreset.list request payload. */ +export const agentPresetListRequestSchema = z.object({ +}) satisfies z.ZodType>> + +/** agentPreset.list response value. */ +export const agentPresetListValueSchema = z.object({ + presets: z.array(agentPresetEntrySchema), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts new file mode 100644 index 0000000000..0f537f6a0f --- /dev/null +++ b/packages/host/apiproxy/src/api/agent-presets.ts @@ -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> +} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 4f10d92853..46307a1c07 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.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 { 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' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 9a8750c722..726f7118d4 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.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'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0f54d76dbc..c5eda9ca41 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -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>> } + readonly agentPresets: { + list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise>> + } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -185,6 +189,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType 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), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d41b51ad6d..278fa57c0b 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -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) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e279575ff4..238a10fe8b 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -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 diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 2eebe7b218..abcedeb8d3 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -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([]) + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 490e0ad7f1..ed4a5341f1 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -23,6 +23,7 @@ function scriptedApi(overrides: { host?: Partial commands?: Partial skills?: Partial + agentPresets?: Partial events?: Partial goals?: Partial settings?: Partial @@ -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, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index bcccfdd52e..4dc46ce66a 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -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' }] } } } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..0df9890e28 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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: [] }) + }) +})