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:
@@ -53,6 +53,10 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`)
|
||||
|
||||
**Switching is allowed only while a 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 `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails.
|
||||
|
||||
**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. All four 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. `list` deliberately stays ordinary — ids and trust only, and a LAN client's picker needs it. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought.
|
||||
|
||||
**A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it.
|
||||
|
||||
**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -54,6 +54,10 @@ Status: implemented
|
||||
|
||||
**只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。
|
||||
|
||||
**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这四者都被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力,而选择它可以把会话切到一个能编辑活动运行时的 preset 上。`list` 刻意保持为普通方法——只有 id 与信任级别,而局域网客户端的选择器需要它。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。
|
||||
|
||||
**preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。
|
||||
|
||||
**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -21,7 +21,7 @@ const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml')
|
||||
* touch the network, or write outside the test. Everything that decides an
|
||||
* agent's capabilities is the real thing, including both shipped presets.
|
||||
*/
|
||||
async function bootWeb(settingsFile: string): Promise<Context> {
|
||||
async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise<Context> {
|
||||
const patches: PatchOptions[] = [
|
||||
...loadOverlayPatches('dsh-test', WEB_OVERLAY),
|
||||
// The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
|
||||
@@ -51,6 +51,7 @@ async function bootWeb(settingsFile: string): Promise<Context> {
|
||||
id: 'agent-presets',
|
||||
config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
|
||||
},
|
||||
...extra,
|
||||
]
|
||||
return await boot('dsh-test', BASE_CONFIG, patches)
|
||||
}
|
||||
@@ -289,6 +290,79 @@ describe('a forked session', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring a preset on the shipped composition', () => {
|
||||
let authorCtx: Context
|
||||
let userRoot: string
|
||||
|
||||
beforeAll(async () => {
|
||||
userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'presets')
|
||||
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml')
|
||||
await writeFile(settingsFile, '{}\n')
|
||||
authorCtx = await bootWeb(settingsFile, [{
|
||||
id: 'agent-presets',
|
||||
config: {
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
|
||||
// The root does not exist yet: a deployment whose user has authored
|
||||
// nothing is the normal first-run state.
|
||||
{ path: userRoot, trust: 'user' },
|
||||
],
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('refuses to overwrite or delete a shipped preset', async () => {
|
||||
await expect(authorCtx.agentPresets.write('standard', '- id: x\n')).rejects.toThrow(/ships with the deployment/)
|
||||
await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/)
|
||||
})
|
||||
|
||||
it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => {
|
||||
// The id becomes a directory name under the user root, so containment is
|
||||
// checked on the id rather than on the joined path afterwards.
|
||||
await expect(authorCtx.agentPresets.write(id, '- id: x\n')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('refuses text that is not a Cordis entry list', async () => {
|
||||
await expect(authorCtx.agentPresets.write('bad-shape', 'tools: []\n')).rejects.toThrow()
|
||||
await expect(authorCtx.agentPresets.resolve('bad-shape')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('writes a preset a session then really composes from', async () => {
|
||||
const copied = await authorCtx.agentPresets.read('core-web')
|
||||
|
||||
await authorCtx.agentPresets.write('my-agent', copied)
|
||||
|
||||
// Round-trips through the roster as a `user` row, and the composition the
|
||||
// editor saved is one the mount actually accepts.
|
||||
const preset = await authorCtx.agentPresets.resolve('my-agent')
|
||||
expect(preset.trust).toBe('user')
|
||||
expect(await authorCtx.agentPresets.read('my-agent')).toBe(copied)
|
||||
// Owner-only, in an owner-only directory: a composition is executable
|
||||
// configuration on a machine that may have other users.
|
||||
expect((await stat(preset.path)).mode & 0o777).toBe(0o600)
|
||||
const handle = await authorCtx.agents.create({
|
||||
sessionId: SessionId('preset-authored'),
|
||||
setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined),
|
||||
})
|
||||
try {
|
||||
// The same tools the shipped `core-web` composes, from a file written
|
||||
// through the service into a root outside the installed harness.
|
||||
expect(toolNames(authorCtx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor'])
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('deletes what it wrote', async () => {
|
||||
await authorCtx.agentPresets.write('doomed', '- id: tool-web-search\n name: \'@deepseek-ai/dsh-tool-web-search\'\n')
|
||||
|
||||
await authorCtx.agentPresets.remove('doomed')
|
||||
|
||||
expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Which preset an unnamed session gets is a user setting layered over the
|
||||
* composition's own default. The package suite proves the layering against a
|
||||
|
||||
@@ -50,40 +50,68 @@ Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent-
|
||||
|
||||
Registry over the deployment's agent presets.
|
||||
|
||||
Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read.
|
||||
Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a profile authored while the process runs is visible immediately, and a profile deleted underneath a picker disappears from the next read.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Every preset the configured roots currently supply.
|
||||
* @returns the presets, first-root-wins per id.
|
||||
* Every profile the configured roots currently supply.
|
||||
* @returns the profiles, first-root-wins per id.
|
||||
*/
|
||||
async list(): Promise<AgentPreset[]>
|
||||
|
||||
/**
|
||||
* Resolve one preset by id.
|
||||
* @param id - the preset id, or `undefined` for {@link defaultId}.
|
||||
* @returns the resolved preset.
|
||||
* Resolve one profile by id.
|
||||
* @param id - the profile id, or `undefined` for {@link defaultId}.
|
||||
* @returns the resolved profile.
|
||||
* @throws when no configured root supplies that id.
|
||||
*/
|
||||
async resolve(id?: string): Promise<AgentPreset>
|
||||
|
||||
/**
|
||||
* Compose one agent from a preset, installing it under that agent alone.
|
||||
* Compose one agent from a profile, installing it under that agent alone.
|
||||
*
|
||||
* Call from the agent factory's `setup(agentCtx)`; a rejection there rolls
|
||||
* the agent creation back, so a broken preset never yields a half-composed
|
||||
* the agent creation back, so a broken profile never yields a half-composed
|
||||
* session.
|
||||
* @param agentCtx - the agent's scope context.
|
||||
* @param id - the preset id, or `undefined` for {@link defaultId}.
|
||||
* @returns the preset that was mounted, for the caller to record.
|
||||
* @throws when the preset is unknown or its composition is unusable.
|
||||
* @param id - the profile id, or `undefined` for {@link defaultId}.
|
||||
* @returns the profile that was mounted, for the caller to record.
|
||||
* @throws when the profile is unknown or its composition is unusable.
|
||||
*/
|
||||
async mount(agentCtx: Context, id?: string): Promise<AgentPreset>
|
||||
|
||||
/**
|
||||
* One agent's instance of a service its preset mounted.
|
||||
* Read one profile's composition text.
|
||||
* @param id - the profile id.
|
||||
* @returns the composition exactly as stored.
|
||||
* @throws when no configured root supplies that id.
|
||||
*/
|
||||
async read(id: string): Promise<string>
|
||||
|
||||
/**
|
||||
* Create or replace a locally authored profile.
|
||||
*
|
||||
* A preset publishes services behind `isolate` realms, which are invisible
|
||||
* The text is shape-checked before it lands, so a save cannot leave a file no
|
||||
* session could load; it is NOT mounted, so a composition that parses but
|
||||
* names a missing plugin still fails at the next session that selects it.
|
||||
* @param id - the profile id, which becomes its directory name.
|
||||
* @param content - the composition text.
|
||||
* @throws when the id is unusable, the text is not an entry list, or the
|
||||
* deployment configures no writable root.
|
||||
*/
|
||||
async write(id: string, content: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Delete a locally authored profile.
|
||||
* @param id - the profile id.
|
||||
* @throws when the profile is unknown or ships with the deployment.
|
||||
*/
|
||||
async remove(id: string): Promise<void>
|
||||
|
||||
/**
|
||||
* One agent's instance of a service its profile mounted.
|
||||
*
|
||||
* A profile publishes services behind `isolate` realms, which are invisible
|
||||
* outside the group that declares them — including to the host. This is how a
|
||||
* caller holding the agent reads one anyway: a request that is ABOUT a
|
||||
* session but arrives from outside it, which is every browser RPC.
|
||||
@@ -92,8 +120,8 @@ async mount(agentCtx: Context, id?: string): Promise<AgentPreset>
|
||||
* because injection resolves before any session exists and has no agent to
|
||||
* key by; such a service belongs on the host plane instead.
|
||||
* @param agent - the agent whose composition to look inside.
|
||||
* @param name - the service name as the preset's rows resolve it.
|
||||
* @returns the agent's instance, or undefined when its preset mounts none.
|
||||
* @param name - the service name as the profile's rows resolve it.
|
||||
* @returns the agent's instance, or undefined when its profile mounts none.
|
||||
*/
|
||||
serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined
|
||||
|
||||
@@ -109,15 +137,15 @@ serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K):
|
||||
* therefore restores the previous composition rather than leaving the agent
|
||||
* with nothing.
|
||||
* @param agentCtx - the agent's scope context.
|
||||
* @param id - the preset to compose the agent from instead.
|
||||
* @returns the preset now installed.
|
||||
* @throws when the preset is unknown or its composition is unusable; the
|
||||
* @param id - the profile to compose the agent from instead.
|
||||
* @returns the profile now installed.
|
||||
* @throws when the profile is unknown or its composition is unusable; the
|
||||
* previous composition is restored first.
|
||||
*/
|
||||
async recompose(agentCtx: Context, id: string): Promise<AgentPreset>
|
||||
```
|
||||
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:57`](../../packages/preset/agent-presets/src/index.ts)
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:63`](../../packages/preset/agent-presets/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
|
||||
@@ -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/client/connection/README.md
|
||||
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
|
||||
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51
|
||||
README.md: 526df44ce2a167e6f06bedea6d57e4d703848e89
|
||||
README.zh.md: 02bba4aeed7155ebacae2ad66b4325d069f37301
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.select`/`read`/`write`/`remove`, since 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` stays out, carrying only ids and trust) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.select`/`read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力,而选择它可以把会话切到一个能编辑活动运行时的 preset 上;`agentPreset.list` 不在其中,它只携带 id 与信任级别)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -168,9 +168,17 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true,
|
||||
}))),
|
||||
write: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
|
||||
@@ -138,6 +138,10 @@ describe('connection node half', () => {
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
// A composition names the plugins a session runs: 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.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -226,13 +230,16 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Carries a draft credential and turns the host into a fetcher for a
|
||||
// URL the caller picked: an anonymous LAN caller must not reach it.
|
||||
'llm.discoverModels',
|
||||
'agentPreset.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
// The model catalog stays reachable for the same authority: a LAN
|
||||
// client's model picker needs it, and it carries no key or endpoint
|
||||
// state (404 is the empty proxy's carrier answer — the fence passed).
|
||||
for (const method of ['llm.providers', 'llm.models']) {
|
||||
// `agentPreset.list` joins the model catalog for the same reason: ids and
|
||||
// trust only, and a LAN client's preset picker needs it.
|
||||
for (const method of ['llm.providers', 'llm.models', 'agentPreset.list']) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
|
||||
}
|
||||
// Loopback reaches everything, configuration included.
|
||||
|
||||
@@ -203,9 +203,17 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true,
|
||||
}))),
|
||||
write: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
|
||||
@@ -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/client/ui-agent-preset/README.md
|
||||
README.md: 14921afb7b90bb0b42a8f7f83ebc78773e8419a3
|
||||
README.zh.md: 06807199e996a6ae9d8a4216b8f686b6bbc044d9
|
||||
README.md: d775daf1e91c6eb9ebca69c7d0484c0029e93cfc
|
||||
README.zh.md: b3fb3e64324dcb78465843ba2ba58f5a3efa3d08
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The agent-preset surface: one General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from.
|
||||
The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a composer seat choosing this session's, and a settings section that authors the compositions themselves.
|
||||
|
||||
## Why it is a new-session preference
|
||||
|
||||
@@ -22,9 +22,21 @@ A locally authored preset is exactly as privileged as the plugins it names, so t
|
||||
|
||||
The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it.
|
||||
|
||||
## When the row is absent
|
||||
## The management section
|
||||
|
||||
A deployment that composes no presets answers with an empty roster, and the row renders nothing — every session then shares the host composition, and there is nothing to choose between.
|
||||
A third surface, its own settings page: the roster as rows, and one composition open in a YAML editor at a time.
|
||||
|
||||
A shipped preset opens read-only. It is the known-good composition a local one is written against, so reading it is the point and overwriting it is not — the deployment's copy is what a broken local preset is compared against. Authoring therefore starts by duplicating: **New preset** copies the current default, and **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from.
|
||||
|
||||
An id becomes a directory name, so the editor mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a create landing on an existing name would overwrite a preset the user never opened. Both checks are conveniences: the host re-applies them, along with the composition's shape, and its answer is what the editor reports on failure. A save that parses is still only a save; a composition naming a plugin that does not exist fails at the next session that selects it.
|
||||
|
||||
Deleting removes the file. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
|
||||
|
||||
`agentPreset.read`, `write`, `remove`, and `select` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `agentPreset.list` is not — it carries ids and trust, and a LAN client's picker needs it.
|
||||
|
||||
## When the surfaces are absent
|
||||
|
||||
A deployment that composes no presets answers with an empty roster, and the row, the seat, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the rows still open, but creating is offered nowhere rather than through a button whose save always fails.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -36,5 +48,6 @@ No direct invalidation. Changing the default never touches a running session's p
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Presets are listed by id** — a preset carries no display metadata, so the menu shows directory names.
|
||||
- **No authoring** — creating, editing, or deleting a preset is a filesystem act; this surface only chooses among what the roster supplies.
|
||||
- **Presets are listed by id** — a preset carries no display metadata, so the menus and rows show directory names.
|
||||
- **The editor is a plain textarea** — no YAML syntax highlighting, folding, or schema completion; the host's shape check on save is the only validation.
|
||||
- **A saved composition is not mounted** — a preset that parses but names a missing plugin is accepted, and fails at the next session that selects it.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
agent preset 表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md)。
|
||||
agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);composer 中的一个座位,用于选择**本会话**的 preset;以及一个设置页分区,用于创作组装本身。
|
||||
|
||||
## 为什么它是"新建会话"的偏好设置
|
||||
|
||||
@@ -22,9 +22,21 @@ agent preset 表层:General 设置中的一行,用于选择新建会话据
|
||||
|
||||
本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。
|
||||
|
||||
## 何时不显示本行
|
||||
## 管理分区
|
||||
|
||||
未组装任何 preset 的部署返回空名单,本行不渲染任何内容——此时每个会话共用宿主组装,也就无从选择。
|
||||
第三个表层,独立的设置页:名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。
|
||||
|
||||
随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。因此创作从复制开始:**新建 preset** 复制当前默认值,**复制**则复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。
|
||||
|
||||
id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——新建若落在已存在的名称上,就会覆盖用户从未打开过的 preset。这两项检查只是便利:宿主会连同组装的形状一起重新校验,失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存;引用了不存在插件的组装,会在下一个选择它的会话处失败。
|
||||
|
||||
删除会移除该文件。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
|
||||
|
||||
`agentPreset.read`、`write`、`remove` 与 `select` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`agentPreset.list` 不在其中——它只携带 id 与信任级别,而局域网客户端的选择器需要它。
|
||||
|
||||
## 何时不显示这些表层
|
||||
|
||||
未组装任何 preset 的部署返回空名单,本行、座位与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -36,5 +48,6 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。
|
||||
- **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。
|
||||
- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单与各行显示的是目录名。
|
||||
- **编辑器是纯文本域** —— 没有 YAML 语法高亮、折叠或 schema 补全;保存时宿主的形状检查是唯一的校验。
|
||||
- **保存的组装不会被挂载** —— 能解析但引用了缺失插件的 preset 会被接受,并在下一个选择它的会话处失败。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-agent-preset",
|
||||
"description": "Agent-preset surface: the default preset for later sessions, in General settings",
|
||||
"description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -27,7 +27,8 @@
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-settings"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -42,7 +43,9 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
@@ -51,9 +54,12 @@
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rowCard {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.rowHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.defaultBadge {
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.defaultBadge {
|
||||
background: var(--dsw-alias-brand-primary);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dangerButton {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryButton:disabled,
|
||||
.dangerButton:disabled,
|
||||
.addButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.input,
|
||||
.code {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.code:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* A shipped composition is drawn a rung up, and it is the one most likely to
|
||||
overflow, so its scroll thumb rebinds to that rung. */
|
||||
.code[readonly] {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.editorActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.addCard {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteDialog {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.deleteConfirm:not(:disabled) {
|
||||
border-color: var(--dsw-alias-state-error-primary);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.deleteConfirm:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Agent-presets settings section: the roster as rows, and one composition
|
||||
* open in a YAML editor at a time.
|
||||
*
|
||||
* A shipped preset opens read-only — it is the known-good composition a local
|
||||
* one is written against — so authoring starts by duplicating one. Deleting a
|
||||
* preset leaves running sessions alone: a composition is mounted once at
|
||||
* session creation and nothing re-reads the file.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { draftBlocker, type AgentPresetSectionState, type PresetDraft } from './section-store.ts'
|
||||
import type { AgentPresetSettingsKey } from './locales.ts'
|
||||
import css from './AgentPresetSection.module.css'
|
||||
|
||||
/** Registration-side business face for the management section. */
|
||||
export interface AgentPresetSectionInjected {
|
||||
hooks: {
|
||||
/** Page snapshot bound by the renderer as useAgentPresetSection. */
|
||||
agentPresetSection: SnapshotStore<AgentPresetSectionState>
|
||||
}
|
||||
/** Read the roster; called once when the section first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Open one preset's composition in the editor. */
|
||||
open: (id: string) => Promise<void>
|
||||
/** Open a copy of one preset — or of the default — as a new preset. */
|
||||
createFrom: (from?: string) => Promise<void>
|
||||
/** Close the editor, discarding the draft. */
|
||||
close: () => void
|
||||
/** Name the preset a new draft saves to. */
|
||||
setId: (id: string) => void
|
||||
/** Replace the draft's composition text. */
|
||||
setContent: (content: string) => void
|
||||
/** Save the open draft. */
|
||||
save: () => Promise<void>
|
||||
/** Ask for delete confirmation, or dismiss it with null. */
|
||||
confirmDelete: (id: string | null) => void
|
||||
/** Delete the preset awaiting confirmation. */
|
||||
remove: () => Promise<void>
|
||||
/** Make one preset the default for sessions created later. */
|
||||
makeDefault: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetSectionProps =
|
||||
PropsRuntime<'settings.section'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetSectionInjected>
|
||||
|
||||
/** Editor sub-view props: the draft plus the actions that mutate it. */
|
||||
interface EditorProps {
|
||||
draft: PresetDraft
|
||||
blocker: ReturnType<typeof draftBlocker>
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
actions: Pick<AgentPresetSectionInjected, 'close' | 'save' | 'setContent' | 'setId'>
|
||||
}
|
||||
|
||||
function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
|
||||
const message = draft.error ?? (blocker === undefined ? null : t(blocker))
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
{draft.creating
|
||||
? (
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('presetName')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.id}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={t('presetNamePlaceholder')}
|
||||
onChange={(event) => { actions.setId(event.target.value) }}
|
||||
/>
|
||||
<span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>
|
||||
</label>
|
||||
)
|
||||
: null}
|
||||
{draft.writable ? null : <p className={css.notice}>{t('readOnlyNotice')}</p>}
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('composition')}</span>
|
||||
<textarea
|
||||
className={css.code}
|
||||
value={draft.content}
|
||||
readOnly={!draft.writable}
|
||||
spellCheck={false}
|
||||
rows={16}
|
||||
onChange={(event) => { actions.setContent(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
{message === null ? null : <p className={css.error} role="alert">{message}</p>}
|
||||
<div className={css.editorActions}>
|
||||
<Button variant="outline" disabled={draft.saving} onClick={() => { actions.close() }}>
|
||||
{draft.writable ? t('cancel') : t('close')}
|
||||
</Button>
|
||||
{draft.writable
|
||||
? (
|
||||
<Button
|
||||
disabled={draft.saving || blocker !== undefined}
|
||||
onClick={() => { void actions.save() }}
|
||||
>
|
||||
{draft.saving ? t('saving') : t('save')}
|
||||
</Button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Agent presets section content column.
|
||||
* @param props - composed slot props.
|
||||
* @returns the section, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
const { useAgentPresetSection, t, load } = props
|
||||
const state = useAgentPresetSection(snapshot => snapshot)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// A deployment that composes no presets has nothing to manage: every
|
||||
// session shares the host composition and the page would be an empty list.
|
||||
if (state.status === 'unavailable') return null
|
||||
if (state.status === 'error') {
|
||||
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
|
||||
const detail = state.error ?? ''
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<p className={css.error} role="alert">{`${t('error')} ${detail}`}</p>
|
||||
<button type="button" className={css.secondaryButton} onClick={() => { void load() }}>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { draft } = state
|
||||
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
|
||||
const editorActions = { close: props.close, save: props.save, setContent: props.setContent, setId: props.setId }
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.title}>{t('nav')}</h2>
|
||||
<p className={css.intro}>{t('sectionIntro')}</p>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
|
||||
<ul className={css.rows}>
|
||||
{state.rows.map(row => (
|
||||
<li key={row.id} className={css.rowCard}>
|
||||
<div className={css.rowHead}>
|
||||
<span className={css.rowName}>{row.id}</span>
|
||||
<span className={css.badge}>{row.trust === 'user' ? t('userTrust') : t('builtIn')}</span>
|
||||
{row.isDefault ? <span className={css.defaultBadge}>{t('defaultBadge')}</span> : null}
|
||||
<span className={css.rowActions}>
|
||||
{row.isDefault
|
||||
? null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.secondaryButton}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
{t('setDefault')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.secondaryButton}
|
||||
onClick={() => { void props.open(row.id) }}
|
||||
>
|
||||
{row.trust === 'user' ? t('edit') : t('view')}
|
||||
</button>
|
||||
{state.authorable
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.secondaryButton}
|
||||
onClick={() => { void props.createFrom(row.id) }}
|
||||
>
|
||||
{t('duplicate')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.dangerButton}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
</div>
|
||||
{draft !== null && !draft.creating && draft.source === row.id
|
||||
? <Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
|
||||
: null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{draft !== null && draft.creating
|
||||
? (
|
||||
<div className={css.addCard}>
|
||||
<Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.addButton}
|
||||
disabled={!state.authorable || state.rows.length === 0}
|
||||
onClick={() => { void props.createFrom() }}
|
||||
>
|
||||
{`+ ${t('newPreset')}`}
|
||||
</button>
|
||||
)}
|
||||
<Modal
|
||||
open={state.pendingDelete !== null}
|
||||
onClose={() => { props.confirmDelete(null) }}
|
||||
title={t('deleteTitle')}
|
||||
closeLabel={t('close')}
|
||||
description={t('deleteDescription')}
|
||||
className={css.deleteDialog as string}
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
autoFocus
|
||||
disabled={state.deleting}
|
||||
onClick={() => { props.confirmDelete(null) }}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={css.deleteConfirm}
|
||||
disabled={state.deleting}
|
||||
onClick={() => { void props.remove() }}
|
||||
>
|
||||
{state.deleting ? t('deleting') : t('deleteConfirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,41 @@
|
||||
/**
|
||||
* Agent-preset surface plugin, browser half — one General-settings row that
|
||||
* writes the default preset for sessions created later.
|
||||
* Agent-preset surface plugin, browser half — three surfaces over one roster:
|
||||
* a General-settings row for the default preset, a composer seat for the
|
||||
* session about to start, and a settings section that authors the compositions
|
||||
* themselves.
|
||||
*
|
||||
* A running session keeps the composition it began with (the host refuses to
|
||||
* adopt an existing session under a different preset), so this row is a
|
||||
* new-session preference rather than a live switch. Per-session choice at
|
||||
* creation time belongs to the session-start surface, which reads the same
|
||||
* roster.
|
||||
* adopt an existing session under a different preset), so the General row is
|
||||
* a new-session preference rather than a live switch, and the seat stops
|
||||
* offering a choice once the conversation starts.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetRow } from './AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
|
||||
import { AgentPresetSection } from './AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
|
||||
import { AgentPresetSeatController } from './seat-store.ts'
|
||||
import { AgentPresetSectionController } from './section-store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
|
||||
|
||||
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
|
||||
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
|
||||
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
|
||||
export type { AgentPresetSeatState } from './seat-store.ts'
|
||||
export {
|
||||
draftBlocker, type AgentPresetSectionState, type PresetDraft, type PresetRow,
|
||||
} from './section-store.ts'
|
||||
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
|
||||
export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
|
||||
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
/** Required services (cordis fiber inject). */
|
||||
export const inject = ['slots', 'locale', 'connection']
|
||||
@@ -35,7 +45,9 @@ export const inject = ['slots', 'locale', 'connection']
|
||||
* @param ctx - the browser plugin context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const controller = new AgentPresetSettingsController((ctx.get('connection') as ConnectionHandle).api)
|
||||
const { api } = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new AgentPresetSettingsController(api)
|
||||
const section = new AgentPresetSectionController(api)
|
||||
|
||||
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
|
||||
|
||||
@@ -51,6 +63,9 @@ export function apply(ctx: ClientContext): void {
|
||||
const refresh = (ns?: string): void => {
|
||||
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
void controller.load()
|
||||
// The section reads the same roster and marks the same default, so a
|
||||
// change made from either surface converges both.
|
||||
if (section.store.getSnapshot().status !== 'idle') void section.load()
|
||||
}
|
||||
const disposers = [
|
||||
ctx.on('settings/changed', refresh),
|
||||
@@ -90,6 +105,20 @@ export function apply(ctx: ClientContext): void {
|
||||
}, AgentPresetSeat), 'ui-agent-preset: composer seat registration')
|
||||
})
|
||||
|
||||
const sectionInjected = (): AgentPresetSectionInjected => ({
|
||||
hooks: { agentPresetSection: section.store },
|
||||
load: () => section.load(),
|
||||
open: (id: string) => section.open(id),
|
||||
createFrom: (from?: string) => section.createFrom(from),
|
||||
close: () => { section.close() },
|
||||
setId: (id: string) => { section.setId(id) },
|
||||
setContent: (content: string) => { section.setContent(content) },
|
||||
save: () => section.save(),
|
||||
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
|
||||
remove: () => section.remove(),
|
||||
makeDefault: (id: string) => section.makeDefault(id),
|
||||
})
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'agent-preset',
|
||||
@@ -97,4 +126,14 @@ export function apply(ctx: ClientContext): void {
|
||||
locale: 'settings.agentPreset',
|
||||
inject: injected,
|
||||
}, AgentPresetRow))
|
||||
// Ordered after Models: choosing a model is routine, and composing an
|
||||
// agent is the deployment-shaping act behind it.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'agent-presets',
|
||||
order: 20,
|
||||
label: () => ctx.locale.bind('settings.agentPreset')('nav'),
|
||||
locale: 'settings.agentPreset',
|
||||
inject: sectionInjected,
|
||||
}, AgentPresetSection))
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
/** Locale bundles for the agent-preset General-settings row. */
|
||||
/** Locale bundles for the agent-preset settings row, composer seat, and management section. */
|
||||
|
||||
/** Locale keys this row renders. */
|
||||
/** Locale keys these surfaces render. */
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
|
||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
|
||||
| 'duplicate' | 'delete' | 'newPreset' | 'presetName' | 'presetNamePlaceholder' | 'copyOf'
|
||||
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
|
||||
| 'idRequired' | 'idInvalid' | 'idTaken'
|
||||
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
@@ -13,6 +18,36 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
userTrust: 'Local',
|
||||
seatHint: 'Agent preset for this session — switchable until you send the first message',
|
||||
lockedHint: 'This session\'s agent preset is fixed once the conversation starts',
|
||||
nav: 'Agent presets',
|
||||
sectionIntro:
|
||||
'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. '
|
||||
+ 'Built-in presets are read-only; duplicate one to make your own.',
|
||||
builtIn: 'Built-in',
|
||||
defaultBadge: 'Default',
|
||||
setDefault: 'Set as default',
|
||||
edit: 'Edit',
|
||||
view: 'View',
|
||||
duplicate: 'Duplicate',
|
||||
delete: 'Delete',
|
||||
newPreset: 'New preset',
|
||||
presetName: 'Preset name',
|
||||
presetNamePlaceholder: 'my-agent',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (cordis.yml)',
|
||||
readOnlyNotice: 'This preset ships with the deployment and cannot be edited. Duplicate it to make your own.',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
retry: 'Retry',
|
||||
idRequired: 'Name the preset.',
|
||||
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
|
||||
idTaken: 'A preset with this name already exists.',
|
||||
deleteTitle: 'Delete this preset?',
|
||||
deleteDescription:
|
||||
'The composition file is deleted. Sessions already running on it keep working; new sessions cannot select it.',
|
||||
deleteConfirm: 'Delete',
|
||||
deleting: 'Deleting…',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
@@ -24,4 +59,31 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
userTrust: '本地',
|
||||
seatHint: '本会话的 agent preset —— 发送第一条消息前可切换',
|
||||
lockedHint: '会话开始后,其 agent preset 即固定',
|
||||
nav: 'Agent preset',
|
||||
sectionIntro: 'preset 即一个会话的 agent 所运行的插件组装 —— 它的工具、提示词与能力。内置 preset 只读;复制一份即可改成自己的。',
|
||||
builtIn: '内置',
|
||||
defaultBadge: '默认',
|
||||
setDefault: '设为默认',
|
||||
edit: '编辑',
|
||||
view: '查看',
|
||||
duplicate: '复制',
|
||||
delete: '删除',
|
||||
newPreset: '新建 preset',
|
||||
presetName: 'preset 名称',
|
||||
presetNamePlaceholder: 'my-agent',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(cordis.yml)',
|
||||
readOnlyNotice: '该 preset 随部署提供,不可编辑。复制一份即可改成自己的。',
|
||||
save: '保存',
|
||||
saving: '正在保存…',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
retry: '重试',
|
||||
idRequired: '请填写 preset 名称。',
|
||||
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
|
||||
idTaken: '同名 preset 已存在。',
|
||||
deleteTitle: '删除该 preset?',
|
||||
deleteDescription: '组装文件将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
|
||||
deleteConfirm: '删除',
|
||||
deleting: '正在删除…',
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
createSnapshotStore, type SessionId, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { messageOf } from './settings-store.ts'
|
||||
import type { AgentPresetOption } from './settings-store.ts'
|
||||
|
||||
/** Composer-seat snapshot for one session. */
|
||||
@@ -69,7 +70,7 @@ export class AgentPresetSeatController {
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ error: error instanceof Error ? error.message : String(error) })
|
||||
this.set({ error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,11 +91,7 @@ export class AgentPresetSeatController {
|
||||
}
|
||||
this.set({ busy: false, current: response.result.value.agentPreset })
|
||||
} catch (error) {
|
||||
this.set({
|
||||
busy: false,
|
||||
current: before.current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
this.set({ busy: false, current: before.current, error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Agent-preset management controller: the roster as a list, and one
|
||||
* composition open in the editor at a time.
|
||||
*
|
||||
* The host stays the single fact source. Every mutation writes through the
|
||||
* wire and the page re-reads the roster afterwards, because a save can change
|
||||
* more than the row it targeted — creating a preset adds one, and the shape
|
||||
* check the host applies is what decides whether the text landed at all.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { messageOf, writeDefaultPreset } from './settings-store.ts'
|
||||
|
||||
/** Ids a preset directory may be named, mirroring the host's own rule. */
|
||||
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
|
||||
|
||||
/** One preset row the page renders. */
|
||||
export interface PresetRow {
|
||||
/** Preset id, also its display name and directory name. */
|
||||
id: string
|
||||
/** Whether the preset ships with the deployment or was authored locally. */
|
||||
trust: 'system' | 'user'
|
||||
/** Whether a session that names no preset gets this one. */
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
/** The composition currently open in the editor. */
|
||||
export interface PresetDraft {
|
||||
/** Preset the draft saves to; empty until a new one is named. */
|
||||
id: string
|
||||
/**
|
||||
* The preset the text came from, shown while a new preset is unnamed so
|
||||
* the editor can say what it is a copy of.
|
||||
*/
|
||||
source: string
|
||||
/** Whether saving creates a preset rather than replacing one. */
|
||||
creating: boolean
|
||||
/** Composition text being edited. */
|
||||
content: string
|
||||
/** Whether this draft can be saved at all — a shipped preset opens read-only. */
|
||||
writable: boolean
|
||||
/** Whether a save is in flight. */
|
||||
saving: boolean
|
||||
/** The last save failure, cleared by the next edit. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
export interface AgentPresetSectionState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error'
|
||||
/** Whole-load failure text; a save failure stays on the draft. */
|
||||
error: string | null
|
||||
/** Whether the deployment configures a root new presets can be written to. */
|
||||
authorable: boolean
|
||||
/** Every preset the deployment currently supplies. */
|
||||
rows: readonly PresetRow[]
|
||||
/** The open editor, or null when the page is just the list. */
|
||||
draft: PresetDraft | null
|
||||
/** The preset awaiting delete confirmation. */
|
||||
pendingDelete: string | null
|
||||
/** Whether a delete is in flight. */
|
||||
deleting: boolean
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSectionState = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
authorable: false,
|
||||
rows: [],
|
||||
draft: null,
|
||||
pendingDelete: null,
|
||||
deleting: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this draft cannot be saved yet, as a locale key, or undefined when it
|
||||
* can. Client-side only: the host re-checks both the id and the composition
|
||||
* shape, and its answer is what the editor reports on failure.
|
||||
* @param draft - the open draft.
|
||||
* @param rows - the roster, for the collision check.
|
||||
* @returns the blocking reason's locale key, or undefined when saveable.
|
||||
*/
|
||||
export function draftBlocker(
|
||||
draft: PresetDraft,
|
||||
rows: readonly PresetRow[],
|
||||
): 'idRequired' | 'idInvalid' | 'idTaken' | undefined {
|
||||
if (!draft.creating) return undefined
|
||||
if (draft.id === '') return 'idRequired'
|
||||
if (!PRESET_ID.test(draft.id)) return 'idInvalid'
|
||||
// Replacing an existing preset is what Edit is for; a create that lands on
|
||||
// a name already in use would overwrite something the user did not open.
|
||||
if (rows.some(row => row.id === draft.id)) return 'idTaken'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Reads the roster and drives the composition editor. */
|
||||
export class AgentPresetSectionController {
|
||||
/** Page snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
|
||||
|
||||
constructor(private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSectionState>): void {
|
||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||
}
|
||||
|
||||
private patchDraft(patch: Partial<PresetDraft>): void {
|
||||
const { draft } = this.store.getSnapshot()
|
||||
if (draft === null) return
|
||||
this.set({ draft: { ...draft, ...patch } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the roster. An empty roster means the deployment composes no
|
||||
* presets, which is a valid deployment rather than a failure — the section
|
||||
* reports `unavailable` and renders nothing.
|
||||
* @returns once the snapshot reflects the host.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
if (this.store.getSnapshot().status === 'loading') return
|
||||
this.set({ status: 'loading', error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.list({})
|
||||
if (!response.result.ok) {
|
||||
this.set({ status: 'error', error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
const { presets, authorable } = response.result.value
|
||||
if (presets.length === 0) {
|
||||
this.set({ status: 'unavailable', rows: [], authorable, draft: null })
|
||||
return
|
||||
}
|
||||
this.set({ status: 'ready', error: null, authorable, rows: presets.map(preset => ({ ...preset })) })
|
||||
} catch (error) {
|
||||
this.set({ status: 'error', error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one preset's composition in the editor.
|
||||
*
|
||||
* A shipped preset opens read-only rather than not opening: it is the
|
||||
* known-good composition a local one is written against, so being able to
|
||||
* read it is the point.
|
||||
* @param id - the preset to open.
|
||||
* @returns once the composition loaded or the failure is on the page.
|
||||
*/
|
||||
async open(id: string): Promise<void> {
|
||||
await this.openDraft(id, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a copy of one preset as a new, unnamed preset. With no argument the
|
||||
* copy is taken from the default preset, which is the composition a new
|
||||
* session gets and therefore the one worth starting from.
|
||||
* @param from - the preset to copy, or undefined for the default.
|
||||
* @returns once the composition loaded or the failure is on the page.
|
||||
*/
|
||||
async createFrom(from?: string): Promise<void> {
|
||||
const source = from ?? this.store.getSnapshot().rows.find(row => row.isDefault)?.id
|
||||
/* v8 ignore next -- the section only renders with a roster, and every roster marks a default */
|
||||
if (source === undefined) return
|
||||
await this.openDraft(source, true)
|
||||
}
|
||||
|
||||
private async openDraft(source: string, creating: boolean): Promise<void> {
|
||||
this.set({ error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.read({ agentPreset: source })
|
||||
if (!response.result.ok) {
|
||||
this.set({ error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
const { content, writable } = response.result.value
|
||||
this.set({
|
||||
draft: {
|
||||
id: creating ? '' : source,
|
||||
source,
|
||||
creating,
|
||||
content,
|
||||
// A copy is always writable: it lands in the local root regardless of
|
||||
// where the text came from.
|
||||
writable: creating || writable,
|
||||
saving: false,
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the editor, discarding whatever was typed. */
|
||||
close(): void {
|
||||
this.set({ draft: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the preset a new draft saves to.
|
||||
* @param id - the id typed into the editor.
|
||||
*/
|
||||
setId(id: string): void {
|
||||
this.patchDraft({ id, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the draft's composition text.
|
||||
* @param content - the text in the editor.
|
||||
*/
|
||||
setContent(content: string): void {
|
||||
this.patchDraft({ content, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the open draft, then re-read the roster.
|
||||
*
|
||||
* The host shape-checks the text, so a composition that could never load is
|
||||
* refused here rather than at the next session that selects it.
|
||||
* @returns once the write settled and the page reflects it.
|
||||
*/
|
||||
async save(): Promise<void> {
|
||||
const draft = this.store.getSnapshot().draft
|
||||
if (draft === null || draft.saving || !draft.writable) return
|
||||
if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return
|
||||
this.patchDraft({ saving: true, error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.write({ agentPreset: draft.id, content: draft.content })
|
||||
if (!response.result.ok) {
|
||||
this.patchDraft({ saving: false, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
this.set({ draft: null })
|
||||
await this.load()
|
||||
} catch (error) {
|
||||
this.patchDraft({ saving: false, error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for confirmation before deleting one preset.
|
||||
* @param id - the preset to delete, or null to dismiss the confirmation.
|
||||
*/
|
||||
confirmDelete(id: string | null): void {
|
||||
if (this.store.getSnapshot().deleting) return
|
||||
this.set({ pendingDelete: id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the preset awaiting confirmation, then re-read the roster.
|
||||
*
|
||||
* A session already composed from it keeps running: its composition was
|
||||
* mounted at creation and nothing re-reads the file.
|
||||
* @returns once the delete settled and the page reflects it.
|
||||
*/
|
||||
async remove(): Promise<void> {
|
||||
const { pendingDelete, deleting, draft } = this.store.getSnapshot()
|
||||
if (pendingDelete === null || deleting) return
|
||||
this.set({ deleting: true, error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete })
|
||||
if (!response.result.ok) {
|
||||
this.set({ deleting: false, pendingDelete: null, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
this.set({
|
||||
deleting: false,
|
||||
pendingDelete: null,
|
||||
// The editor cannot stay open on a file that no longer exists.
|
||||
draft: draft?.id === pendingDelete && !draft.creating ? null : draft,
|
||||
})
|
||||
await this.load()
|
||||
} catch (error) {
|
||||
this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make one preset the default for sessions created later. Running sessions
|
||||
* keep the composition they began with, so this never disturbs work.
|
||||
* @param id - the preset to make default.
|
||||
* @returns once the write settled and the roster was re-read.
|
||||
*/
|
||||
async makeDefault(id: string): Promise<void> {
|
||||
const failure = await writeDefaultPreset(this.api, id)
|
||||
if (failure !== undefined) {
|
||||
this.set({ error: failure })
|
||||
return
|
||||
}
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,42 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client
|
||||
/** The agent-preset settings namespace on the host wire. */
|
||||
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
|
||||
|
||||
/**
|
||||
* Human text for a rejected wire call. A transport failure rejects with an
|
||||
* Error; a host or a runtime can reject with anything, and the surface still
|
||||
* has to say something.
|
||||
* @param error - the rejection value.
|
||||
* @returns the message to show.
|
||||
*/
|
||||
export function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one preset as the default for sessions created later.
|
||||
*
|
||||
* The default is a settings field rather than a preset property, so both the
|
||||
* General row and the management section write it here — one home for which
|
||||
* namespace and field the host resolves at session creation.
|
||||
* @param api - the settings wire face.
|
||||
* @param id - the preset to make default.
|
||||
* @returns the failure message, or undefined once the write landed.
|
||||
*/
|
||||
export async function writeDefaultPreset(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
id: string,
|
||||
): Promise<string | undefined> {
|
||||
let response
|
||||
try {
|
||||
response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } })
|
||||
} catch (error) {
|
||||
// The transport rejected rather than answering; the caller must be able to
|
||||
// say so instead of the row silently snapping back.
|
||||
return messageOf(error)
|
||||
}
|
||||
return response.result.ok ? undefined : response.result.error.message
|
||||
}
|
||||
|
||||
/** One selectable preset. */
|
||||
export interface AgentPresetOption {
|
||||
/** Preset id, written to Settings and shown as the label. */
|
||||
@@ -65,7 +101,8 @@ export class AgentPresetSettingsController {
|
||||
return
|
||||
}
|
||||
const presets = response.result.value.presets
|
||||
if (presets.length === 0) {
|
||||
const [first] = presets
|
||||
if (first === undefined) {
|
||||
this.set({ status: 'unavailable', options: [], currentValue: '' })
|
||||
return
|
||||
}
|
||||
@@ -73,10 +110,12 @@ export class AgentPresetSettingsController {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
|
||||
// A roster can mark nothing default: settings can name a preset that
|
||||
// was since deleted, and the picker still has to show something.
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ status: 'error', error: error instanceof Error ? error.message : String(error) })
|
||||
this.set({ status: 'error', error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,24 +130,13 @@ export class AgentPresetSettingsController {
|
||||
const before = this.store.getSnapshot()
|
||||
if (before.status === 'saving' || id === before.currentValue) return
|
||||
this.set({ status: 'saving', error: null, currentValue: id })
|
||||
try {
|
||||
const response = await this.api.settings.update({
|
||||
ns: AGENT_PRESET_SETTINGS_NS,
|
||||
patch: { default: id },
|
||||
})
|
||||
if (!response.result.ok) {
|
||||
this.set({ status: 'ready', currentValue: before.currentValue, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
// Re-read rather than trust the patch: the host resolves the default
|
||||
// through the same roster the row displays.
|
||||
await this.load()
|
||||
} catch (error) {
|
||||
this.set({
|
||||
status: 'ready',
|
||||
currentValue: before.currentValue,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
const failure = await writeDefaultPreset(this.api, id)
|
||||
if (failure !== undefined) {
|
||||
this.set({ status: 'ready', currentValue: before.currentValue, error: failure })
|
||||
return
|
||||
}
|
||||
// Re-read rather than trust the patch: the host resolves the default
|
||||
// through the same roster the row displays.
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Registration: the General row, the settings section, and the per-session
|
||||
* composer seat all come from one apply, and each defers until the slot it
|
||||
* fills has been declared. A pushed settings change refreshes the surfaces
|
||||
* that are already showing, so a default set from one converges the other.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
|
||||
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx'
|
||||
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx'
|
||||
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
|
||||
|
||||
// The service reads its initial locale from the browser; these specs assert
|
||||
// the shipped Chinese copy, so they state the browser they assume.
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const ROSTER = {
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
const calls: string[] = []
|
||||
ctx.provide('connection', {
|
||||
api: {
|
||||
agentPresets: {
|
||||
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 } },
|
||||
}),
|
||||
write: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
|
||||
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
||||
select: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
|
||||
},
|
||||
settings: {
|
||||
update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) },
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, calls }
|
||||
}
|
||||
|
||||
function declareRoot(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'settings.general.item': { kind: 'list', scope: 'root' },
|
||||
'settings.section': { kind: 'list', scope: 'root' },
|
||||
conversation: { kind: 'single', scope: 'root' },
|
||||
},
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
/** The composer's own declaration, which the seat registration waits for. */
|
||||
function declareComposer(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'conversation',
|
||||
children: { 'conversation.input.agentPreset': { kind: 'single', scope: 'session' } },
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
})
|
||||
|
||||
it('registers the General row and the settings section', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const row = slots.entries('settings.general.item')[0]!
|
||||
expect(row.component).toBe(AgentPresetRow)
|
||||
expect(row.options).toMatchObject({ id: 'agent-preset', order: -25 })
|
||||
const section = slots.entries('settings.section')[0]!
|
||||
expect(section.component).toBe(AgentPresetSection)
|
||||
expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 })
|
||||
// The nav label is a locale-following thunk; owners resolve it at read time.
|
||||
expect(resolveSlotLabel(section.options.label)).toBe('Agent preset')
|
||||
})
|
||||
|
||||
it('registers into a declaration that arrives after apply', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
declareRoot(slots)
|
||||
|
||||
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
|
||||
})
|
||||
|
||||
it('hands each surface its own store and actions', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
|
||||
expect(row.hooks.agentPreset).not.toBe(section.hooks.agentPresetSection)
|
||||
// Each thunk reaches its own controller: the row's load fills the row's
|
||||
// store, and the section's default write does not go through the row.
|
||||
await row.load()
|
||||
await row.select('standard')
|
||||
await section.makeDefault('standard')
|
||||
expect(row.hooks.agentPreset.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().rows)
|
||||
.toEqual([{ id: 'standard', trust: 'system', isDefault: true }])
|
||||
})
|
||||
|
||||
it('routes the section actions to one controller', async () => {
|
||||
const { ctx, slots } = 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.confirmDelete('mine')
|
||||
section.close()
|
||||
await Promise.all([section.open('standard'), section.createFrom(), section.save(), 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)
|
||||
})
|
||||
|
||||
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
|
||||
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()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
|
||||
const afterRelevant = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'llm-deepseek')
|
||||
await Promise.resolve()
|
||||
|
||||
// Both surfaces re-read on their own namespace; an unrelated one moves
|
||||
// neither, so this rules out a blanket refresh on every settings write.
|
||||
expect(calls.length).toBe(afterRelevant)
|
||||
})
|
||||
|
||||
it('re-reads both surfaces when the connection comes back', async () => {
|
||||
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()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('connection/reset')
|
||||
|
||||
// A reconnect can land on a host whose roster changed under the browser.
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
|
||||
})
|
||||
|
||||
it('leaves the section alone until it has been opened once', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const before = calls.length
|
||||
|
||||
ctx.emit('settings/changed', 'agent-presets')
|
||||
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
|
||||
|
||||
// Only the General row reloads: a section nobody opened has nothing to
|
||||
// converge, and reading the roster for it would be a wasted round trip.
|
||||
expect(calls.length - before).toBe(1)
|
||||
})
|
||||
|
||||
it('gives each session its own seat controller and drops its registrations on disposal', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareComposer(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: { s1: { blank: true, agentPreset: 'standard' } } }) } } as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply })
|
||||
await fiber.await()
|
||||
|
||||
const seat = slots.entries('conversation.input.agentPreset')[0]!
|
||||
expect(seat.component).toBe(AgentPresetSeat)
|
||||
const make = seat.inject as unknown as (id: string) => AgentPresetSeatInjected
|
||||
// Same session, same controller; a different session gets its own, because
|
||||
// "may it still switch" is a per-session fact.
|
||||
expect(make('s1').hooks.agentPresetSeat).toBe(make('s1').hooks.agentPresetSeat)
|
||||
expect(make('s2').hooks.agentPresetSeat).not.toBe(make('s1').hooks.agentPresetSeat)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.input.agentPreset')).toHaveLength(0)
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('reads a seat\'s session state through the live session list', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
declareComposer(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const byId: Record<string, { blank: boolean; agentPreset?: string }> = {}
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId }) } } as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const make = slots.entries('conversation.input.agentPreset')[0]!
|
||||
.inject as unknown as (id: string) => AgentPresetSeatInjected
|
||||
|
||||
const seat = make('s1')
|
||||
await seat.load()
|
||||
const unknownSession = seat.hooks.agentPresetSeat.getSnapshot().switchable
|
||||
// A session created before presets existed records none; the seat then
|
||||
// shows the roster default rather than an empty control.
|
||||
byId['s1'] = { blank: true }
|
||||
await seat.load()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
byId['s1'] = { blank: true, agentPreset: 'standard' }
|
||||
await seat.load()
|
||||
|
||||
// A session the list has not caught up to offers no switch rather than
|
||||
// guessing that it is blank.
|
||||
expect(unknownSession).toBe(false)
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ switchable: true, current: 'standard' })
|
||||
await make('s1').select('standard')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The two picker surfaces: the General-settings row that names the default for
|
||||
* later sessions, and the composer seat that names this one's — the seat stops
|
||||
* being a control once the conversation starts, because the history from there
|
||||
* on was produced under the preset's tools.
|
||||
*/
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatProps } from '../src/client/AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSettingsState } from '../src/client/settings-store.ts'
|
||||
import type { AgentPresetSeatState } from '../src/client/seat-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ROW_READY: AgentPresetSettingsState = {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
writable: true,
|
||||
currentValue: 'standard',
|
||||
options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }],
|
||||
}
|
||||
|
||||
const SEAT_READY: AgentPresetSeatState = {
|
||||
current: 'standard',
|
||||
options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }],
|
||||
switchable: true,
|
||||
busy: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, ...state })
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
|
||||
render(<AgentPresetRow {...({
|
||||
...actions,
|
||||
useAgentPreset: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetRowProps)} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}, locked = false) {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
locked,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
describe('the General-settings row', () => {
|
||||
it('reads the roster once and shows the current default', async () => {
|
||||
const actions = renderRow()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('standard')
|
||||
})
|
||||
|
||||
it('marks a locally authored option as local', () => {
|
||||
renderRow()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// A local preset is exactly as privileged as the plugins it names, so the
|
||||
// list says which rows are local rather than presenting all as vetted.
|
||||
expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy()
|
||||
// The shipped one carries no marker; only local rows are called out.
|
||||
expect(screen.getAllByText('standard')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('writes the picked preset and closes the menu', () => {
|
||||
const actions = renderRow()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
|
||||
|
||||
expect(actions.select).toHaveBeenCalledWith('mine')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('closes on an outside dismissal', () => {
|
||||
renderRow()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('says it is loading before the roster answers', () => {
|
||||
renderRow({ status: 'loading', currentValue: '' })
|
||||
|
||||
expect(screen.getByRole('button').textContent).toContain(en.loading)
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a failure in place of the description', () => {
|
||||
renderRow({ error: 'roster unavailable' })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('roster unavailable')
|
||||
})
|
||||
|
||||
it('renders nothing when the deployment composes no presets', () => {
|
||||
const { container } = render(<AgentPresetRow {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
useAgentPreset: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, status: 'unavailable', options: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetRowProps)} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('closes and locks the menu when the settings turn read-only', () => {
|
||||
const store = createSnapshotStore<AgentPresetSettingsState>(ROW_READY)
|
||||
render(<AgentPresetRow {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
useAgentPreset: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetRowProps)} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
act(() => { store.set({ ...ROW_READY, writable: false }) })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composer seat', () => {
|
||||
it('reads the roster once and offers the session\'s preset', async () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('standard')
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
|
||||
})
|
||||
|
||||
it('switches the session and closes the menu', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
|
||||
|
||||
expect(actions.select).toHaveBeenCalledWith('mine')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('becomes a plain label once the conversation has started', () => {
|
||||
renderSeat({ switchable: false })
|
||||
|
||||
// Not a disabled menu: that would suggest the preset could still change.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle(en.lockedHint).textContent).toBe('standard')
|
||||
})
|
||||
|
||||
it('closes an open menu the moment the session stops being blank', () => {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>(SEAT_READY)
|
||||
render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
act(() => { store.set({ ...SEAT_READY, switchable: false }) })
|
||||
|
||||
expect(screen.getByTitle(en.lockedHint)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables the trigger while the composer or a switch is busy', () => {
|
||||
renderSeat({ busy: true })
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
cleanup()
|
||||
|
||||
renderSeat({}, true)
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a failed switch on the trigger', () => {
|
||||
renderSeat({ error: 'session has already started' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started')
|
||||
})
|
||||
|
||||
it('renders nothing before the roster arrives or when there is none', () => {
|
||||
const empty = render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, options: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const unresolved = render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, current: '' })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
expect(unresolved.container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('closes on an outside dismissal', () => {
|
||||
renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
/** The package's node half: an empty host body and an explained empty invariant companion. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as AgentPresetInvariant from '@deepseek-ai/dsh-client-ui-agent-preset/invariant'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('reserves package ownership with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(AgentPresetInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('has an empty node half', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-agent-preset')
|
||||
|
||||
// The host body exists only so the plugin appears in the host cordis.yml;
|
||||
// every surface this package ships lives in the browser half.
|
||||
apply()
|
||||
|
||||
expect(typeof apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* The agent-preset management controller: it holds one draft at a time, opens
|
||||
* a shipped preset read-only, treats "new" as a copy of an existing
|
||||
* composition, and re-reads the roster after every mutation because a save can
|
||||
* change more than the row it targeted.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
|
||||
import type { PresetDraft, PresetRow } from '../src/client/section-store.ts'
|
||||
|
||||
interface FakePreset { trust: 'system' | 'user'; content: string }
|
||||
interface Recorded { method: string; payload: unknown }
|
||||
|
||||
interface FakeOptions {
|
||||
/** Every call the controller made, in order. */
|
||||
calls?: Recorded[]
|
||||
/** Reject `list` with this message. */
|
||||
failList?: string
|
||||
/** Reject `read` with this message. */
|
||||
failRead?: string
|
||||
/** Reject `write` with this message. */
|
||||
failWrite?: string
|
||||
/** Reject `remove` with this message. */
|
||||
failRemove?: string
|
||||
/** Reject `settings.update` with this message. */
|
||||
failSettings?: string
|
||||
/** Throw from `list` rather than answering, as a dead transport does. */
|
||||
throwList?: boolean
|
||||
/** Whether the deployment configures a writable root. */
|
||||
authorable?: boolean
|
||||
/** Hold `remove` until this resolves, to observe the in-flight state. */
|
||||
holdRemove?: Promise<void>
|
||||
}
|
||||
|
||||
const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } })
|
||||
const fail = (message: string) =>
|
||||
Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } })
|
||||
|
||||
/**
|
||||
* A wire face over an in-memory preset store: writes land, so the roster the
|
||||
* controller re-reads after a save is the one the save produced.
|
||||
* @param presets - the starting compositions by id.
|
||||
* @param defaultId - the preset a session with no choice gets.
|
||||
* @param options - failure injection and call recording.
|
||||
* @returns the fake client.
|
||||
*/
|
||||
function fakeApi(
|
||||
presets: Map<string, FakePreset>,
|
||||
defaultId: { id: string },
|
||||
options: FakeOptions = {},
|
||||
): Pick<IApiClient, 'agentPresets' | 'settings'> {
|
||||
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
|
||||
return {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
record('list', {})
|
||||
if (options.throwList === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failList !== undefined) return fail(options.failList)
|
||||
return ok({
|
||||
presets: [...presets].map(([id, preset]) => ({
|
||||
id, trust: preset.trust, isDefault: id === defaultId.id,
|
||||
})),
|
||||
authorable: options.authorable ?? true,
|
||||
})
|
||||
},
|
||||
read: (payload: { agentPreset: string }) => {
|
||||
record('read', payload)
|
||||
if (options.failRead !== undefined) return fail(options.failRead)
|
||||
const preset = presets.get(payload.agentPreset)
|
||||
/* v8 ignore next -- every test reads an id the fake store holds */
|
||||
if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`)
|
||||
return ok({
|
||||
agentPreset: payload.agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
writable: preset.trust === 'user',
|
||||
})
|
||||
},
|
||||
write: (payload: { agentPreset: string; content: string }) => {
|
||||
record('write', payload)
|
||||
if (options.failWrite !== undefined) return fail(options.failWrite)
|
||||
presets.set(payload.agentPreset, { trust: 'user', content: payload.content })
|
||||
return ok({ agentPreset: payload.agentPreset })
|
||||
},
|
||||
remove: async (payload: { agentPreset: string }) => {
|
||||
record('remove', payload)
|
||||
await options.holdRemove
|
||||
if (options.failRemove !== undefined) return await fail(options.failRemove)
|
||||
presets.delete(payload.agentPreset)
|
||||
return await ok({})
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
update: (payload: { ns: string; patch: { default?: string } }) => {
|
||||
record('settings.update', payload)
|
||||
if (options.failSettings !== undefined) return fail(options.failSettings)
|
||||
/* v8 ignore next -- the controller only ever patches `default` */
|
||||
defaultId.id = payload.patch.default ?? defaultId.id
|
||||
return ok({})
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>
|
||||
}
|
||||
|
||||
function seed(): Map<string, FakePreset> {
|
||||
return new Map<string, FakePreset>([
|
||||
['standard', { trust: 'system', content: '- id: tool-bash\n' }],
|
||||
['mine', { trust: 'user', content: '- id: tool-read\n' }],
|
||||
])
|
||||
}
|
||||
|
||||
function harness(options: FakeOptions = {}) {
|
||||
const presets = seed()
|
||||
const defaultId = { id: 'standard' }
|
||||
const calls: Recorded[] = []
|
||||
const controller = new AgentPresetSectionController(
|
||||
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
|
||||
)
|
||||
return { controller, presets, defaultId, calls }
|
||||
}
|
||||
|
||||
function draftOf(controller: AgentPresetSectionController): PresetDraft {
|
||||
const { draft } = controller.store.getSnapshot()
|
||||
if (draft === null) throw new Error('expected an open draft')
|
||||
return draft
|
||||
}
|
||||
|
||||
describe('loading the roster', () => {
|
||||
it('reports the presets, their trust, the default, and whether authoring is possible', async () => {
|
||||
const { controller } = harness()
|
||||
|
||||
await controller.load()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('ready')
|
||||
expect(state.authorable).toBe(true)
|
||||
expect(state.rows).toEqual([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('treats an empty roster as a deployment that composes no presets', async () => {
|
||||
const presets = new Map<string, FakePreset>()
|
||||
const controller = new AgentPresetSectionController(fakeApi(presets, { id: '' }, { authorable: false }))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// Not an error: every session then shares the host composition, and the
|
||||
// section renders nothing rather than an empty management page.
|
||||
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
||||
expect(controller.store.getSnapshot().authorable).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces a rejected roster call', async () => {
|
||||
const { controller } = harness({ failList: 'roster unavailable' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'roster unavailable' })
|
||||
})
|
||||
|
||||
it('surfaces a transport that rejects rather than answering', async () => {
|
||||
const { controller } = harness({ throwList: true })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('ignores a load while one is already in flight', async () => {
|
||||
const { controller, calls } = harness()
|
||||
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('opening a composition', () => {
|
||||
it('opens a locally authored preset for editing', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.open('mine')
|
||||
|
||||
expect(draftOf(controller)).toMatchObject({
|
||||
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n', writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a shipped preset read-only', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.open('standard')
|
||||
|
||||
// Readable on purpose: it is the known-good composition a local preset is
|
||||
// written against, and duplicating it is how authoring starts.
|
||||
expect(draftOf(controller)).toMatchObject({ writable: false, content: '- id: tool-bash\n' })
|
||||
})
|
||||
|
||||
it('surfaces a rejected read on the page rather than opening an empty editor', async () => {
|
||||
const { controller } = harness({ failRead: 'permission denied' })
|
||||
await controller.load()
|
||||
|
||||
await controller.open('mine')
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'permission denied' })
|
||||
})
|
||||
|
||||
it('surfaces a transport failure on the page', async () => {
|
||||
const presets = seed()
|
||||
const api = fakeApi(presets, { id: 'standard' })
|
||||
const controller = new AgentPresetSectionController({
|
||||
...api,
|
||||
agentPresets: { ...api.agentPresets, read: () => Promise.reject(new Error('socket closed')) },
|
||||
})
|
||||
await controller.load()
|
||||
|
||||
await controller.open('mine')
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('closes the editor without writing anything', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
controller.setContent('- id: changed\n')
|
||||
controller.close()
|
||||
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('creating a preset', () => {
|
||||
it('copies the default composition when no source is named', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.createFrom()
|
||||
|
||||
// The default is the composition a new session gets, so it is the one
|
||||
// worth starting from — and a copy is writable wherever it came from.
|
||||
expect(draftOf(controller)).toMatchObject({ id: '', source: 'standard', creating: true, writable: true })
|
||||
expect(draftOf(controller).content).toBe('- id: tool-bash\n')
|
||||
})
|
||||
|
||||
it('copies a named preset', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.createFrom('mine')
|
||||
|
||||
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
|
||||
})
|
||||
|
||||
it('does nothing before the roster loaded', async () => {
|
||||
const { controller, calls } = harness()
|
||||
|
||||
await controller.createFrom()
|
||||
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the save blocker', () => {
|
||||
const base: PresetDraft = {
|
||||
id: '', source: 'standard', creating: true, content: '', writable: true, saving: false, error: null,
|
||||
}
|
||||
const rows: readonly PresetRow[] = [{ id: 'mine', trust: 'user', isDefault: false }]
|
||||
|
||||
it('never blocks an edit of an existing preset', () => {
|
||||
expect(draftBlocker({ ...base, id: 'mine', creating: false }, rows)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires a name', () => {
|
||||
expect(draftBlocker(base, rows)).toBe('idRequired')
|
||||
})
|
||||
|
||||
it.each(['Upper', 'has space', '-leading', 'a/b', '../escape'])('rejects the unusable id %j', (id) => {
|
||||
// The id becomes a directory name, so the client mirrors the host's own
|
||||
// containment rule instead of letting the save round-trip to find out.
|
||||
expect(draftBlocker({ ...base, id }, rows)).toBe('idInvalid')
|
||||
})
|
||||
|
||||
it('rejects a name already in use', () => {
|
||||
// Replacing is what Edit is for; a create landing on an existing name
|
||||
// would overwrite a preset the user never opened.
|
||||
expect(draftBlocker({ ...base, id: 'mine' }, rows)).toBe('idTaken')
|
||||
})
|
||||
|
||||
it('accepts an unused, containable id', () => {
|
||||
expect(draftBlocker({ ...base, id: 'my-agent2' }, rows)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('saving', () => {
|
||||
it('creates the preset and re-reads the roster', async () => {
|
||||
const { controller, presets } = harness()
|
||||
await controller.load()
|
||||
await controller.createFrom()
|
||||
controller.setId('my-agent')
|
||||
controller.setContent('- id: tool-web-search\n')
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(presets.get('my-agent')).toEqual({ trust: 'user', content: '- id: tool-web-search\n' })
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('my-agent')
|
||||
})
|
||||
|
||||
it('replaces an existing composition', async () => {
|
||||
const { controller, presets } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
controller.setContent('- id: tool-edit\n')
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(presets.get('mine')?.content).toBe('- id: tool-edit\n')
|
||||
})
|
||||
|
||||
it('refuses to write a blocked draft', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.createFrom()
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
expect(controller.store.getSnapshot().draft).not.toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to write a read-only draft', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.open('standard')
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing without an open draft', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the draft open and reports a rejected save', async () => {
|
||||
const { controller } = harness({ failWrite: 'composition is not an entry list' })
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
await controller.save()
|
||||
|
||||
// The text stays in the editor: it is the only copy, and the message says
|
||||
// what to fix.
|
||||
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'composition is not an entry list' })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects mid-save', async () => {
|
||||
const presets = seed()
|
||||
const api = fakeApi(presets, { id: 'standard' })
|
||||
const controller = new AgentPresetSectionController({
|
||||
...api,
|
||||
agentPresets: { ...api.agentPresets, write: () => Promise.reject(new Error('socket closed')) },
|
||||
})
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
await controller.save()
|
||||
|
||||
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('ignores a second save while one is in flight', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
await Promise.all([controller.save(), controller.save()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'write')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears a save failure when the text changes', async () => {
|
||||
const { controller } = harness({ failWrite: 'invalid' })
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
await controller.save()
|
||||
|
||||
controller.setContent('- id: fixed\n')
|
||||
|
||||
expect(draftOf(controller).error).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores an edit with no draft open', () => {
|
||||
const { controller } = harness()
|
||||
|
||||
controller.setId('x')
|
||||
controller.setContent('y')
|
||||
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting', () => {
|
||||
it('deletes the confirmed preset and re-reads the roster', async () => {
|
||||
const { controller, presets } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
await controller.remove()
|
||||
|
||||
expect(presets.has('mine')).toBe(false)
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ pendingDelete: null, deleting: false })
|
||||
expect(controller.store.getSnapshot().rows.map(row => row.id)).toEqual(['standard'])
|
||||
})
|
||||
|
||||
it('closes an editor open on the deleted preset', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
await controller.remove()
|
||||
|
||||
// The file is gone; leaving its text in an editor whose Save would
|
||||
// resurrect it is worse than closing.
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a copy-in-progress open when its source is deleted', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
await controller.createFrom('mine')
|
||||
controller.setId('mine')
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
await controller.remove()
|
||||
|
||||
// The draft is a new preset that happens to be named after the one just
|
||||
// deleted; its text is unsaved work.
|
||||
expect(draftOf(controller)).toMatchObject({ id: 'mine', creating: true })
|
||||
})
|
||||
|
||||
it('dismisses the confirmation without deleting', async () => {
|
||||
const { controller, calls, presets } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
controller.confirmDelete(null)
|
||||
await controller.remove()
|
||||
|
||||
expect(calls.some(call => call.method === 'remove')).toBe(false)
|
||||
expect(presets.has('mine')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a refused delete on the page', async () => {
|
||||
const { controller } = harness({ failRemove: 'it ships with the deployment' })
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('standard')
|
||||
await controller.remove()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
pendingDelete: null, deleting: false, error: 'it ships with the deployment',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a transport that rejects mid-delete', async () => {
|
||||
const presets = seed()
|
||||
const api = fakeApi(presets, { id: 'standard' })
|
||||
const controller = new AgentPresetSectionController({
|
||||
...api,
|
||||
agentPresets: { ...api.agentPresets, remove: () => Promise.reject(new Error('socket closed')) },
|
||||
})
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
await controller.remove()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ deleting: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('ignores a second delete while one is in flight', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
controller.confirmDelete('mine')
|
||||
|
||||
await Promise.all([controller.remove(), controller.remove()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores a confirmation change while a delete is in flight', async () => {
|
||||
let release = (): void => {}
|
||||
const held = new Promise<void>((resolve) => { release = resolve })
|
||||
const presets = seed()
|
||||
const controller = new AgentPresetSectionController(
|
||||
fakeApi(presets, { id: 'standard' }, { holdRemove: held }),
|
||||
)
|
||||
await controller.load()
|
||||
controller.confirmDelete('mine')
|
||||
|
||||
const pending = controller.remove()
|
||||
// Dismissing mid-flight cannot un-delete the file, so the confirmation
|
||||
// stays put rather than the page claiming nothing is happening.
|
||||
controller.confirmDelete(null)
|
||||
expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
|
||||
release()
|
||||
await pending
|
||||
|
||||
expect(presets.has('mine')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default preset', () => {
|
||||
it('writes the settings field and re-reads the roster', async () => {
|
||||
const { controller, calls, defaultId } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
|
||||
expect(calls.find(call => call.method === 'settings.update')?.payload)
|
||||
.toEqual({ ns: 'agent-presets', patch: { default: 'mine' } })
|
||||
expect(defaultId.id).toBe('mine')
|
||||
expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
|
||||
})
|
||||
|
||||
it('reports a refused write and leaves the roster alone', async () => {
|
||||
const { controller, defaultId } = harness({ failSettings: 'settings are read-only' })
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('settings are read-only')
|
||||
expect(defaultId.id).toBe('standard')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The management section's rendering rules: which actions a row offers depends
|
||||
* on its trust and whether it is the default, a shipped composition opens
|
||||
* without a Save, and a draft the host would refuse is blocked before it is
|
||||
* sent.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionState } from '../src/client/section-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const READY: AgentPresetSectionState = {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
authorable: true,
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
draft: null,
|
||||
pendingDelete: null,
|
||||
deleting: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the section over a fixed snapshot, with every action a spy.
|
||||
* @param state - the snapshot to render.
|
||||
* @returns the spies, so a test can assert what a click reached.
|
||||
*/
|
||||
function renderSection(state: Partial<AgentPresetSectionState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSectionState>({ ...READY, ...state })
|
||||
const actions = {
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
open: vi.fn(() => Promise.resolve()),
|
||||
createFrom: vi.fn(() => Promise.resolve()),
|
||||
close: vi.fn(),
|
||||
setId: vi.fn(),
|
||||
setContent: vi.fn(),
|
||||
save: vi.fn(() => Promise.resolve()),
|
||||
confirmDelete: vi.fn(),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
makeDefault: vi.fn(() => Promise.resolve()),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
useAgentPresetSection: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSectionProps
|
||||
render(<AgentPresetSection {...props} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
function rowFor(id: string): HTMLElement {
|
||||
const row = screen.getByText(id).closest('li')
|
||||
/* v8 ignore next -- every rendered row id resolves to its card */
|
||||
if (row === null) throw new Error(`no row for ${id}`)
|
||||
return row
|
||||
}
|
||||
|
||||
describe('the preset list', () => {
|
||||
it('reads the roster once when it first renders', async () => {
|
||||
const actions = renderSection()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('marks trust and the default, and offers no "set default" on the default row', () => {
|
||||
renderSection()
|
||||
|
||||
const standard = rowFor('standard')
|
||||
expect(within(standard).getByText(en.builtIn)).toBeTruthy()
|
||||
expect(within(standard).getByText(en.defaultBadge)).toBeTruthy()
|
||||
expect(within(standard).queryByText(en.setDefault)).toBeNull()
|
||||
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers Edit for a local preset and View for a shipped one', () => {
|
||||
renderSection()
|
||||
|
||||
expect(within(rowFor('mine')).getByText(en.edit)).toBeTruthy()
|
||||
// A shipped composition is readable but not editable, and the label is
|
||||
// what says so before the editor opens.
|
||||
expect(within(rowFor('standard')).getByText(en.view)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers Delete only for a locally authored preset', () => {
|
||||
renderSection()
|
||||
|
||||
expect(within(rowFor('mine')).getByText(en.delete)).toBeTruthy()
|
||||
expect(within(rowFor('standard')).queryByText(en.delete)).toBeNull()
|
||||
})
|
||||
|
||||
it('hides duplication and disables creation when nothing is writable', () => {
|
||||
renderSection({ authorable: false })
|
||||
|
||||
expect(screen.queryByText(en.duplicate)).toBeNull()
|
||||
expect(screen.getByText(`+ ${en.newPreset}`)).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('routes the row actions to the controller', () => {
|
||||
const actions = renderSection()
|
||||
|
||||
fireEvent.click(within(rowFor('mine')).getByText(en.setDefault))
|
||||
fireEvent.click(within(rowFor('mine')).getByText(en.edit))
|
||||
fireEvent.click(within(rowFor('mine')).getByText(en.duplicate))
|
||||
fireEvent.click(screen.getByText(`+ ${en.newPreset}`))
|
||||
|
||||
expect(actions.makeDefault).toHaveBeenCalledWith('mine')
|
||||
expect(actions.open).toHaveBeenCalledWith('mine')
|
||||
expect(actions.createFrom).toHaveBeenCalledWith('mine')
|
||||
// The bare "new" copies the default, which the controller resolves.
|
||||
expect(actions.createFrom).toHaveBeenLastCalledWith()
|
||||
})
|
||||
|
||||
it('shows a page-level failure without hiding the list', () => {
|
||||
renderSection({ error: 'settings are read-only' })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('settings are read-only')
|
||||
expect(rowFor('mine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders nothing when the deployment composes no presets', () => {
|
||||
const { container } = render(<AgentPresetSection {...({
|
||||
useAgentPresetSection: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSectionState>({ ...READY, status: 'unavailable', rows: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as AgentPresetSectionProps)} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('offers a retry when the roster could not be read', () => {
|
||||
const actions = renderSection({ status: 'error', error: 'roster unavailable' })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain('roster unavailable')
|
||||
fireEvent.click(screen.getByText(en.retry))
|
||||
|
||||
expect(actions.load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composition editor', () => {
|
||||
const draft = {
|
||||
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n',
|
||||
writable: true, saving: false, error: null,
|
||||
}
|
||||
|
||||
it('opens under the row it belongs to and edits through the controller', () => {
|
||||
const actions = renderSection({ draft })
|
||||
|
||||
const editor = within(rowFor('mine')).getByRole('textbox')
|
||||
expect(editor).toHaveProperty('value', '- id: tool-read\n')
|
||||
fireEvent.change(editor, { target: { value: '- id: tool-edit\n' } })
|
||||
|
||||
expect(actions.setContent).toHaveBeenCalledWith('- id: tool-edit\n')
|
||||
})
|
||||
|
||||
it('saves and cancels through the controller', () => {
|
||||
const actions = renderSection({ draft })
|
||||
|
||||
fireEvent.click(screen.getByText(en.save))
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
|
||||
expect(actions.save).toHaveBeenCalledTimes(1)
|
||||
expect(actions.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports a save in flight and blocks a second click', () => {
|
||||
const actions = renderSection({ draft: { ...draft, saving: true } })
|
||||
|
||||
fireEvent.click(screen.getByText(en.saving))
|
||||
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a shipped composition read-only, with no way to save it', () => {
|
||||
renderSection({ draft: { ...draft, id: 'standard', source: 'standard', writable: false } })
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveProperty('readOnly', true)
|
||||
expect(screen.getByText(en.readOnlyNotice)).toBeTruthy()
|
||||
expect(screen.queryByText(en.save)).toBeNull()
|
||||
expect(screen.getByText(en.close)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('names a new preset and says what it was copied from', () => {
|
||||
const actions = renderSection({
|
||||
draft: { ...draft, id: '', source: 'standard', creating: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(`${en.copyOf} standard`)).toBeTruthy()
|
||||
fireEvent.change(screen.getByPlaceholderText(en.presetNamePlaceholder), { target: { value: 'my-agent' } })
|
||||
|
||||
expect(actions.setId).toHaveBeenCalledWith('my-agent')
|
||||
})
|
||||
|
||||
it('blocks a save the host would refuse, and says why', () => {
|
||||
const actions = renderSection({
|
||||
draft: { ...draft, id: 'Upper Case', source: 'standard', creating: true },
|
||||
})
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe(en.idInvalid)
|
||||
fireEvent.click(screen.getByText(en.save))
|
||||
|
||||
// Disabled rather than round-tripping: the id is a directory name and the
|
||||
// rule is the host's own.
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the host\'s refusal instead of the local blocker', () => {
|
||||
renderSection({ draft: { ...draft, error: 'composition is not an entry list' } })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('composition is not an entry list')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a preset', () => {
|
||||
it('asks before deleting', () => {
|
||||
const actions = renderSection()
|
||||
|
||||
fireEvent.click(within(rowFor('mine')).getByText(en.delete))
|
||||
|
||||
expect(actions.confirmDelete).toHaveBeenCalledWith('mine')
|
||||
})
|
||||
|
||||
it('confirms and dismisses through the controller', () => {
|
||||
const actions = renderSection({ pendingDelete: 'mine' })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByText(en.deleteConfirm))
|
||||
fireEvent.click(within(dialog).getByText(en.cancel))
|
||||
|
||||
expect(actions.remove).toHaveBeenCalledTimes(1)
|
||||
expect(actions.confirmDelete).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
|
||||
it('dismisses the confirmation on Escape', () => {
|
||||
const actions = renderSection({ pendingDelete: 'mine' })
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(actions.confirmDelete).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('reports a delete in flight', () => {
|
||||
const actions = renderSection({ pendingDelete: 'mine', deleting: true })
|
||||
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.deleting))
|
||||
|
||||
expect(actions.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
|
||||
} from '../src/client/settings-store.ts'
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
|
||||
@@ -17,7 +17,7 @@ interface Recorded { ns: string; patch: unknown }
|
||||
/** A client whose roster and write outcome the test controls. */
|
||||
function fakeApi(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
options: { writes?: Recorded[]; failWrite?: string; failList?: string } = {},
|
||||
options: { writes?: Recorded[]; failWrite?: string; failList?: string; failWriteWith?: Error } = {},
|
||||
): IApiClient {
|
||||
return {
|
||||
agentPresets: {
|
||||
@@ -28,6 +28,7 @@ function fakeApi(
|
||||
settings: {
|
||||
update: (payload: { ns: string; patch: unknown }) => {
|
||||
options.writes?.push({ ns: payload.ns, patch: payload.patch })
|
||||
if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith)
|
||||
if (options.failWrite !== undefined) {
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } })
|
||||
}
|
||||
@@ -127,12 +128,18 @@ describe('the composer seat controller', () => {
|
||||
function seat(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
summary: { blank: boolean; agentPreset?: string } | undefined,
|
||||
options: { writes?: Recorded[]; failSelect?: string } = {},
|
||||
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { presets } } }),
|
||||
list: () => {
|
||||
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
|
||||
return Promise.resolve(options.failList === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } })
|
||||
},
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
|
||||
options.writes?.push({ ns: 'select', patch: payload.agentPreset })
|
||||
return Promise.resolve(options.failSelect === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
|
||||
@@ -209,6 +216,89 @@ describe('the composer seat controller', () => {
|
||||
expect(state.error).toBe('already started')
|
||||
})
|
||||
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
// Settings can name a preset that was since deleted; the picker still has
|
||||
// to show something rather than an empty control.
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
]))
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().currentValue).toBe('standard')
|
||||
})
|
||||
|
||||
it('ignores a load while one is already in flight', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = new AgentPresetSettingsController(fakeApi(
|
||||
[{ id: 'standard', trust: 'system', isDefault: true }], { writes }))
|
||||
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
|
||||
expect(controller.store.getSnapshot().status).toBe('ready')
|
||||
})
|
||||
|
||||
it('reads an Error\'s message and stringifies anything else', () => {
|
||||
// A transport rejects with an Error, but a host or a runtime can reject
|
||||
// with anything and the surface still has to say something.
|
||||
expect(messageOf(new Error('boom'))).toBe('boom')
|
||||
expect(messageOf({ code: 7 })).toBe('[object Object]')
|
||||
})
|
||||
|
||||
it('reports a transport that rejects rather than answering', async () => {
|
||||
const controller = new AgentPresetSettingsController({
|
||||
agentPresets: { list: () => Promise.reject(new Error('socket closed')) },
|
||||
} as unknown as IApiClient)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects mid-write and keeps the old default showing', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
], { failWriteWith: new Error('socket closed') }))
|
||||
await controller.load()
|
||||
|
||||
await controller.select('mine')
|
||||
|
||||
// The value snaps back because the host never took it; a picker still
|
||||
// showing "mine" would be claiming a default that does not exist.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('reports a refused roster read without emptying the seat', async () => {
|
||||
const controller = seat(ROSTER, { blank: true }, { failList: 'host down' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
// The seat keeps whatever it last showed rather than claiming this session
|
||||
// has no preset; the message says why it could not refresh.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects the roster read', async () => {
|
||||
const controller = seat(ROSTER, { blank: true }, { throwOn: 'list' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('socket closed')
|
||||
})
|
||||
|
||||
it('restores the previous preset when the switch never reaches the host', async () => {
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { throwOn: 'select' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('core-web')
|
||||
|
||||
// Showing `core-web` after a failed switch would claim a composition the
|
||||
// session never got.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', busy: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('reports no options when the session is unknown to the list yet', async () => {
|
||||
const controller = seat([], undefined)
|
||||
|
||||
|
||||
@@ -20,15 +20,24 @@
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../test-runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -96,6 +96,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'async mount(agentCtx: Context, id?: string): Promise<AgentPreset>',
|
||||
jsDoc: '/**\n * Compose one agent from a preset, installing it under that agent alone.\n *\n * Call from the agent factory\'s `setup(agentCtx)`; a rejection there rolls\n * the agent creation back, so a broken preset never yields a half-composed\n * session.\n * @param agentCtx - the agent\'s scope context.\n * @param id - the preset id, or `undefined` for {@link defaultId}.\n * @returns the preset that was mounted, for the caller to record.\n * @throws when the preset is unknown or its composition is unusable.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async read(id: string): Promise<string>',
|
||||
jsDoc: '/**\n * Read one profile\'s composition text.\n * @param id - the profile id.\n * @returns the composition exactly as stored.\n * @throws when no configured root supplies that id.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async write(id: string, content: string): Promise<void>',
|
||||
jsDoc: '/**\n * Create or replace a locally authored profile.\n *\n * The text is shape-checked before it lands, so a save cannot leave a file no\n * session could load; it is NOT mounted, so a composition that parses but\n * names a missing plugin still fails at the next session that selects it.\n * @param id - the profile id, which becomes its directory name.\n * @param content - the composition text.\n * @throws when the id is unusable, the text is not an entry list, or the\n * deployment configures no writable root.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async remove(id: string): Promise<void>',
|
||||
jsDoc: '/**\n * Delete a locally authored profile.\n * @param id - the profile id.\n * @throws when the profile is unknown or ships with the deployment.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined',
|
||||
jsDoc: '/**\n * One agent\'s instance of a service its preset mounted.\n *\n * A preset publishes services behind `isolate` realms, which are invisible\n * outside the group that declares them — including to the host. This is how a\n * caller holding the agent reads one anyway: a request that is ABOUT a\n * session but arrives from outside it, which is every browser RPC.\n *\n * Read addressing only. A host row that `inject`s a service cannot use this,\n * because injection resolves before any session exists and has no agent to\n * key by; such a service belongs on the host plane instead.\n * @param agent - the agent whose composition to look inside.\n * @param name - the service name as the preset\'s rows resolve it.\n * @returns the agent\'s instance, or undefined when its preset mounts none.\n */',
|
||||
|
||||
@@ -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: 963a590f46e3ad41e432ad7ec98666ca180f7426
|
||||
README.zh.md: 87f1a702dd754119e34e615f203e1bb073c9f5d4
|
||||
README.md: abada8172e0ddc74850e1b3a0ec6b19aee5b8011
|
||||
README.zh.md: 833ebf603da94ecd46dec5f8ea8c22b71d164859
|
||||
@@ -36,6 +36,8 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
|
||||
|
||||
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.
|
||||
|
||||
`agentPreset.read`, `write`, and `remove` author the compositions themselves. `read` reports the text with its `trust` and whether it is `writable`; `write` and `remove` refuse a preset that ships with the deployment, and `write` refuses an id that is not a containable directory name or text that is not a Cordis entry list (`agent-preset-invalid`), a refusal that reaches the caller as `agent-preset-read-only` for the shipped case. Together with `select`, these four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` stays ordinary — it carries ids and trust, and every preset picker needs it — and reports `authorable`, whether the deployment configures a root a new preset could be written to at all.
|
||||
|
||||
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.
|
||||
|
||||
@@ -36,6 +36,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)以及它是否为当前默认值。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
|
||||
|
||||
`agentPreset.read`、`write` 与 `remove` 负责创作组装本身。`read` 返回文本连同它的 `trust` 以及是否 `writable`;`write` 与 `remove` 拒绝随部署提供的 preset,`write` 还拒绝不构成可约束目录名的 id 或不是 Cordis entry 列表的文本(`agent-preset-invalid`),而随部署提供这一情形以 `agent-preset-read-only` 抵达调用方。连同 `select`,这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 保持为普通方法——它只携带 id 与信任级别,而每个 preset 选择器都需要它——并报告 `authorable`,即部署是否配置了可供写入新 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` 错误应答这些领域。
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
|
||||
import {
|
||||
PresetMountError, resolveSessionPreset, UnknownPresetError,
|
||||
InvalidCompositionError, InvalidPresetIdError, PresetMountError,
|
||||
PresetNotWritableError, resolveSessionPreset, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
@@ -658,6 +659,33 @@ class SubagentSessionOwnership extends Error {
|
||||
* replay tool calls the rebuilt agent cannot make. Naming a different preset
|
||||
* is therefore a caller error rather than a switch.
|
||||
*/
|
||||
/** The roster is absent: this deployment composes no agent presets at all. */
|
||||
function noRoster(agentPreset: string): RpcError {
|
||||
return {
|
||||
code: 'agent-preset-not-found',
|
||||
message: 'this deployment composes no agent presets',
|
||||
details: { agentPreset, available: [] },
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one authoring/roster failure onto its wire code. */
|
||||
function presetError(agentPreset: string, error: unknown): RpcError {
|
||||
if (error instanceof UnknownPresetError) {
|
||||
return {
|
||||
code: 'agent-preset-not-found',
|
||||
message: error.message,
|
||||
details: { agentPreset: error.presetId, available: [...error.available] },
|
||||
}
|
||||
}
|
||||
if (error instanceof PresetNotWritableError) {
|
||||
return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } }
|
||||
}
|
||||
if (error instanceof InvalidPresetIdError || error instanceof InvalidCompositionError) {
|
||||
return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
|
||||
}
|
||||
return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
|
||||
}
|
||||
|
||||
class AgentPresetConflict extends Error {
|
||||
constructor(
|
||||
readonly sessionId: SessionId,
|
||||
@@ -2501,7 +2529,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// simply offers no choice.
|
||||
async list(request) {
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return ok(request, { presets: [] })
|
||||
if (presets === undefined) return ok(request, { presets: [], authorable: false })
|
||||
const defaultId = presets.defaultId
|
||||
return ok(request, {
|
||||
presets: (await presets.list()).map(preset => ({
|
||||
@@ -2509,6 +2537,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
trust: preset.trust,
|
||||
isDefault: preset.id === defaultId,
|
||||
})),
|
||||
authorable: presets.authorable,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -2563,6 +2592,50 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection):
|
||||
// a composition names the plugins a session runs, so reading one is
|
||||
// reconnaissance and writing one is arbitrary capability.
|
||||
async read(request) {
|
||||
const { agentPreset } = request.payload
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return err(request, noRoster(agentPreset))
|
||||
try {
|
||||
const preset = await presets.resolve(agentPreset)
|
||||
return ok(request, {
|
||||
agentPreset: preset.id,
|
||||
trust: preset.trust,
|
||||
content: await presets.read(preset.id),
|
||||
writable: preset.trust === 'user' && presets.authorable,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, presetError(agentPreset, error))
|
||||
}
|
||||
},
|
||||
|
||||
async write(request) {
|
||||
const { agentPreset, content } = request.payload
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return err(request, noRoster(agentPreset))
|
||||
try {
|
||||
await presets.write(agentPreset, content)
|
||||
return ok(request, { agentPreset })
|
||||
} catch (error: unknown) {
|
||||
return err(request, presetError(agentPreset, error))
|
||||
}
|
||||
},
|
||||
|
||||
async remove(request) {
|
||||
const { agentPreset } = request.payload
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return err(request, noRoster(agentPreset))
|
||||
try {
|
||||
await presets.remove(agentPreset)
|
||||
return ok(request, {})
|
||||
} catch (error: unknown) {
|
||||
return err(request, presetError(agentPreset, error))
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const agentPresetListRequestSchema = z.object({
|
||||
/** agentPreset.list response value. */
|
||||
export const agentPresetListValueSchema = z.object({
|
||||
presets: z.array(agentPresetEntrySchema),
|
||||
authorable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
|
||||
|
||||
/** agentPreset.select request payload. */
|
||||
@@ -35,3 +36,36 @@ export const agentPresetSelectRequestSchema = z.object({
|
||||
export const agentPresetSelectValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.select'>>>
|
||||
|
||||
/** agentPreset.read request payload. */
|
||||
export const agentPresetReadRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.read response value. */
|
||||
export const agentPresetReadValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
content: z.string(),
|
||||
writable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.write request payload. */
|
||||
export const agentPresetWriteRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
content: z.string(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.write'>>>
|
||||
|
||||
/** agentPreset.write response value. */
|
||||
export const agentPresetWriteValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.write'>>>
|
||||
|
||||
/** agentPreset.remove request payload. */
|
||||
export const agentPresetRemoveRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.remove'>>>
|
||||
|
||||
/** agentPreset.remove response value. */
|
||||
export const agentPresetRemoveValueSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.remove'>>>
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
* session, plus the authoring calls behind it.
|
||||
*
|
||||
* `list` is ordinary: it carries ids and trust, and every preset picker needs
|
||||
* it. Everything else is privileged and 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.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -26,9 +31,12 @@ 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.
|
||||
* every session shares the host composition. `authorable` reports whether
|
||||
* the deployment configures a root new presets can be written to, which is
|
||||
* a deployment fact rather than a per-preset one.
|
||||
*/
|
||||
list(request: RpcRequest<{}>): Promise<RpcResponse<{ presets: readonly AgentPresetEntry[] }>>
|
||||
list(request: RpcRequest<{}>):
|
||||
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean }>>
|
||||
|
||||
/**
|
||||
* Recompose one session's agent from a different preset.
|
||||
@@ -40,4 +48,24 @@ export interface AgentPresetsApi {
|
||||
*/
|
||||
select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/**
|
||||
* Read one preset's composition text, for an editor.
|
||||
*
|
||||
* Privileged: a composition names the plugins a session runs, so reading one
|
||||
* is reconnaissance and writing one is arbitrary capability.
|
||||
*/
|
||||
read(request: RpcRequest<{ agentPreset: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string; trust: 'system' | 'user'; content: string; writable: boolean }>>
|
||||
|
||||
/**
|
||||
* Create or replace a locally authored preset. Shipped presets are refused;
|
||||
* the text is shape-checked before it lands, so a save cannot leave a file no
|
||||
* session could load.
|
||||
*/
|
||||
write(request: RpcRequest<{ agentPreset: string; content: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/** Delete a locally authored preset. Shipped presets are refused. */
|
||||
remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
|
||||
}
|
||||
@@ -53,6 +53,9 @@ export interface RpcMethodMap {
|
||||
'skill.list': SkillsApi['list']
|
||||
'agentPreset.list': AgentPresetsApi['list']
|
||||
'agentPreset.select': AgentPresetsApi['select']
|
||||
'agentPreset.read': AgentPresetsApi['read']
|
||||
'agentPreset.write': AgentPresetsApi['write']
|
||||
'agentPreset.remove': AgentPresetsApi['remove']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
|
||||
@@ -46,6 +46,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
|
||||
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-exists': { path: string }
|
||||
'directory-create-failed': { path: string }
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-preset-read-only': { agentPreset: string; reason: string }
|
||||
'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
|
||||
'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string }
|
||||
'agent-preset-not-found': { agentPreset: string; available: string[] }
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import { agentPresetListValueSchema, agentPresetSelectValueSchema } from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
agentPresetListValueSchema, agentPresetReadValueSchema, agentPresetRemoveValueSchema,
|
||||
agentPresetSelectValueSchema, agentPresetWriteValueSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
@@ -123,6 +126,9 @@ export interface IApiClient {
|
||||
readonly agentPresets: {
|
||||
list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>>
|
||||
select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.select'>>>
|
||||
read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.read'>>>
|
||||
write(payload: RequestPayload<'agentPreset.write'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.write'>>>
|
||||
remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
@@ -192,6 +198,9 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'skill.list': skillListValueSchema,
|
||||
'agentPreset.list': agentPresetListValueSchema,
|
||||
'agentPreset.select': agentPresetSelectValueSchema,
|
||||
'agentPreset.read': agentPresetReadValueSchema,
|
||||
'agentPreset.write': agentPresetWriteValueSchema,
|
||||
'agentPreset.remove': agentPresetRemoveValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
@@ -455,6 +464,12 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
this.callUnary('agentPreset.list', payload, signal),
|
||||
select: (payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal) =>
|
||||
this.callUnary('agentPreset.select', payload, signal),
|
||||
read: (payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal) =>
|
||||
this.callUnary('agentPreset.read', payload, signal),
|
||||
write: (payload: RequestPayload<'agentPreset.write'>, signal?: AbortSignal) =>
|
||||
this.callUnary('agentPreset.write', payload, signal),
|
||||
remove: (payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal) =>
|
||||
this.callUnary('agentPreset.remove', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
|
||||
@@ -42,7 +42,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import { agentPresetListRequestSchema, agentPresetSelectRequestSchema } from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
agentPresetListRequestSchema, agentPresetReadRequestSchema, agentPresetRemoveRequestSchema,
|
||||
agentPresetSelectRequestSchema, agentPresetWriteRequestSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
@@ -112,6 +115,9 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
|
||||
'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },
|
||||
'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) },
|
||||
'agentPreset.write': { schema: agentPresetWriteRequestSchema, invoke: (api, r) => api.agentPresets.write(r) },
|
||||
'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(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) },
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,11 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
|
||||
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` Every preset the configured roots currently supply, earlier root winning a duplicate id.
|
||||
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` One preset by id, defaulting to `defaultId`. Throws naming the available ids when no root supplies it.
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset and return the preset that was mounted, for the caller to record.
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Replace the composition installed for one agent. Valid only while the agent has produced nothing — **the caller owns that check**; this method does not read session history.
|
||||
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be written at all.
|
||||
- `ctx.agentPresets.read(id): Promise<string>` One preset's composition text, exactly as stored.
|
||||
- `ctx.agentPresets.write(id, content): Promise<void>` Create or replace a locally authored preset.
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset.
|
||||
|
||||
`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file).
|
||||
|
||||
@@ -27,6 +32,28 @@ The creation header names the preset a session STARTED with; `resolveSessionPres
|
||||
|
||||
The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent.
|
||||
|
||||
### Switching a blank agent
|
||||
|
||||
`recompose()` unmounts the installed subtree and mounts the new one, because two compositions cannot coexist — both would register the same tool names into one layer. A failed mount restores the previous composition rather than leaving the agent with nothing, and an unknown id is rejected before anything is torn down.
|
||||
|
||||
The restriction to a produced-nothing agent is a product rule, not a mechanical one: swapping tools mid-conversation would leave logged tool calls the new composition cannot make. The gateway enforces it at the wire ([`dsh-apiproxy`](../../host/apiproxy/README.md) answers `agent-preset-locked`), which is where session history is in hand.
|
||||
|
||||
## Authoring
|
||||
|
||||
A locally authored preset is a directory under the first `user` root holding one `agent.cordis.yml`. `write()` refuses three things before anything lands:
|
||||
|
||||
- **An id that is not `[a-z0-9][a-z0-9-]*`.** The id becomes a directory name, so containment is a property of the id itself rather than of a path check after the fact — `../escape`, `a/b`, and an absolute path are all rejected as ids.
|
||||
- **Text that is not a Cordis entry list.** The content is parsed with the loader's own schema and dialect (`!!js` included), so a save cannot leave a file no session could load. Shape only: a composition naming a plugin that does not exist is accepted here and fails at the next session that selects it.
|
||||
- **A preset that ships with the deployment.** Overwriting one would remove the known-good composition a broken local preset is compared against. `remove()` refuses the same.
|
||||
|
||||
Writes are atomic and owner-only (`0o600`, in a `0o700` directory), and the root is created on first write — a deployment configuring a user root that does not exist yet is the normal first-run state.
|
||||
|
||||
### How a preset's rows resolve
|
||||
|
||||
A row's **package name** resolves from the host composition, not from the preset directory. The Loader normally resolves an entry against its own tree's `baseUrl`, which for a preset is wherever the composition file sits; a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the harness, so every `@deepseek-ai/dsh-*` row would fail to import. The mount records the host base before plugging the subtree and sends bare specifiers there.
|
||||
|
||||
A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it.
|
||||
|
||||
## Config
|
||||
|
||||
| Field | Default | Meaning |
|
||||
@@ -79,6 +106,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A preset cannot be changed on a live agent** — the mount happens once during creation, so switching a running session's composition would mean unwinding its subtree mid-turn, dropping tools the model may already have called. Changing the default affects only sessions created afterwards.
|
||||
- **A preset cannot be changed once a session has produced anything** — `recompose()` covers the blank-agent case; past the first turn, unwinding the subtree would drop tools the model may already have called, so the choice is fixed for the session's life. Changing the default affects only sessions created afterwards.
|
||||
- **A written composition is never mounted to check it** — `write()` validates shape, not resolvability, so a preset naming a missing plugin is stored and fails at the next session that selects it.
|
||||
- **Display names are the directory id** — a preset carries no manifest, so pickers and settings surfaces show the id until a consumer needs richer metadata.
|
||||
- **Root scans are not watched** — every read hits the filesystem instead, which keeps the roster fresh but puts one `readdir` per root on each `list()`.
|
||||
@@ -14,6 +14,11 @@
|
||||
- `ctx.agentPresets.list(): Promise<AgentPreset[]>` 当前各根目录提供的全部 preset;id 重复时靠前的根目录胜出。
|
||||
- `ctx.agentPresets.resolve(id?): Promise<AgentPreset>` 按 id 取一个 preset,缺省取 `defaultId`。没有任何根目录提供该 id 时抛错,并列出可用 id。
|
||||
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent,并返回所挂载的 preset 供调用方记录。
|
||||
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 替换某个 agent 已装入的组装。仅在该 agent 尚未产出任何内容时有效——**该检查由调用方负责**,本方法不读取会话历史。
|
||||
- `ctx.agentPresets.authorable: boolean` 是否存在 `user` 信任级别的根目录,也即是否可能写入 preset。
|
||||
- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容完全一致。
|
||||
- `ctx.agentPresets.write(id, content): Promise<void>` 创建或替换一个本地创作的 preset。
|
||||
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset。
|
||||
|
||||
`AgentPreset` 携带 `id`(目录名)、`trust`(`system` 或 `user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。
|
||||
|
||||
@@ -27,6 +32,28 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
|
||||
|
||||
头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。
|
||||
|
||||
### 切换空白 agent
|
||||
|
||||
`recompose()` 先卸载已装入的子树、再装入新的,因为两份组装无法共存——它们会把相同的工具名注册进同一个层。挂载失败会恢复先前的组装,而不是让 agent 一无所有;未知 id 则在任何东西被拆除之前就被拒绝。
|
||||
|
||||
"仅限尚未产出任何内容的 agent"是一条产品规则而非机制约束:在对话进行中调换工具,会留下新组装无法执行的、已被记录的工具调用。该规则由网关在传输层执行([`dsh-apiproxy`](../../host/apiproxy/README.md) 返回 `agent-preset-locked`),因为会话历史在那里才拿得到。
|
||||
|
||||
## 创作
|
||||
|
||||
本地创作的 preset 是首个 `user` 根目录下的一个目录,其中放置一份 `agent.cordis.yml`。`write()` 在任何内容落盘之前拒绝三种情况:
|
||||
|
||||
- **不符合 `[a-z0-9][a-z0-9-]*` 的 id。** id 会成为目录名,因此约束是 id 自身的性质,而非事后再做一次路径检查——`../escape`、`a/b` 与绝对路径都作为 id 被拒绝。
|
||||
- **不是 Cordis entry 列表的文本。** 内容使用 loader 自身的 schema 与方言(含 `!!js`)解析,因此保存不会留下任何会话都无法加载的文件。只校验形状:引用了不存在插件的组装在此被接受,并在下一个选择它的会话处失败。
|
||||
- **随部署提供的 preset。** 覆写它会抹掉那份用来对照有问题的本地 preset 的已知良好组装。`remove()` 同样拒绝。
|
||||
|
||||
写入是原子的、仅属主可读写(`0o600`,位于 `0o700` 的目录内),且根目录在首次写入时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。
|
||||
|
||||
### preset 的各行如何解析
|
||||
|
||||
行的**包名**从宿主组装解析,而非从 preset 目录解析。Loader 通常按 entry 所属树的 `baseUrl` 解析,而对 preset 而言那就是组装文件所在之处;本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败。挂载在插入子树之前先记录宿主的基址,并把裸标识符送往那里。
|
||||
|
||||
**相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。
|
||||
|
||||
## 配置
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
@@ -79,6 +106,7 @@ Indirectly, through the plugins a mounted composition registers, which own every
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **无法在存活的 agent 上更换 preset** —— 挂载只在创建时发生一次,因此切换运行中会话的组装意味着要在轮次进行途中卸载其子树,抽走模型可能已经调用的工具。更改默认值只影响此后创建的会话。
|
||||
- **会话一旦产出任何内容便无法更换 preset** —— `recompose()` 覆盖空白 agent 的情形;第一个轮次之后,卸载子树会抽走模型可能已经调用的工具,因此该选择在会话的整个生命周期内固定。更改默认值只影响此后创建的会话。
|
||||
- **写入的组装从不被实际挂载以校验** —— `write()` 校验形状而非可解析性,因此引用了缺失插件的 preset 会被存下,并在下一个选择它的会话处失败。
|
||||
- **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id。
|
||||
- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。
|
||||
@@ -27,6 +27,7 @@
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
@@ -35,6 +36,7 @@
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -42,6 +44,7 @@
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Creating, reading, and deleting locally authored presets.
|
||||
*
|
||||
* Authoring is confined to a `user` root: the shipped `.system` set is part of
|
||||
* the deployment, and letting a browser rewrite it would turn "reset to a known
|
||||
* preset" into something the same caller could have broken first.
|
||||
* @module @deepseek-ai/dsh-agent-presets/authoring
|
||||
*/
|
||||
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@cordisjs/plugin-include'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import { COMPOSITION_FILE } from './discovery.ts'
|
||||
import type { AgentPreset, PresetRoot } from './types.ts'
|
||||
|
||||
/**
|
||||
* Ids a preset directory may use.
|
||||
*
|
||||
* The id becomes a path segment, so this is a containment boundary rather than
|
||||
* a style rule: `..`, a separator, or an absolute-looking name would place the
|
||||
* composition outside the root the deployment authorised.
|
||||
*/
|
||||
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
|
||||
|
||||
/** A preset id that cannot be used as a directory name under a root. */
|
||||
export class InvalidPresetIdError extends Error {
|
||||
constructor(
|
||||
/** The rejected id. */
|
||||
readonly presetId: string,
|
||||
) {
|
||||
super(
|
||||
`agent-presets: preset id ${JSON.stringify(presetId)} must match ${String(PRESET_ID)} — `
|
||||
+ 'the id is a directory name, so anything else could escape the preset root',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A composition that is not a usable entry list. */
|
||||
export class InvalidCompositionError extends Error {
|
||||
constructor(
|
||||
/** Why the text cannot be a composition. */
|
||||
readonly reason: string,
|
||||
) {
|
||||
super(`agent-presets: composition is not a valid entry list: ${reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Authoring was attempted where the deployment allows none. */
|
||||
export class PresetNotWritableError extends Error {
|
||||
constructor(
|
||||
/** What the caller tried to change, for the diagnostic. */
|
||||
readonly presetId: string,
|
||||
reason: string,
|
||||
) {
|
||||
super(`agent-presets: preset "${presetId}" cannot be written: ${reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The root locally authored presets are written to.
|
||||
* @param roots - the configured roots in precedence order.
|
||||
* @returns the absolute path of the first `user` root.
|
||||
* @throws when the deployment configured no writable root.
|
||||
*/
|
||||
export function writableRoot(roots: readonly PresetRoot[]): string {
|
||||
const root = roots.find(candidate => candidate.trust === 'user')
|
||||
if (root === undefined) {
|
||||
throw new PresetNotWritableError('', 'this deployment configures no user-writable preset root')
|
||||
}
|
||||
return resolve(expandHomePath(root.path))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one composition's text without mounting it.
|
||||
*
|
||||
* This is the shape check the Include performs when it reads a file — a
|
||||
* top-level list of entries. It cannot prove the composition mounts (that
|
||||
* needs the plugins), so it is a guard against saving something no session
|
||||
* could ever load, not a substitute for trying it.
|
||||
* @param content - the YAML text.
|
||||
* @throws when the text does not parse or is not a top-level array.
|
||||
*/
|
||||
export function assertComposition(content: string): void {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: entryListSchema })
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- js-yaml rejects with a YAMLException, which is an Error; the
|
||||
fallback keeps a hostile throw readable rather than printing `undefined`. */
|
||||
throw new InvalidCompositionError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new InvalidCompositionError('a composition must be a top-level list of plugin rows')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one preset's composition text.
|
||||
* @param preset - the resolved preset.
|
||||
* @returns the file's contents.
|
||||
*/
|
||||
export async function readComposition(preset: AgentPreset): Promise<string> {
|
||||
return await readFile(preset.path, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace a locally authored preset.
|
||||
* @param roots - the configured roots; the first `user` one receives the write.
|
||||
* @param id - the preset id, which becomes its directory name.
|
||||
* @param content - the composition text.
|
||||
* @returns the absolute path written.
|
||||
* @throws when the id is unusable, the content is not an entry list, or the
|
||||
* deployment has no writable root.
|
||||
*/
|
||||
export async function writeComposition(
|
||||
roots: readonly PresetRoot[],
|
||||
id: string,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id)
|
||||
assertComposition(content)
|
||||
const dir = join(writableRoot(roots), id)
|
||||
const path = join(dir, COMPOSITION_FILE)
|
||||
// Owner-only: a composition names the plugins a session runs, so it carries
|
||||
// the same weight as the settings document beside it.
|
||||
await writeFileAtomic(path, content, { mode: 0o600, dirMode: 0o700 })
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a locally authored preset.
|
||||
*
|
||||
* A shipped preset is refused: it belongs to the deployment. A preset a live
|
||||
* session mounted is NOT refused — the composition was read at creation and is
|
||||
* never re-read, so that session keeps running exactly as it was.
|
||||
* @param roots - the configured roots.
|
||||
* @param preset - the resolved preset to remove.
|
||||
* @throws when the preset ships with the deployment or lies outside the writable root.
|
||||
*/
|
||||
export async function deleteComposition(
|
||||
roots: readonly PresetRoot[],
|
||||
preset: AgentPreset,
|
||||
): Promise<void> {
|
||||
if (preset.trust !== 'user') {
|
||||
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
|
||||
}
|
||||
const dir = join(writableRoot(roots), preset.id)
|
||||
// Belt and braces over the id pattern: the resolved directory must still be
|
||||
// the one the writable root owns, whatever discovery reported.
|
||||
if (!isAbsolute(preset.path) || !preset.path.startsWith(dir)) {
|
||||
throw new PresetNotWritableError(preset.id, 'it does not live under the writable preset root')
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { deleteComposition, readComposition, writeComposition } from './authoring.ts'
|
||||
import { mountPreset, serviceForAgent, unmountPresetFor } from './mount.ts'
|
||||
import { PresetNotWritableError } from './authoring.ts'
|
||||
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
@@ -37,6 +39,10 @@ export {
|
||||
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
|
||||
unmountPresetFor, type PresetMount,
|
||||
} from './mount.ts'
|
||||
export {
|
||||
assertComposition, deleteComposition, InvalidCompositionError, InvalidPresetIdError,
|
||||
PresetNotWritableError, readComposition, writableRoot, writeComposition,
|
||||
} from './authoring.ts'
|
||||
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
|
||||
export { PresetMountError, UnknownPresetError } from './types.ts'
|
||||
export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts'
|
||||
@@ -142,6 +148,51 @@ export class AgentPresets extends Service {
|
||||
return preset
|
||||
}
|
||||
|
||||
/** Whether this deployment configures a root locally authored presets go to. */
|
||||
get authorable(): boolean {
|
||||
return this.config.roots.some(root => root.trust === 'user')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one preset's composition text.
|
||||
* @param id - the preset id.
|
||||
* @returns the composition exactly as stored.
|
||||
* @throws when no configured root supplies that id.
|
||||
*/
|
||||
async read(id: string): Promise<string> {
|
||||
return await readComposition(await this.resolve(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace a locally authored preset.
|
||||
*
|
||||
* The text is shape-checked before it lands, so a save cannot leave a file no
|
||||
* session could load; it is NOT mounted, so a composition that parses but
|
||||
* names a missing plugin still fails at the next session that selects it.
|
||||
* @param id - the preset id, which becomes its directory name.
|
||||
* @param content - the composition text.
|
||||
* @throws when the id is unusable, the text is not an entry list, or the
|
||||
* deployment configures no writable root.
|
||||
*/
|
||||
async write(id: string, content: string): Promise<void> {
|
||||
// A shipped preset belongs to the deployment: overwriting it would remove
|
||||
// the known-good composition a broken local one is compared against.
|
||||
const existing = (await this.list()).find(preset => preset.id === id)
|
||||
if (existing !== undefined && existing.trust !== 'user') {
|
||||
throw new PresetNotWritableError(id, 'it ships with the deployment')
|
||||
}
|
||||
await writeComposition(this.config.roots, id, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a locally authored preset.
|
||||
* @param id - the preset id.
|
||||
* @throws when the preset is unknown or ships with the deployment.
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
await deleteComposition(this.config.roots, await this.resolve(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent's instance of a service its preset mounted.
|
||||
*
|
||||
|
||||
@@ -41,6 +41,14 @@ interface MountedTree {
|
||||
*/
|
||||
const mounted = new WeakMap<object, MountedTree>()
|
||||
|
||||
/**
|
||||
* The base URL bare specifiers resolve against, per pending mount, keyed by the
|
||||
* same config object. Recorded before the subtree is plugged, because `Include`
|
||||
* rewrites its own context's `baseUrl` to the composition's directory and the
|
||||
* pre-mount value is the only handle on where the harness itself lives.
|
||||
*/
|
||||
const harnessBase = new WeakMap<object, string>()
|
||||
|
||||
/**
|
||||
* Include subclass that publishes its tree and fiber for the audit, and never
|
||||
* writes to the file it read.
|
||||
@@ -51,6 +59,33 @@ class PresetTree extends Include {
|
||||
mounted.set(config, { tree: this, fiber: ctx.fiber })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare specifier from the harness rather than from the preset.
|
||||
*
|
||||
* `EntryTree.import()` resolves against the tree's own `baseUrl`, which
|
||||
* `Include` sets to the composition's directory. That is right for a
|
||||
* relative specifier — a preset's own files travel with it — and wrong for
|
||||
* a package name: a locally authored preset lives under the user's home,
|
||||
* where Node's upward `node_modules` walk never reaches the harness's own
|
||||
* dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The
|
||||
* mount records the host composition's base instead, which is inside the
|
||||
* installed harness, and bare names resolve from there.
|
||||
* @param name - the module specifier from the row.
|
||||
* @param getOuterStack - the loader's stack composer for import diagnostics.
|
||||
* @returns the imported module, or the `cordis:` builtin.
|
||||
*/
|
||||
override import(name: string, getOuterStack?: () => string[]): unknown {
|
||||
const base = harnessBase.get(this.config)
|
||||
/* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */
|
||||
if (base === undefined) return super.import(name, getOuterStack)
|
||||
if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(name, getOuterStack)
|
||||
const internal = this.ctx.loader.internal
|
||||
/* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a
|
||||
hypothetical embedder from losing the row's name in a resolution error. */
|
||||
if (internal === undefined) return super.import(name, getOuterStack)
|
||||
return internal.import(name, base, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* A preset is an input, never a persistence target.
|
||||
*
|
||||
@@ -269,6 +304,11 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi
|
||||
)
|
||||
}
|
||||
const config: Include.Config = { path: pathToFileURL(preset.path).href }
|
||||
// Captured before the subtree exists: the agent context still carries the
|
||||
// host composition's base, which is inside the installed harness and is
|
||||
// therefore where a row's package name has to resolve from.
|
||||
/* v8 ignore next -- the Loader sets `baseUrl` on the root before any agent context derives from it */
|
||||
if (agentCtx.baseUrl !== undefined) harnessBase.set(config, agentCtx.baseUrl)
|
||||
// Before the record this mount is about to add: every session takes this
|
||||
// path, so it is what keeps the set bounded on a host that never reads it.
|
||||
pruneDisposedMounts()
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Authoring a preset writes a composition into the deployment's `user` root.
|
||||
* The id is a directory name, so its pattern is a containment boundary rather
|
||||
* than a style rule; the shipped `.system` set stays read-only.
|
||||
*/
|
||||
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { COMPOSITION_FILE, assertComposition } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
|
||||
const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
|
||||
|
||||
let ctx: Context
|
||||
let userRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-'))
|
||||
ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.plugin(AgentPresets, {
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
|
||||
{ path: userRoot, trust: 'user' as const },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
describe('authoring a preset', () => {
|
||||
it('creates one in the user root and lists it', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID)
|
||||
|
||||
expect(await readFile(join(userRoot, 'mine', COMPOSITION_FILE), 'utf8')).toBe(VALID)
|
||||
const listed = await ctx.agentPresets.list()
|
||||
expect(listed.find(preset => preset.id === 'mine')?.trust).toBe('user')
|
||||
})
|
||||
|
||||
it('reads back what it stored', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID)
|
||||
|
||||
expect(await ctx.agentPresets.read('mine')).toBe(VALID)
|
||||
})
|
||||
|
||||
it('replaces an existing local preset', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID)
|
||||
const next = '- id: tool-beta\n name: ../../plugins/contribute.js\n config:\n tool: beta\n'
|
||||
|
||||
await ctx.agentPresets.write('mine', next)
|
||||
|
||||
expect(await ctx.agentPresets.read('mine')).toBe(next)
|
||||
})
|
||||
|
||||
it('refuses an id that could escape the preset root', async () => {
|
||||
for (const id of ['../escape', 'a/b', '/abs', '..', 'Upper']) {
|
||||
await expect(ctx.agentPresets.write(id, VALID)).rejects.toThrow(/must match/)
|
||||
}
|
||||
// Nothing was created for any of them.
|
||||
expect(existsSync(join(userRoot, 'escape'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses text that is not a top-level entry list', async () => {
|
||||
await expect(ctx.agentPresets.write('bad', 'tools: [a, b]\n'))
|
||||
.rejects.toThrow(/top-level list of plugin rows/)
|
||||
await expect(ctx.agentPresets.write('bad', '- id: x\n name: [unclosed\n'))
|
||||
.rejects.toThrow(/not a valid entry list/)
|
||||
|
||||
expect(existsSync(join(userRoot, 'bad'))).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a composition using the `!!js` dialect the include reads', () => {
|
||||
// A preset legitimately carries expressions; rejecting them would make
|
||||
// the editor refuse compositions the loader accepts.
|
||||
expect(() => { assertComposition('- id: x\n name: y\n config:\n cwd: !!js process.cwd()\n') })
|
||||
.not.toThrow()
|
||||
})
|
||||
|
||||
it('refuses to overwrite a preset that ships with the deployment', async () => {
|
||||
await expect(ctx.agentPresets.write('standard', VALID))
|
||||
.rejects.toThrow(/ships with the deployment/)
|
||||
|
||||
expect(await ctx.agentPresets.read('standard')).not.toBe(VALID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting a preset', () => {
|
||||
it('removes a locally authored one', async () => {
|
||||
await ctx.agentPresets.write('mine', VALID)
|
||||
|
||||
await ctx.agentPresets.remove('mine')
|
||||
|
||||
expect(existsSync(join(userRoot, 'mine'))).toBe(false)
|
||||
expect((await ctx.agentPresets.list()).some(preset => preset.id === 'mine')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to delete a shipped one', async () => {
|
||||
await expect(ctx.agentPresets.remove('standard'))
|
||||
.rejects.toThrow(/ships with the deployment/)
|
||||
})
|
||||
|
||||
it('reports an unknown id rather than silently succeeding', async () => {
|
||||
await expect(ctx.agentPresets.remove('never-existed')).rejects.toThrow(/not found/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a deployment with more than one user root', () => {
|
||||
it('refuses to delete a preset the writable root does not own', async () => {
|
||||
const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-'))
|
||||
await mkdir(join(second, 'elsewhere'), { recursive: true })
|
||||
await writeFile(join(second, 'elsewhere', COMPOSITION_FILE), VALID)
|
||||
const layered = new Context()
|
||||
layered.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await layered.plugin(Loader)
|
||||
layered.loader.builtins.include = Include
|
||||
await layered.plugin(AgentPresets, {
|
||||
default: 'standard',
|
||||
roots: [
|
||||
{ path: userRoot, trust: 'user' as const },
|
||||
{ path: second, trust: 'user' as const },
|
||||
],
|
||||
})
|
||||
|
||||
// Writes go to the first user root, so a preset discovered from a later
|
||||
// one is `user` trust yet outside what deletion is allowed to touch —
|
||||
// `rm -r` on a directory this root does not own is the failure to avoid.
|
||||
await expect(layered.agentPresets.remove('elsewhere'))
|
||||
.rejects.toThrow(/does not live under the writable preset root/)
|
||||
expect(existsSync(join(second, 'elsewhere'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a deployment with no writable root', () => {
|
||||
it('says authoring is unavailable rather than guessing a directory', async () => {
|
||||
const readOnly = new Context()
|
||||
readOnly.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await readOnly.plugin(Loader)
|
||||
readOnly.loader.builtins.include = Include
|
||||
await readOnly.plugin(AgentPresets, {
|
||||
default: 'standard',
|
||||
roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }],
|
||||
})
|
||||
|
||||
expect(readOnly.agentPresets.authorable).toBe(false)
|
||||
await expect(readOnly.agentPresets.write('mine', VALID))
|
||||
.rejects.toThrow(/no user-writable preset root/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a user root that does not exist yet', () => {
|
||||
it('is created by the first save', async () => {
|
||||
const absent = join(await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')), 'nested', 'preset')
|
||||
const fresh = new Context()
|
||||
fresh.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await fresh.plugin(Loader)
|
||||
fresh.loader.builtins.include = Include
|
||||
await fresh.plugin(AgentPresets, {
|
||||
default: 'mine',
|
||||
roots: [{ path: absent, trust: 'user' as const }],
|
||||
})
|
||||
|
||||
await fresh.agentPresets.write('mine', VALID)
|
||||
|
||||
expect(await readFile(join(absent, 'mine', COMPOSITION_FILE), 'utf8')).toBe(VALID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a stray file beside the preset directories', () => {
|
||||
it('does not become a preset', async () => {
|
||||
await mkdir(join(userRoot, 'not-a-preset'), { recursive: true })
|
||||
await writeFile(join(userRoot, 'not-a-preset', 'README.txt'), 'nope\n')
|
||||
|
||||
expect((await ctx.agentPresets.list()).some(preset => preset.id === 'not-a-preset')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
@@ -13,6 +13,7 @@ import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { Config } from '@deepseek-ai/dsh-agent-presets'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -27,8 +28,13 @@ const ROOTS = [
|
||||
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
|
||||
]
|
||||
|
||||
/** A composition carrying the registries a preset contributes to, plus the preset roster. */
|
||||
async function harness(): Promise<Context> {
|
||||
/**
|
||||
* A composition carrying the registries a preset contributes to, plus the
|
||||
* preset roster.
|
||||
* @param roster - roster config, defaulting to the fixture roots.
|
||||
* @returns the booted context.
|
||||
*/
|
||||
async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
@@ -39,7 +45,7 @@ async function harness(): Promise<Context> {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
|
||||
await ctx.plugin(AgentPresets, roster)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -331,6 +337,55 @@ describe('replacing a composition', () => {
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
|
||||
})
|
||||
|
||||
it('composes an agent that had nothing installed', async () => {
|
||||
// An agent created without a preset has no subtree to discard, so the
|
||||
// swap is a plain mount rather than a restore-on-failure path.
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') })
|
||||
|
||||
await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
|
||||
|
||||
expect(toolNames(ctx, handle.agent)).toEqual(['beta'])
|
||||
})
|
||||
|
||||
it('refuses a bare agent\'s broken composition without restoring anything', async () => {
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare-broken') })
|
||||
|
||||
await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken'))
|
||||
.rejects.toThrow(/failed to mount/)
|
||||
|
||||
// Nothing was installed, so there is nothing to put back.
|
||||
expect(toolNames(ctx, handle.agent)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports the switch failure even when the restore also fails', async () => {
|
||||
// The previous preset's whole directory disappears between the unmount
|
||||
// and the restore. The caller still needs to hear why the switch was
|
||||
// refused rather than why putting the old one back did not work.
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-vanishing-'))
|
||||
await mkdir(join(root, 'vanishing'), { recursive: true })
|
||||
// An absolute plugin path, because a relative one resolves from the
|
||||
// preset's own directory and this preset does not live beside the fixtures.
|
||||
await writeFile(join(root, 'vanishing', COMPOSITION_FILE), [
|
||||
'- id: alpha',
|
||||
` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`,
|
||||
' config:',
|
||||
' tool: vanishing',
|
||||
'',
|
||||
].join('\n'))
|
||||
const local = await harness({
|
||||
default: 'vanishing',
|
||||
roots: [{ path: root, trust: 'user' as const }, ...ROOTS],
|
||||
})
|
||||
const handle = await local.agents.create({
|
||||
sessionId: SessionId('sess-vanishing'),
|
||||
setup: async (agentCtx: Context) => void await local.agentPresets.mount(agentCtx, 'vanishing'),
|
||||
})
|
||||
await rm(root, { recursive: true, force: true })
|
||||
|
||||
await expect(local.agentPresets.recompose(handle.agent.ctx, 'broken'))
|
||||
.rejects.toThrow(/failed to mount/)
|
||||
})
|
||||
|
||||
it('refuses an unscoped context', async () => {
|
||||
await expect(ctx.agentPresets.recompose(ctx, 'minimal'))
|
||||
.rejects.toThrow(/unscoped context/)
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../util/atomic-write"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
|
||||
Generated
+15
@@ -1449,15 +1449,24 @@ importers:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../test-runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-settings
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-client-web-react':
|
||||
specifier: workspace:^
|
||||
version: link:../web-react
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
@@ -4213,6 +4222,9 @@ importers:
|
||||
|
||||
packages/preset/agent-presets:
|
||||
dependencies:
|
||||
js-yaml:
|
||||
specifier: ^4.1.0
|
||||
version: 4.2.0
|
||||
schemastery:
|
||||
specifier: ^3.18.0
|
||||
version: link:../../../vendor/schemastery
|
||||
@@ -4229,6 +4241,9 @@ importers:
|
||||
'@deepseek-ai/dsh-agent-loop':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent-loop
|
||||
'@deepseek-ai/dsh-atomic-write':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/atomic-write
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
|
||||
Reference in New Issue
Block a user