refactor(agent-presets,web): copy-only preset authoring with a path to the files

The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.

The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.

With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.

New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
This commit is contained in:
Yichen Jiang
2026-08-08 22:35:26 +08:00
parent 2cea99409f
commit b77fb9036c
60 changed files with 2253 additions and 1336 deletions
@@ -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 .agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md
2026-08-08-per-preset-standing-mounts.md: 19a53926e9f07b01115cfbb4ddc89eb7ee0c59a0
2026-08-08-per-preset-standing-mounts.zh.md: 856fbb742a843935902f9aa7578ba6821a9a13c3
2026-08-08-per-preset-standing-mounts.md: 61aa737a4b7d6f15f160d3673a14cdc0c5df4a76
2026-08-08-per-preset-standing-mounts.zh.md: 17de2cf3ec9ee9564cb5fde1fabb5014c389be39
@@ -23,7 +23,7 @@ Standing mounts fix the class, not the instances: the registrations a reader nee
## Load-bearing details
- **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer.
- **A settled mount is permanent for the process.** The composition a running session joined must survive its file changing or disappearing; deletion and edits reach only future generations (the authoring layer swaps the map pointer, never disposes a joined generation), and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations.
- **A settled mount serves until its composition file's stamp changes.** The composition a running session joined must survive its file changing or disappearing; each generation records the file's stamp (mtime + size) and a session that finds it stale starts the next generation, so file edits — the only composition editor once authoring became copy-only — reach later sessions without any authoring call dropping the pointer. Joined sessions keep their generation, and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations.
- **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it).
- **Re-linking a key (`setScopeParent` on a live agent) is the blank-session recompose path** — valid only while nothing produced under the old parent is retained, which the caller must uphold; the relation cannot see session logs.
@@ -23,7 +23,7 @@ Status: implemented
## Load-bearing details
- **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。
- **挂载一旦成功即进程级永久。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;删除与编辑只影响未来的代际(创作层替换 map 指针,绝不 dispose 已被加入的代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。
- **挂载一旦成功即持续供职,直到组装文件的 stamp 变化。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stamp(mtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。
- **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。
- **对活 agent 重新认父(`setScopeParent`)是空白会话 recompose 的路径**——仅当旧父之下的产出一概不被保留时才合法,由调用方保证;该关系看不见会话日志。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md
2026-08-08-copy-only-preset-authoring.md: c16518b087c7acedbee3d89ce5cc8dbcaa0a0cde
2026-08-08-copy-only-preset-authoring.zh.md: dc2d7924cb0fd9363efa9387bc8ba68bf11af5d7
@@ -0,0 +1,30 @@
# Agent Note: Copy-only preset authoring, and the way into a preset's files
Status: implemented
English | [中文](2026-08-08-copy-only-preset-authoring.zh.md)
## Problem
The agent-preset settings page carried a web YAML editor: `agentPreset.write` accepted arbitrary composition text, the page held a textarea with no completion, highlighting, or diff, and the shape check leaned on the Loader's own `entryListSchema` — whose dialect includes `!!js`, so "shape-checked text" was still arbitrary code on the next mount. Weak as an editor, wide as a capability, and the source of the editor-vs-roster races the section had to defend against.
## Decision
Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `agentPreset.openDocument { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`, pinned by the gateway's `nativeOpen` config where `canOpenNativePath` platform detection would mislead, e.g. e2e and containers).
## Consequences
- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The privileged set is now `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target.
- With the editor gone, hand-editing `agent.cordis.yml` is the ONLY composition edit, so the standing-mount layer grew stamp-keyed generations: `ensureStanding` compares the file's mtime+size and starts the next generation for later sessions ([standing-mounts note](../architecture/2026-08-08-per-preset-standing-mounts.md), updated in place). Without this, an edited file would serve stale compositions until process restart.
- A copy is a full snapshot that drifts from an upgraded shipped source — accepted; the preset layer has no patch semantics (that is the bundle layer's `cordis.patch.yml`), and the shipped set itself pays the same cost (`cordis`/`code` are full copies of `standard`) for one-file readability.
- `read` dropped `writable` (no editor to gate) and builtin directories are never opened (`openDocument` refuses non-`user` trust like `remove`): the install is overwritten by upgrades, and pointing an editor into it invites edits an upgrade silently discards.
## Load-bearing details
- **Copy target refusal is two checks on purpose.** The roster check refuses any id a root supplies — a user directory named like a shipped preset would be shadowed, so "create" would land a file nothing ever lists; the disk check (`PresetExistsError` before `cp` with `errorOnExist` as the race backstop) refuses a directory occupying the name without being a preset, which discovery cannot see.
- **The revealed path is response-direction disclosure, loopback-pinned.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the loopback user is the fallback the plan requires. It never rides the unprivileged `list`.
- **The e2e lane pins `nativeOpen: false`** (`agent-preset-authoring.overlay.yml`) — both so goldens render the same branch on macOS dev and headless Linux CI, and so test runs never pop a real file manager. The revealed directory is tokenized as `{{presetRoot}}` by the lane itself, since `normalizeAria` only knows the workspace cwd.
## Alternatives considered
Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `host.openPath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter.
@@ -0,0 +1,30 @@
# Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口
Status: implemented
[English](2026-08-08-copy-only-preset-authoring.md) | 中文
## Problem
agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` 接收任意组装文本,页面是一个没有补全、高亮或 diff 的文本域,形状检查依赖 Loader 自己的 `entryListSchema`——其方言含 `!!js`,所以「过了形状检查的文本」在下一次挂载时仍是任意代码。作为编辑器很弱,作为能力很宽,还是该分区不得不防御的「编辑器 vs 名单」竞态的来源。
## Decision
创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`agentPreset.openDocument { agentPreset }` 在宿主端解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供卡片以文本展示(`list` 上的 `hasDocument`;在 `canOpenNativePath` 平台探测会失真处由网关的 `nativeOpen` 配置钉死,例如 e2e 与容器)。
## Consequences
- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。特权集现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标。
- 编辑器移除后,手改 `agent.cordis.yml` 成为**唯一**的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:`ensureStanding` 比对文件的 mtime+大小,为后续会话开启下一代际([常驻挂载 note](../architecture/2026-08-08-per-preset-standing-mounts.md),已就地更新)。没有它,改过的文件要等进程重启才生效。
- 副本是完整快照,会随随附来源升级而漂移——接受;preset 层没有 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(`cordis`/`code` 就是 `standard` 的完整副本)。
- `read` 去掉了 `writable`(没有编辑器可门控),内置目录绝不被打开(`openDocument``remove` 一样拒绝非 `user` 信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。
## Load-bearing details
- **复制目标的拒绝刻意分两道检查。** roster 检查拒绝任一根目录提供的 id——与随附 preset 同名的用户目录会被遮蔽,「创建」只会落下一个永远不被列出的文件;磁盘检查(`cp` 之前的 `PresetExistsError``errorOnExist` 作竞态兜底)拒绝占着名字却不是 preset 的目录,那是 discovery 看不见的。
- **展示的路径是响应方向的披露,且钉在环回。**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给环回用户正是方案要求的降级。它绝不搭乘非特权的 `list`
- **e2e lane 钉死 `nativeOpen: false`**`agent-preset-authoring.overlay.yml`)——既让 golden 在 macOS 开发机与无头 Linux CI 上渲染同一分支,也让测试运行永不弹出真实文件管理器。揭示的目录由 lane 自己 token 化为 `{{presetRoot}}`,因为 `normalizeAria` 只认识 workspace cwd。
## Alternatives considered
保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `host.openPath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。
+14 -18
View File
@@ -376,32 +376,28 @@ describe('authoring a preset on the shipped composition', () => {
}])
})
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/)
it('refuses to copy over or delete a shipped preset', async () => {
await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/)
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()
await expect(authorCtx.agentPresets.copy('minimal', id)).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('copies a shipped preset a session then really composes from', async () => {
await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式')
it('writes a preset a session then really composes from', async () => {
const copied = await authorCtx.agentPresets.read('minimal')
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.
// Round-trips through the roster as a `user` row carrying the given name
// and the source's description, over the source's own composition text.
const preset = await authorCtx.agentPresets.resolve('my-agent')
const source = await authorCtx.agentPresets.resolve('minimal')
expect(preset.trust).toBe('user')
expect(await authorCtx.agentPresets.read('my-agent')).toBe(copied)
expect(preset.name).toBe('我的模式')
expect(preset.description).toBe(source.description)
expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal'))
// 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)
@@ -410,7 +406,7 @@ describe('authoring a preset on the shipped composition', () => {
setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined),
})
try {
// The same tools the shipped `minimal` composes, from a file written
// The same tools the shipped `minimal` composes, from a directory copied
// through the service into a root outside the installed harness.
expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
} finally {
@@ -418,8 +414,8 @@ describe('authoring a preset on the shipped composition', () => {
}
})
it('deletes what it wrote', async () => {
await authorCtx.agentPresets.write('doomed', '- id: tool-web-search\n name: \'@deepseek-ai/dsh-tool-web-search\'\n')
it('deletes what it copied', async () => {
await authorCtx.agentPresets.copy('minimal', 'doomed')
await authorCtx.agentPresets.remove('doomed')
@@ -0,0 +1,181 @@
// Web e2e scenario: the agent-preset settings section as copy-only authoring.
// The browser never edits composition text — a shipped preset opens in a
// read-only viewer, the copy dialog collects an id and an optional display
// name, and the host copies the whole directory. The section's other job is
// getting the user TO the files: this lane pins `nativeOpen: false` (see the
// overlay), so the location affordance answers the preset directory as text —
// the deterministic branch a golden can hold on every platform.
//
// Zero model calls: no replay fixture mounts, so a stray stream fails loud.
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { Locator } from 'playwright'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url))
const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md')
const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md')
/** The shipped roster, beside the composition that names it. */
const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url))
const MODE = webSnapshotMode()
describe('web e2e: agent-preset authoring is a host-side copy', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let userRoot: string
/** The settings dialog, opened on the Agent-presets section. */
function settingsDialog(): Locator {
return page.getByRole('dialog', { name: '设置' })
}
/** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */
function withPresetRoot(snapshot: string): string {
return snapshot.split(userRoot).join('{{presetRoot}}')
}
beforeAll(async () => {
userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-')))
scaffold = await launchWebScaffold({
extraOverlayPath: OVERLAY,
agentPresets: {
roots: [
{ path: SHIPPED_PRESETS, trust: 'system' },
{ path: userRoot, trust: 'user' },
],
default: 'standard',
},
})
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('offers the roster with copy as the only way to create', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section'))
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = settingsDialog()
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
// The intro carries the guidance a create button used to imply, and the
// shipped rows offer view/copy but never delete or a location — their
// install is overwritten by upgrades and is not the user's to manage.
expect(snapshot).toContain('复制「极简模式」')
expect(snapshot).not.toContain('新建预设')
expect(snapshot).toContain('查看: 标准模式')
expect(snapshot).not.toContain('删除: 标准模式')
expect(snapshot).not.toContain('打开目录')
}, 60_000)
it('views a shipped composition read-only instead of editing it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '查看: 标准模式' }).click()
const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' })
await viewer.waitFor({ timeout: 10_000 })
// The real shipped composition, not a golden: the viewer shows whatever
// the deployment ships, and this lane only asserts it is shown read-only.
const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8')
expect(await viewer.locator('pre').textContent()).toBe(shipped)
expect(await viewer.getByRole('textbox').count()).toBe(0)
// The header X and the footer button share the 关闭 name; the footer one
// is last in the dialog.
await viewer.getByRole('button', { name: '关闭' }).last().click()
await viewer.waitFor({ state: 'detached', timeout: 10_000 })
}, 60_000)
it('copies 极简模式 whole under a new id and lands in its files', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
await copyDialog.waitFor({ timeout: 10_000 })
const dialogSnapshot = await captureStableAria(
page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE)
// Two fields and nothing else: the id is the directory name the host
// needs up front; description and composition live in the files.
expect(dialogSnapshot).toContain('标识符')
expect(dialogSnapshot).not.toContain('描述')
await copyDialog.getByPlaceholder('my-agent').fill('my-agent')
await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式')
await copyDialog.getByRole('button', { name: '创建' }).click()
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
// The new row lands in the custom group, and — with no desktop opener —
// its directory is revealed as text right away: landing in the files is
// the completion of a copy, not a follow-up.
await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 })
await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 })
// The copy dialog is detached, so the settings dialog is the only one
// left (it names itself via aria-labelledby, which a CSS attribute
// selector cannot address).
const snapshot = withPresetRoot(
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('{{presetRoot}}/my-agent')
// The host copied the whole directory and rewrote only the display
// metadata: the composition is byte-identical to the shipped source, the
// description rides along for the user to edit in place, and neither the
// source's name nor its roster order survives into the copy.
const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8')
expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8'))
const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8')
expect(metadata).toContain('name: 我的模式')
expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。')
expect(metadata).not.toContain('order:')
}, 60_000)
it('deletes the copy after confirmation and reclaims the roster', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete'))
const dialog = settingsDialog()
await dialog.getByRole('button', { name: '删除: 我的模式' }).click()
const confirm = page.getByRole('dialog', { name: '删除该预设?' })
await confirm.waitFor({ timeout: 10_000 })
await confirm.getByRole('button', { name: '删除', exact: true }).click()
await confirm.waitFor({ state: 'detached', timeout: 10_000 })
await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
// Custom group gone with its only member; the shipped set stands.
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
}, 60_000)
it('drove every surface without a page error or a stream warning', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
@@ -0,0 +1,12 @@
# The authoring lane drives the location affordance. A real desktop open
# would pop a file manager on the machine running the tests and the
# capability itself is platform-detected (macOS yes, headless Linux CI no),
# so the gateway is pinned headless: `hasDocument` is false everywhere and
# `openDocument` answers the directory as text — the same branch on every
# host, and the one whose rendering a golden can hold. A patch replaces the
# row's complete config, so the shipped routing defaults ride along.
- id: api-gateway
config:
provider: deepseek-official
model: deepseek-v4-flash
nativeOpen: false
@@ -0,0 +1,14 @@
- dialog "复制预设 · 复制自 极简模式":
- heading "复制预设 · 复制自 极简模式" [level=2]
- button "关闭":
- img
- paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。
- text: 标识符
- textbox "标识符":
- /placeholder: my-agent
- text: 名称
- textbox "名称":
- /placeholder: 选择器中显示的名字,缺省用标识符
- alert: 请填写标识符。
- button "取消"
- button "创建" [disabled]
@@ -0,0 +1,78 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份即可改成自己的,之后直接编辑它的文件。 想从最小的骨架开始,就复制「极简模式」。
- heading "内置" [level=3]
- list:
- listitem:
- 'button "当前使用: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
- code: standard
- 'button "查看: 标准模式"':
- img
- text: 查看
- 'button "复制: 标准模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 代码模式"':
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
- code: code
- 'button "查看: 代码模式"':
- img
- text: 查看
- 'button "复制: 代码模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
- code: minimal
- 'button "查看: 极简模式"':
- img
- text: 查看
- 'button "复制: 极简模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
- code: cordis
- 'button "查看: 创造模式"':
- img
- text: 查看
- 'button "复制: 创造模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- list:
- listitem:
- 'button "设为默认: 我的模式"':
- text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
- code: my-agent
- 'button "查看路径: 我的模式"':
- img
- text: 查看路径
- 'button "复制: 我的模式"':
- img
- text: 复制
- 'button "删除: 我的模式"':
- img
- text: 删除
- paragraph:
- text: 预设文件:
- code: {{presetRoot}}/my-agent
@@ -0,0 +1,60 @@
- dialog "设置":
- navigation:
- text: 设置
- button "通用设置":
- img
- text: 通用设置
- button "模型":
- img
- text: 模型
- button "Agent 预设":
- img
- text: Agent 预设
- button "打开配置文件"
- button "关闭":
- img
- text: 关闭
- heading "Agent 预设" [level=2]
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份即可改成自己的,之后直接编辑它的文件。 想从最小的骨架开始,就复制「极简模式」。
- heading "内置" [level=3]
- list:
- listitem:
- 'button "当前使用: 标准模式" [disabled] [pressed]':
- text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。
- code: standard
- 'button "查看: 标准模式"':
- img
- text: 查看
- 'button "复制: 标准模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 代码模式"':
- text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。
- code: code
- 'button "查看: 代码模式"':
- img
- text: 查看
- 'button "复制: 代码模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 极简模式"':
- text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
- code: minimal
- 'button "查看: 极简模式"':
- img
- text: 查看
- 'button "复制: 极简模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 创造模式"':
- text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。
- code: cordis
- 'button "查看: 创造模式"':
- img
- text: 查看
- 'button "复制: 创造模式"':
- img
- text: 复制
+1
View File
@@ -60,6 +60,7 @@
"tests/permission-policy-context.e2e.ts",
"tests/access-confirmation.e2e.ts",
"tests/agent-preset-selection.e2e.ts",
"tests/agent-preset-authoring.e2e.ts",
"tests/shipped-composition.e2e.ts",
"tests/startup-auto-selection.e2e.ts",
"tests/produced-files.e2e.ts",
+8
View File
@@ -649,6 +649,14 @@ export interface Config {
model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
/**
* Whether this deployment can hand paths to a native desktop opener —
* the `hasDocument` capability the agent-preset roster reports. Absent,
* the platform is asked (macOS/Windows/WSL yes; Linux only with a display
* server); set it explicitly where detection misleads, e.g. `false` in a
* container whose DISPLAY points nowhere a user can see.
*/
nativeOpen?: boolean
}
```
+13 -10
View File
@@ -91,18 +91,21 @@ async mount(agentCtx: Context, id?: string): Promise<AgentPreset>
async read(id: string): Promise<string>
/**
* Create or replace a locally authored preset.
* Create a locally authored preset by copying an existing one whole.
*
* 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.
* @param metadata - display name and description; clearing both removes the file.
* @throws when the id is unusable, the text is not an entry list, or the
* deployment configures no writable root.
* Copy is the only authoring write. Composition text never crosses this
* seam: the source is named by id and its directory is copied as it stands,
* so the copy is exactly as loadable as its source and authoring grants no
* capability the roster did not already carry. The copy is NOT mounted to
* validate — a source that mounts today yields a copy that mounts today.
* @param from - the preset the copy starts from; shipped presets are the
* primary source, so any trust is accepted.
* @param id - the new preset's id, which becomes its directory name.
* @param name - display name for the copy; absent falls back to the id.
* @throws when the source is unknown, the id is unusable or already taken,
* or the deployment configures no writable root.
*/
async write(id: string, content: string, metadata: PresetMetadata = {}): Promise<void>
async copy(from: string, id: string, name?: string): Promise<void>
/**
* Delete a locally authored preset.
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: e597d047ab36cf34ac1bce041e162a42c01dc58a
README.zh.md: 9442633526266c981aa7b39942e9c0a9b34dd161
README.md: da2cb781de6726596d1003ac9c2756b6113afc19
README.zh.md: 87998d73ae5b768e4c3ded2967ab972fbc872c9f
+1 -1
View File
@@ -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. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. 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.read`/`write`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability; `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) 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. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. 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.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) 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
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unaryrespond,并为 `events.mux``events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。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.read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 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 发送 unaryrespond,并为 `events.mux``events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。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.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径)`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
## /api 浏览器信任栅栏
@@ -2464,6 +2464,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
isDefault: id === fixtureDefaultPreset,
})),
authorable: true,
hasDocument: true,
}),
select: (request) => {
fixtureDefaultPreset = request.payload.agentPreset
@@ -2483,21 +2484,42 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
agentPreset,
trust: preset.trust,
content: preset.content,
writable: preset.trust === 'user',
})
},
write: (request) => {
const { agentPreset, content } = request.payload
copy: (request) => {
const { from, agentPreset } = request.payload
const source = fixturePresets.get(from)
if (source === undefined) {
return err(request, {
code: 'agent-preset-not-found',
message: `unknown agent preset "${from}"`,
details: { agentPreset: from, available: [...fixturePresets.keys()] },
})
}
if (fixturePresets.has(agentPreset)) {
return err(request, {
code: 'agent-preset-invalid',
message: `agent preset "${agentPreset}" already exists`,
details: { agentPreset, reason: 'already exists' },
})
}
fixturePresets.set(agentPreset, { trust: 'user', content: source.content })
return ok(request, { agentPreset })
},
// Native opens are deterministic no-op successes in this fixture, so the
// open-directory affordance renders and the path-text fallback stays a
// component-test concern.
openDocument: (request) => {
const { agentPreset } = request.payload
const existing = fixturePresets.get(agentPreset)
if (existing?.trust === 'system') {
if (existing === undefined || 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 })
return ok(request, { opened: true as const })
},
remove: (request) => {
const { agentPreset } = request.payload
@@ -2835,7 +2857,8 @@ export class FixtureApiClient extends AbstractApiClient {
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.copy': return this.api.agentPresets.copy(request)
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
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)
+6 -3
View File
@@ -67,8 +67,10 @@ export const Config: z<ConnectionConfig> = z.object({
*/
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.
// reconnaissance; copy and remove rearrange what the deployment offers, and
// openDocument drives the host desktop — all more than the roster beside
// them. (Authoring is copy-only, so no method here accepts composition text
// or a path; the pin is about who may manage the roster at all.)
//
// CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
// preset looks like escalation — one of them mounts the toolset that edits the
@@ -79,7 +81,8 @@ const PRIVILEGED_METHODS = new Set([
// any caller that may start a session at all can already run commands as this
// process. Pinning the switch would be a fence beside an open gate.
'agentPreset.read',
'agentPreset.write',
'agentPreset.copy',
'agentPreset.openDocument',
'agentPreset.remove',
'host.pickDirectory',
'host.openPath',
+6 -4
View File
@@ -169,15 +169,17 @@ export class FakeApiClient implements IApiClient {
}
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))),
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: 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,
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
}))),
write: (payload: { agentPreset: string }) =>
this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
copy: (payload: { agentPreset: string }) =>
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
openDocument: (payload: { agentPreset: string }) =>
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
remove: (payload: { agentPreset: string }) =>
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
}
@@ -160,8 +160,9 @@ describe('connection node half', () => {
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.discoverModels',
// A composition names the plugins a session runs: reading one is
// reconnaissance and writing one is arbitrary capability.
'agentPreset.read', 'agentPreset.write', 'agentPreset.remove',
// reconnaissance, and copy/remove/openDocument manage the roster and
// drive the host desktop.
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
]) {
const denied = fakeResponse()
await routes[0]!.handler(
@@ -455,7 +456,7 @@ 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.read', 'agentPreset.write', 'agentPreset.remove',
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
]) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
}
+6 -4
View File
@@ -204,15 +204,17 @@ export class FakeApiClient implements IApiClient {
}
readonly agentPresets: IApiClient['agentPresets'] = {
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))),
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: 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,
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
}))),
write: (payload: { agentPreset: string }) =>
this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
copy: (payload: { agentPreset: string }) =>
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
openDocument: (payload: { agentPreset: string }) =>
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
remove: (payload: { agentPreset: string }) =>
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
}
@@ -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: c9aa370ebb7612cdc18bbe3d63c85cbb7097f69b
README.zh.md: 707745c547f2172abb4d11cda09f3de1c5c1ec78
README.md: c43fbff7846153911d0dc5bd85935470b35dcbff
README.zh.md: 1fca73a5efe873cd92afb9881fbc41d5b3b42d43
+12 -10
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that authors the compositions themselves.
The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files.
## Why it is a new-session preference
@@ -30,21 +30,23 @@ The row re-reads on `settings/changed` for its own namespace and on `connection/
## The management section
A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as rows, and one composition open in a YAML editor at a time.
A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as cards, a copy dialog as the only way a preset is created, and a read-only viewer over the shipped compositions.
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. **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from; **New preset** starts blank, since copying is already offered on the row being copied and a composition nobody named is text the author has to recognise as unwanted before deleting it.
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
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.
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: to start from the smallest skeleton, duplicate 极简模式 (31 lines against standard's 233).
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.
The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a copy never overwrites. Both checks are conveniences: the host re-applies them and its answer is what the dialog reports on failure.
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
`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.
`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, 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 chip, the label, 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.
A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, 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 shipped compositions still open in the viewer, but every copy action is disabled with the reason as its tooltip rather than offering a dialog whose create always fails.
## Model Experience
@@ -56,6 +58,6 @@ No direct invalidation. Changing the default never touches a running session's p
## Known Limitations and Deferred Work
- **A preset without metadata is listed by id** — display text is optional, and a preset that publishes none (every preset authored by duplicating another starts that way) shows its directory name.
- **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.
- **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source.
- **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself.
- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/changed`, and `connection/reset`, not on every disk edit.
+12 -10
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于创作组装本身
agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口
## 为什么它是"新建会话"的偏好设置
@@ -30,21 +30,23 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
## 管理分区
第四个表层,独立的设置页(`settings.section`id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以呈现,同一时刻有一份组装在 YAML 编辑器中打开
第四个表层,独立的设置页(`settings.section`id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以卡片呈现,复制对话框是创建 preset 的唯一入口,随附组装则在只读查看器中展示
随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。**复制**复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。**新建 preset** 则从空白开始——复制这件事已经由被复制那一行自己提供,而一份没人指名的组装,只会让作者先认出它不是自己想要的、再把它删掉
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮
id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——新建若落在已存在的名称上,就会覆盖用户从未打开过的 preset。这两项检查只是便利:宿主会连同组装的形状一起重新校验,失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存;引用了不存在插件的组装,会在下一个选择它的会话处失败
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:想从最小的骨架开始,就复制极简模式(31 行,对照 standard 的 233 行)
删除会移除该文件。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件
对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
`agentPreset.read``write``remove``select` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力`agentPreset.list` 不在其中——它携带 id信任级别,而局域网客户端的选择器需要它。
`agentPreset.read``copy``openDocument``remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面`agentPreset.list` 不在其中——它携带 id信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。
## 何时不显示这些表层
未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮
未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:随附组装仍可在查看器中打开,但每个复制操作都被禁用并以原因作提示,而不是给出一个创建必然失败的对话框
## Model Experience
@@ -56,6 +58,6 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre
## Known Limitations and Deferred Work
- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未发布任何展示文本的 preset(每个由复制他人而来的 preset 起初都是如此)显示的是它的目录名
- **编辑器是纯文本域** —— 没有 YAML 语法高亮、折叠或 schema 补全;保存时宿主的形状检查是唯一的校验
- **保存的组装不会被挂载** —— 能解析但引用了缺失插件的 preset 会被接受,并在下一个选择它的会话处失败
- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样
- **展示的路径是文本,不是链接** —— 宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置
- **组装编辑对页面不可见** —— 文件在浏览器之外编辑,传输层不广播文件变动,因此名单只在自身操作、`settings/changed``connection/reset` 时重读,而非每次磁盘编辑
@@ -6,31 +6,6 @@
color: var(--dsw-alias-label-primary);
}
/* Editing form: take the settings column's whole height so the composition
gets the space the panel has, instead of a fixed-row box with an empty band
under it. The list keeps the default flow — a card grid pinned to the top
reads as a list, while a stretched one would drift its rows apart. */
.sectionFill {
height: 100%;
}
.sectionFill .editor {
flex: 1;
min-height: 0;
}
.sectionFill .codeField {
flex: 1;
min-height: 0;
}
.sectionFill .codeField .code {
flex: 1;
min-height: 160px;
/* The drag handle would fight the flex height it is nested in. */
resize: none;
}
.title {
margin: 0;
font-size: 18px;
@@ -43,17 +18,6 @@
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);
}
/* Cards, not rows: a preset is a thing you pick, and the description is the
part that tells them apart — a row would bury it beside the actions. */
.group {
@@ -199,6 +163,11 @@
align-items: center;
}
.iconButton:disabled {
opacity: 0.4;
cursor: default;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
@@ -237,6 +206,28 @@
color: var(--dsw-alias-state-error-primary);
}
/* Where the host has no desktop opener, the row answers with the directory
itself — text to copy, not a control that would spawn into nothing. */
.revealedPath {
margin: 0;
padding: 6px 16px 10px;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
display: flex;
gap: 6px;
align-items: baseline;
}
.revealedPath code {
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
color: var(--dsw-alias-label-secondary);
user-select: all;
overflow-wrap: anywhere;
}
.revealedPathLabel {
white-space: nowrap;
}
.secondaryButton {
border: none;
@@ -254,18 +245,11 @@
background: var(--dsw-alias-bg-layer-1);
}
.secondaryButton:disabled,
.addButton:disabled {
.secondaryButton:disabled {
opacity: 0.5;
cursor: default;
}
.editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
@@ -278,8 +262,7 @@
color: var(--dsw-alias-label-secondary);
}
.input,
.code {
.input {
box-sizing: border-box;
padding: 9px 12px;
border: 1px solid var(--dsw-alias-border-l2);
@@ -290,18 +273,7 @@
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 {
.input:focus {
outline: none;
border-color: var(--dsw-alias-brand-primary);
}
@@ -310,67 +282,35 @@
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);
.dialog {
width: min(560px, 100%);
}
.dialogFields {
display: flex;
flex-direction: column;
gap: 12px;
}
/* A shipped composition can be long; the dialog scrolls it rather than grow. */
.viewerCode {
margin: 0;
padding: 12px;
max-height: min(52vh, 480px);
overflow: auto;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-secondary);
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 12.5px;
line-height: 1.5;
white-space: pre;
tab-size: 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;
}
.editorBar {
display: flex;
align-items: baseline;
gap: 12px;
padding-bottom: 4px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.backButton {
appearance: none;
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 13px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.backButton:hover {
color: var(--dsw-alias-label-primary);
}
.backButton:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 2px;
border-radius: 4px;
}
.editorTitle {
font-size: 14px;
font-weight: 600;
}
.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;
@@ -1,21 +1,23 @@
/**
* Agent-presets settings section: the roster as rows, and one composition
* open in a YAML editor at a time.
* Agent-presets settings section: the roster as cards, a copy dialog as the
* only way a preset is created, and a read-only viewer over the shipped
* compositions.
*
* 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.
* The browser edits no composition text — a shipped preset opens read-only to
* be READ (it is the known-good composition a copy starts from), and a custom
* preset is edited in its own files, which is what the location action leads
* to. 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, IconBrowseOutline16, IconCopyOutline16, IconEditOutline16, IconTrashOutline16, Modal,
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpen16, IconTrashOutline16, 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 { draftBlocker, type AgentPresetSectionState } from './section-store.ts'
import type { AgentPresetSettingsKey } from './locales.ts'
import css from './AgentPresetSection.module.css'
@@ -27,22 +29,22 @@ export interface AgentPresetSectionInjected {
}
/** 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
/** Rename the draft. */
setName: (name: string) => void
/** Replace the draft's description. */
setDescription: (description: string) => void
/** Save the open draft. */
save: () => Promise<void>
/** Open one shipped preset's composition in the read-only viewer. */
view: (id: string) => Promise<void>
/** Close the read-only viewer. */
closeView: () => void
/** Open the copy dialog over one preset. */
beginCopy: (from: string) => void
/** Close the copy dialog, discarding the draft. */
cancelCopy: () => void
/** Name the preset the copy creates. */
setCopyId: (id: string) => void
/** Name the copy's display name. */
setCopyName: (name: string) => void
/** Submit the copy. */
confirmCopy: () => Promise<void>
/** Open one preset's directory, or reveal its path where there is no desktop. */
openLocation: (id: string) => Promise<void>
/** Ask for delete confirmation, or dismiss it with null. */
confirmDelete: (id: string | null) => void
/** Delete the preset awaiting confirmation. */
@@ -57,93 +59,73 @@ export type AgentPresetSectionProps =
& 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>
/** Copy-dialog sub-view props: the draft plus the actions that mutate it. */
interface CopyDialogProps {
state: AgentPresetSectionState
t: (key: AgentPresetSettingsKey) => string
actions: Pick<AgentPresetSectionInjected,
'close' | 'save' | 'setContent' | 'setDescription' | 'setId' | 'setName'>
'cancelCopy' | 'confirmCopy' | 'setCopyId' | 'setCopyName'>
}
function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
const message = draft.error ?? (blocker === undefined ? null : t(blocker))
function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
const draft = state.copy
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker))
return (
<div className={css.editor}>
{draft.creating
? (
<label className={css.field}>
<span className={css.fieldLabel}>{t('presetId')}</span>
<input
className={css.input}
value={draft.id}
autoFocus
spellCheck={false}
placeholder={t('presetIdPlaceholder')}
onChange={(event) => { actions.setId(event.target.value) }}
/>
{draft.source === undefined
? null
: <span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>}
</label>
)
: null}
{draft.writable
? (
<>
<Modal
open={draft !== null}
onClose={() => { actions.cancelCopy() }}
title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`}
closeLabel={t('close')}
description={t('copyIntro')}
className={css.dialog as string}
footer={(
<>
<Button
variant="outline"
disabled={draft?.saving === true}
onClick={() => { actions.cancelCopy() }}
>
{t('cancel')}
</Button>
<Button
disabled={draft === null || draft.saving || blocker !== undefined}
onClick={() => { void actions.confirmCopy() }}
>
{draft?.saving === true ? t('creating') : t('create')}
</Button>
</>
)}
>
{draft === null
? null
: (
<div className={css.dialogFields}>
<label className={css.field}>
<span className={css.fieldLabel}>{t('presetId')}</span>
<input
className={css.input}
value={draft.id}
autoFocus
spellCheck={false}
placeholder={t('presetIdPlaceholder')}
onChange={(event) => { actions.setCopyId(event.target.value) }}
/>
</label>
<label className={css.field}>
<span className={css.fieldLabel}>{t('displayName')}</span>
<input
className={css.input}
value={draft.name}
spellCheck={false}
placeholder={draft.id === '' ? t('displayNamePlaceholder') : draft.id}
onChange={(event) => { actions.setName(event.target.value) }}
placeholder={t('displayNamePlaceholder')}
onChange={(event) => { actions.setCopyName(event.target.value) }}
/>
</label>
<label className={css.field}>
<span className={css.fieldLabel}>{t('displayDescription')}</span>
<input
className={css.input}
value={draft.description}
placeholder={t('displayDescriptionPlaceholder')}
onChange={(event) => { actions.setDescription(event.target.value) }}
/>
</label>
</>
)
: null}
{draft.writable ? null : <p className={css.notice}>{t('readOnlyNotice')}</p>}
<label className={`${css.field} ${css.codeField}`}>
<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>}
{/* Read-only has nothing to commit or abandon, and leaving is already the
back link above — a lone Close button would be a second way out. */}
{draft.writable
? (
<div className={css.editorActions}>
<Button variant="outline" disabled={draft.saving} onClick={() => { actions.close() }}>
{t('cancel')}
</Button>
<Button
disabled={draft.saving || blocker !== undefined}
onClick={() => { void actions.save() }}
>
{draft.saving ? t('saving') : t('save')}
</Button>
{message === null ? null : <p className={css.error} role="alert">{message}</p>}
</div>
)
: null}
</div>
)}
</Modal>
)
}
@@ -176,40 +158,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
)
}
const { draft } = state
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
// Editing replaces the list rather than hanging off the end of it: the form
// is tall, and a column of the card grid is far too narrow to hold it.
if (draft !== null) {
const editorActions = {
close: props.close,
save: props.save,
setContent: props.setContent,
setDescription: props.setDescription,
setId: props.setId,
setName: props.setName,
}
return (
<div className={`${css.section} ${css.sectionFill}`}>
<div className={css.editorBar}>
<button type="button" className={css.backButton} onClick={() => { props.close() }}>
{`${t('backToList')}`}
</button>
<span className={css.editorTitle}>
{draft.creating
? (draft.source === undefined ? t('newPreset') : `${t('newPreset')} · ${t('copyOf')} ${draft.source}`)
: `${draft.writable ? t('edit') : t('view')} · ${draft.name === '' ? draft.id : draft.name}`}
</span>
</div>
<Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
</div>
)
}
return (
<div className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
<p className={css.intro}>{t('sectionIntro')}</p>
<p className={css.intro}>{`${t('sectionIntro')} ${t('copyHint')}`}</p>
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
{([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => {
const group = state.rows.filter(row => row.trust === trust)
@@ -246,35 +198,50 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
<code className={css.cardId}>{row.id}</code>
</button>
<div className={css.cardFoot}>
<button
type="button"
className={css.iconButton}
data-tip={row.trust === 'user' ? t('edit') : t('view')}
aria-label={row.trust === 'user' ? t('edit') : t('view')}
onClick={() => { void props.open(row.id) }}
>
{row.trust === 'user' ? <IconEditOutline16 /> : <IconBrowseOutline16 />}
</button>
{state.authorable
{/* Shipped presets are the compositions a copy starts
from, so READING one is the point; a custom preset is
edited in its files instead, which the location action
leads to. */}
{row.trust === 'system'
? (
<button
type="button"
className={css.iconButton}
data-tip={t('duplicate')}
aria-label={t('duplicate')}
onClick={() => { void props.createFrom(row.id) }}
data-tip={t('view')}
aria-label={`${t('view')}: ${row.name ?? row.id}`}
onClick={() => { void props.view(row.id) }}
>
<IconCopyOutline16 />
<IconBrowseOutline16 />
</button>
)
: null}
: (
<button
type="button"
className={css.iconButton}
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${row.name ?? row.id}`}
onClick={() => { void props.openLocation(row.id) }}
>
<IconFolderOpen16 />
</button>
)}
<button
type="button"
className={css.iconButton}
disabled={!state.authorable}
data-tip={state.authorable ? t('duplicate') : t('duplicateUnavailable')}
aria-label={`${t('duplicate')}: ${row.name ?? row.id}`}
onClick={() => { props.beginCopy(row.id) }}
>
<IconCopyOutline16 />
</button>
{row.trust === 'user'
? (
<button
type="button"
className={`${css.iconButton} ${css.iconDanger}`}
data-tip={t('delete')}
aria-label={t('delete')}
aria-label={`${t('delete')}: ${row.name ?? row.id}`}
onClick={() => { props.confirmDelete(row.id) }}
>
<IconTrashOutline16 />
@@ -282,22 +249,47 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
)
: null}
</div>
{state.revealedPaths[row.id] === undefined
? null
: (
<p className={css.revealedPath}>
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
<code>{state.revealedPaths[row.id]}</code>
</p>
)}
</li>
))}
</ul>
</section>
)
})}
{(
<button
type="button"
className={css.addButton}
disabled={!state.authorable || state.rows.length === 0}
onClick={() => { void props.createFrom() }}
>
{`+ ${t('newPreset')}`}
</button>
)}
<CopyDialog
state={state}
t={t}
actions={{
cancelCopy: props.cancelCopy,
confirmCopy: props.confirmCopy,
setCopyId: props.setCopyId,
setCopyName: props.setCopyName,
}}
/>
<Modal
open={state.view !== null}
onClose={() => { props.closeView() }}
title={state.view === null ? '' : `${t('view')} · ${state.view.title}`}
closeLabel={t('close')}
description={t('composition')}
className={css.dialog as string}
footer={(
<Button variant="outline" autoFocus onClick={() => { props.closeView() }}>
{t('close')}
</Button>
)}
>
{state.view === null
? null
: <pre className={css.viewerCode}>{state.view.content}</pre>}
</Modal>
<Modal
open={state.pendingDelete !== null}
onClose={() => { props.confirmDelete(null) }}
@@ -2,7 +2,8 @@
* Agent-preset surface plugin, browser half — four surfaces over one roster:
* a General-settings row for the default preset, a chip on the new-session
* screen for the session about to start, a read-only label in the session
* header, and a settings section that authors the compositions themselves.
* header, and a settings section that manages the roster (copy, delete,
* default, and the way into a preset's own files).
*
* A running session keeps the composition it began with (the host refuses to
* adopt an existing session under a different preset). That is what splits
@@ -36,7 +37,7 @@ export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPrese
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts'
export {
draftBlocker, type AgentPresetSectionState, type PresetDraft, type PresetRow,
draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView,
} from './section-store.ts'
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
@@ -158,14 +159,14 @@ export function apply(ctx: ClientContext): void {
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) },
setName: (name: string) => { section.setName(name) },
setDescription: (description: string) => { section.setDescription(description) },
save: () => section.save(),
view: (id: string) => section.view(id),
closeView: () => { section.closeView() },
beginCopy: (from: string) => { section.beginCopy(from) },
cancelCopy: () => { section.cancelCopy() },
setCopyId: (id: string) => { section.setCopyId(id) },
setCopyName: (name: string) => { section.setCopyName(name) },
confirmCopy: () => section.confirmCopy(),
openLocation: (id: string) => section.openLocation(id),
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
remove: () => section.remove(),
makeDefault: (id: string) => section.makeDefault(id),
@@ -3,11 +3,13 @@
/** Locale keys these surfaces render. */
export type AgentPresetSettingsKey =
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint'
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
| 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
| 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup' | 'backToList'
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
| 'nav' | 'sectionIntro' | 'copyHint' | 'builtIn' | 'setDefault' | 'view'
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
| 'displayName' | 'displayNamePlaceholder'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
| 'composition' | 'cancel' | 'close' | 'retry'
| 'copyTitle' | 'copyIntro' | 'create' | 'creating'
| 'openLocation' | 'showLocation' | 'revealedPathLabel'
| 'idRequired' | 'idInvalid' | 'idTaken'
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
@@ -23,40 +25,42 @@ export const en: Record<AgentPresetSettingsKey, string> = {
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.',
+ 'Duplicate one to make your own, then edit its files directly.',
copyHint: 'To start from the smallest skeleton, duplicate Minimal.',
builtIn: 'Built-in',
defaultBadge: 'Default',
setDefault: 'Set as default',
edit: 'Edit',
view: 'View',
duplicate: 'Duplicate',
duplicateUnavailable: 'This deployment has no writable preset directory',
delete: 'Delete',
newPreset: 'New preset',
presetId: 'Identifier',
presetIdPlaceholder: 'my-agent',
displayName: 'Name',
displayNamePlaceholder: 'Shown in the picker',
displayDescription: 'Description',
displayDescriptionPlaceholder: 'One sentence on what this preset is for',
displayNamePlaceholder: 'Shown in the picker; defaults to the identifier',
inUse: 'In use',
backToList: 'All presets',
builtInGroup: 'Built-in',
customGroup: 'Custom',
noDescription: 'No description.',
copyOf: 'Copied from',
composition: 'Composition (agent.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',
copyTitle: 'Duplicate preset',
copyIntro:
'The whole preset is copied on this machine. The identifier becomes its directory name and cannot '
+ 'be changed later; everything else is edited in the preset\'s own files.',
create: 'Create',
creating: 'Creating…',
openLocation: 'Open folder',
showLocation: 'Show location',
revealedPathLabel: 'Preset files:',
idRequired: 'Give the preset an identifier.',
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
idTaken: 'A preset with this identifier already exists.',
deleteTitle: 'Delete this preset?',
deleteDescription:
'The composition file is deleted. Sessions already running on it keep working; new sessions cannot select it.',
'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.',
deleteConfirm: 'Delete',
deleting: 'Deleting…',
}
@@ -71,39 +75,39 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
seatHint: '即将开始的这个会话所用的 Agent 预设',
headerHint: '本会话运行的 Agent 预设,开始时即固定',
nav: 'Agent 预设',
sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。内置预设只读;复制一份即可改成自己的。',
sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份即可改成自己的,之后直接编辑它的文件。',
copyHint: '想从最小的骨架开始,就复制「极简模式」。',
builtIn: '内置',
defaultBadge: '默认',
setDefault: '设为默认',
edit: '编辑',
view: '查看',
duplicate: '复制',
duplicateUnavailable: '此部署未配置可写的预设目录',
delete: '删除',
newPreset: '新建预设',
presetId: '标识符',
presetIdPlaceholder: 'my-agent',
displayName: '名称',
displayNamePlaceholder: '选择器中显示的名字',
displayDescription: '描述',
displayDescriptionPlaceholder: '一句话说明这个预设做什么',
displayNamePlaceholder: '选择器中显示的名字,缺省用标识符',
inUse: '当前使用',
backToList: '全部预设',
builtInGroup: '内置',
customGroup: '自定义',
noDescription: '暂无描述。',
copyOf: '复制自',
composition: '组装(agent.cordis.yml',
readOnlyNotice: '该预设随部署提供,不可编辑。复制一份即可改成自己的。',
save: '保存',
saving: '正在保存…',
cancel: '取消',
close: '关闭',
retry: '重试',
idRequired: '请填写预设名称。',
copyTitle: '复制预设',
copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。',
create: '创建',
creating: '正在创建…',
openLocation: '打开目录',
showLocation: '查看路径',
revealedPathLabel: '预设文件:',
idRequired: '请填写标识符。',
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
idTaken: '该标识符已被占用。',
deleteTitle: '删除该预设?',
deleteDescription: '组装文件将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
deleteConfirm: '删除',
deleting: '正在删除…',
}
@@ -1,11 +1,17 @@
/**
* Agent-preset management controller: the roster as a list, and one
* composition open in the editor at a time.
* Agent-preset management controller: the roster as a list, a copy dialog as
* the only way a preset is created, and a read-only viewer over the shipped
* compositions.
*
* The browser edits no composition text. A new preset is a host-side copy of
* an existing one (`{ from, id, name? }` is all that crosses the wire), and
* everything after creation happens in the preset's own files — which is why
* the page's other job is getting the user TO those files: open the directory
* where the host has a desktop, show its path where it does not.
*
* 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.
* wire and the page re-reads the roster afterwards, because a copy changes
* more than the row it targeted.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
@@ -29,81 +35,92 @@ export interface PresetRow {
isDefault: boolean
}
/** The composition currently open in the editor. */
export interface PresetDraft {
/** Preset the draft saves to; empty until a new one is named. */
/** The copy dialog: a new id and optional display name over a fixed source. */
export interface CopyDraft {
/** The preset being copied. */
from: string
/** Display name of the source, for the dialog title. */
fromTitle: string
/** New preset id being typed; the directory name, so it is required. */
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. Absent on a preset started
* blank, which is a copy of nothing.
*/
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
/** Display name being edited; empty means the picker falls back to the id. */
/** Display name being typed; empty falls back to the id. */
name: string
/** Description being edited. */
description: string
/** The last save failure, cleared by the next edit. */
/** Whether the copy is in flight. */
saving: boolean
/** The last copy failure, cleared by the next edit. */
error: string | null
}
/** The read-only composition viewer over one shipped preset. */
export interface PresetView {
/** The preset whose composition is shown. */
id: string
/** Display name, for the dialog title. */
title: string
/** Composition text exactly as stored. */
content: string
}
/** Page snapshot. */
export interface AgentPresetSectionState {
status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error'
/** Whole-load failure text; a save failure stays on the draft. */
/** Whole-load failure text; a copy failure stays on the dialog. */
error: string | null
/** Whether the deployment configures a root new presets can be written to. */
authorable: boolean
/** Whether the host can open a preset directory on a native desktop. */
hasDocument: 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 open copy dialog, or null. */
copy: CopyDraft | null
/** The open read-only viewer, or null. */
view: PresetView | null
/** The preset awaiting delete confirmation. */
pendingDelete: string | null
/** Whether a delete is in flight. */
deleting: boolean
/**
* Preset directories shown as text because the host has no desktop opener
* — the answer `openDocument` gives instead of opening.
*/
revealedPaths: Readonly<Record<string, string>>
}
const INITIAL: AgentPresetSectionState = {
status: 'idle',
error: null,
authorable: false,
hasDocument: false,
rows: [],
draft: null,
copy: null,
view: null,
pendingDelete: null,
deleting: false,
revealedPaths: {},
}
/**
* 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.
* Why this copy cannot be submitted yet, as a locale key, or undefined when
* it can. Client-side only: the host re-checks the id and its answer is what
* the dialog reports on failure.
* @param draft - the open copy dialog.
* @param rows - the roster, for the collision check.
* @returns the blocking reason's locale key, or undefined when saveable.
* @returns the blocking reason's locale key, or undefined when submittable.
*/
export function draftBlocker(
draft: PresetDraft,
draft: CopyDraft,
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.
// A copy never overwrites: landing on a name already in use would replace
// 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. */
/** Reads the roster and drives the copy dialog, viewer, and location reveals. */
export class AgentPresetSectionController {
/** Page snapshot the renderer subscribes to. */
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
@@ -113,9 +130,9 @@ export class AgentPresetSectionController {
/**
* Called after this page changes the roster DIRECTORY, so the other
* surfaces reading the same roster re-read it. A settings field moving is
* already announced by the host through `settings/changed`; a file written
* or deleted here is not, and the new-session chip has no other way to
* learn a preset it should offer now exists.
* already announced by the host through `settings/changed`; a directory
* copied or deleted here is not, and the new-session chip has no other
* way to learn a preset it should offer now exists.
*/
private readonly rosterChanged: () => void = () => {},
) {}
@@ -124,10 +141,10 @@ export class AgentPresetSectionController {
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 } })
private patchCopy(patch: Partial<CopyDraft>): void {
const { copy } = this.store.getSnapshot()
if (copy === null) return
this.set({ copy: { ...copy, ...patch } })
}
/**
@@ -139,152 +156,136 @@ export class AgentPresetSectionController {
async load(): Promise<void> {
const roster = await beginRosterRead(this.api, this.store)
if (roster === undefined) return
const { presets, authorable } = roster
const { presets, authorable, hasDocument } = roster
if (presets.length === 0) {
this.set({ status: 'unavailable', rows: [], authorable, draft: null })
// Nothing to manage leaves nothing to keep a dialog open over.
this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null })
return
}
this.set({ status: 'ready', error: null, authorable, rows: presets.map(preset => ({ ...preset })) })
// A reveal outlives a reload but not its preset: a path for a row the
// roster no longer lists would be a claim about a directory that is gone.
const revealed = this.store.getSnapshot().revealedPaths
const kept = Object.fromEntries(
Object.entries(revealed).filter(([id]) => presets.some(preset => preset.id === id)))
this.set({
status: 'ready',
error: null,
authorable,
hasDocument,
rows: presets.map(preset => ({ ...preset })),
revealedPaths: kept,
})
}
/**
* 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.
* Open one shipped preset's composition in the read-only viewer.
* @param id - the preset to view.
* @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 new, unnamed preset. With no argument it starts blank; copying is
* its own action, offered on the row being copied, so the two arrive at the
* same editor by the route the author actually chose. Starting from some
* preset nobody named would put text in the editor that the author has to
* recognise as unwanted before deleting it.
* @param from - the preset to copy, or undefined to start blank.
* @returns once the composition loaded or the failure is on the page.
*/
async createFrom(from?: string): Promise<void> {
if (from === undefined) {
this.set({
error: null,
draft: {
id: '',
creating: true,
content: '',
writable: true,
name: '',
description: '',
saving: false,
error: null,
},
})
return
}
await this.openDraft(from, true)
}
private async openDraft(source: string, creating: boolean): Promise<void> {
async view(id: string): Promise<void> {
this.set({ error: null })
try {
const response = await this.api.agentPresets.read({ agentPreset: source })
const response = await this.api.agentPresets.read({ agentPreset: id })
if (!response.result.ok) {
this.set({ error: response.result.error.message })
return
}
const { content, writable, name, description } = 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,
// A copy starts from the source's text but must be renamed, or two
// rows would present themselves identically.
name: creating ? '' : name ?? '',
description: description ?? '',
saving: false,
error: null,
},
})
const { name, content } = response.result.value
this.set({ view: { id, title: name ?? id, content } })
} catch (error) {
this.set({ error: messageOf(error) })
}
}
/** Close the editor, discarding whatever was typed. */
close(): void {
this.set({ draft: null })
/** Close the read-only viewer. */
closeView(): void {
this.set({ view: null })
}
/**
* Name the preset a new draft saves to.
* @param id - the id typed into the editor.
* Open the copy dialog over one preset.
* @param from - the preset the copy will start from.
*/
setId(id: string): void {
this.patchDraft({ id, error: null })
beginCopy(from: string): void {
const row = this.store.getSnapshot().rows.find(candidate => candidate.id === from)
this.set({
error: null,
copy: { from, fromTitle: row?.name ?? from, id: '', name: '', saving: false, error: null },
})
}
/** Close the copy dialog, discarding whatever was typed. */
cancelCopy(): void {
this.set({ copy: null })
}
/**
* Replace the draft's composition text.
* @param content - the text in the editor.
* Name the preset the copy creates.
* @param id - the id typed into the dialog.
*/
setContent(content: string): void {
this.patchDraft({ content, error: null })
setCopyId(id: string): void {
this.patchCopy({ id, error: null })
}
/**
* Rename the draft.
* @param name - the display name typed into the editor.
* Name the copy's display name.
* @param name - the display name typed into the dialog.
*/
setName(name: string): void {
this.patchDraft({ name, error: null })
setCopyName(name: string): void {
this.patchCopy({ name, error: null })
}
/**
* Replace the draft's description.
* @param description - the description typed into the editor.
* Submit the copy, re-read the roster, then take the user to the new
* preset's files — the directory opens where the host has a desktop, and
* its path appears on the new row where it does not.
* @returns once the copy settled and the page reflects it.
*/
setDescription(description: string): void {
this.patchDraft({ description, 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
async confirmCopy(): Promise<void> {
const draft = this.store.getSnapshot().copy
if (draft === null || draft.saving) return
if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return
this.patchDraft({ saving: true, error: null })
this.patchCopy({ saving: true, error: null })
try {
const response = await this.api.agentPresets.write({
const name = draft.name.trim()
const response = await this.api.agentPresets.copy({
from: draft.from,
agentPreset: draft.id,
content: draft.content,
name: draft.name,
description: draft.description,
...name === '' ? {} : { name },
})
if (!response.result.ok) {
this.patchDraft({ saving: false, error: response.result.error.message })
this.patchCopy({ saving: false, error: response.result.error.message })
return
}
this.set({ draft: null })
this.set({ copy: null })
await this.load()
this.rosterChanged()
// A preset is its files from here on (the dialog collected nothing
// else), so landing in them is the completion, not a follow-up.
await this.openLocation(draft.id)
} catch (error) {
this.patchDraft({ saving: false, error: messageOf(error) })
this.patchCopy({ saving: false, error: messageOf(error) })
}
}
/**
* Open one preset's directory on the host desktop, or reveal its path on
* the row where the deployment has no opener to hand it to.
* @param id - the preset whose files the user wants.
* @returns once the host answered and the page reflects it.
*/
async openLocation(id: string): Promise<void> {
try {
const response = await this.api.agentPresets.openDocument({ agentPreset: id })
if (!response.result.ok) {
this.set({ error: response.result.error.message })
return
}
if (response.result.value.opened) return
const { path } = response.result.value
this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } })
} catch (error) {
this.set({ error: messageOf(error) })
}
}
@@ -305,7 +306,7 @@ export class AgentPresetSectionController {
* @returns once the delete settled and the page reflects it.
*/
async remove(): Promise<void> {
const { pendingDelete, deleting, draft } = this.store.getSnapshot()
const { pendingDelete, deleting } = this.store.getSnapshot()
if (pendingDelete === null || deleting) return
this.set({ deleting: true, error: null })
try {
@@ -314,12 +315,7 @@ export class AgentPresetSectionController {
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,
})
this.set({ deleting: false, pendingDelete: null })
await this.load()
this.rosterChanged()
} catch (error) {
@@ -81,6 +81,8 @@ export interface RosterValue {
presets: readonly RosterPreset[]
/** Whether this browser may author presets at all. */
authorable: boolean
/** Whether the host can open a preset directory on a native desktop. */
hasDocument: boolean
}
/** The roster, or the message to show in its place. */
@@ -27,10 +27,17 @@ usePinnedBrowserLanguages('zh-CN')
const ROSTER_ONE = {
rpcId: 'r',
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
result: {
ok: true as const,
value: {
presets: [{ id: 'standard', trust: 'system', isDefault: true }],
authorable: true,
hasDocument: true,
},
},
}
/** The roster after this browser authored one preset of its own. */
/** The roster after this browser copied one preset of its own. */
const ROSTER_AUTHORED = {
rpcId: 'r',
result: {
@@ -41,6 +48,7 @@ const ROSTER_AUTHORED = {
{ id: 'mine', trust: 'user', isDefault: false },
],
authorable: true,
hasDocument: true,
},
},
}
@@ -56,6 +64,7 @@ const ROSTER_MOVED = {
{ id: 'minimal', trust: 'system', isDefault: true },
],
authorable: true,
hasDocument: true,
},
},
}
@@ -76,15 +85,19 @@ async function bench() {
list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
read: () => Promise.resolve({
rpcId: 'r',
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '', writable: false } },
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } },
}),
write: (payload: { agentPreset: string }) => {
calls.push(`write:${payload.agentPreset}`)
copy: (payload: { from: string; agentPreset: string }) => {
calls.push(`copy:${payload.agentPreset}`)
// The host's roster now contains it, which is the whole point of the
// write and what every surface must converge on.
// copy and what every surface must converge on.
ROSTER = ROSTER_AUTHORED
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
},
openDocument: (payload: { agentPreset: string }) => {
calls.push(`openDocument:${payload.agentPreset}`)
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } })
},
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
select: (payload: { agentPreset: string }) => {
calls.push(`select:${payload.agentPreset}`)
@@ -195,23 +208,29 @@ describe('ui-agent-preset apply', () => {
})
it('routes the section actions to one controller', async () => {
const { ctx, slots } = await bench()
const { ctx, slots, calls } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
section.setId('mine')
section.setContent('- id: x\n')
section.setName('我的模式')
section.setDescription('只做检索。')
section.beginCopy('standard')
section.cancelCopy()
section.beginCopy('standard')
section.setCopyId('mine')
section.setCopyName('我的模式')
await section.confirmCopy()
await section.view('standard')
section.closeView()
section.confirmDelete('mine')
section.close()
await Promise.all([section.open('standard'), section.createFrom(), section.save(), section.remove()])
await Promise.all([section.openLocation('mine'), section.remove()])
// One controller behind every action: the delete the section confirmed is
// the one its remove() sees.
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(1)
// One controller behind every action: the copy the dialog named is the
// one the roster re-read reflects, and the delete the section confirmed
// is the one its remove() sees.
expect(calls).toContain('copy:mine')
expect(calls.filter(call => call === 'openDocument:mine').length).toBeGreaterThan(0)
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2)
})
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
@@ -329,14 +348,14 @@ describe('ui-agent-preset apply', () => {
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
await section.createFrom()
section.setId('mine')
section.setName('我的模式')
await section.save()
section.beginCopy('standard')
section.setCopyId('mine')
section.setCopyName('我的模式')
await section.confirmCopy()
// Authoring writes a file rather than a setting, so nothing on the wire
// announces it: a preset authored to be used must appear on the one screen
// that starts sessions, without a reload.
// Authoring copies a directory rather than writing a setting, so nothing
// on the wire announces it: a preset created to be used must appear on
// the one screen that starts sessions, without a reload.
await vi.waitFor(() => {
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine'])
})
@@ -1,16 +1,17 @@
/**
* 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.
* The agent-preset management controller: a copy dialog is the only way a
* preset is created, the shipped compositions open in a read-only viewer, and
* the way into a custom preset's files is the location action — opened on a
* desktop, revealed as a path where the host has none. Every mutation
* re-reads the roster because a copy changes 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'
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
interface FakePreset { trust: 'system' | 'user'; content: string }
interface FakePreset { trust: 'system' | 'user'; content: string; name?: string }
interface Recorded { method: string; payload: unknown }
interface FakeOptions {
@@ -20,16 +21,26 @@ interface FakeOptions {
failList?: string
/** Reject `read` with this message. */
failRead?: string
/** Reject `write` with this message. */
failWrite?: string
/** Reject `copy` with this message. */
failCopy?: string
/** Reject `openDocument` with this message. */
failOpen?: 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
/** Throw from `read`, as a dead transport does. */
throwRead?: boolean
/** Throw from `copy`, as a dead transport does. */
throwCopy?: boolean
/** Throw from `openDocument`, as a dead transport does. */
throwOpen?: boolean
/** Whether the deployment configures a writable root. */
authorable?: boolean
/** Whether the host can open a preset directory on a desktop. */
hasDocument?: boolean
/** Hold `remove` until this resolves, to observe the in-flight state. */
holdRemove?: Promise<void>
}
@@ -39,8 +50,8 @@ 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.
* A wire face over an in-memory preset store: copies land, so the roster the
* controller re-reads after a copy is the one the copy 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.
@@ -61,12 +72,15 @@ function fakeApi(
return ok({
presets: [...presets].map(([id, preset]) => ({
id, trust: preset.trust, isDefault: id === defaultId.id,
...preset.name === undefined ? {} : { name: preset.name },
})),
authorable: options.authorable ?? true,
hasDocument: options.hasDocument ?? true,
})
},
read: (payload: { agentPreset: string }) => {
record('read', payload)
if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
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 */
@@ -75,15 +89,31 @@ function fakeApi(
agentPreset: payload.agentPreset,
trust: preset.trust,
content: preset.content,
writable: preset.trust === 'user',
...preset.name === undefined ? {} : { name: preset.name },
})
},
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 })
copy: (payload: { from: string; agentPreset: string; name?: string }) => {
record('copy', payload)
if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
if (options.failCopy !== undefined) return fail(options.failCopy)
const source = presets.get(payload.from)
/* v8 ignore next -- every test copies a source the fake store holds */
if (source === undefined) return fail(`unknown preset ${payload.from}`)
presets.set(payload.agentPreset, {
trust: 'user',
content: source.content,
...payload.name === undefined ? {} : { name: payload.name },
})
return ok({ agentPreset: payload.agentPreset })
},
openDocument: (payload: { agentPreset: string }) => {
record('openDocument', payload)
if (options.throwOpen === true) return Promise.reject(new Error('socket closed'))
if (options.failOpen !== undefined) return fail(options.failOpen)
return (options.hasDocument ?? true)
? ok({ opened: true })
: ok({ opened: false, path: `/presets/${payload.agentPreset}` })
},
remove: async (payload: { agentPreset: string }) => {
record('remove', payload)
await options.holdRemove
@@ -106,7 +136,7 @@ function fakeApi(
function seed(): Map<string, FakePreset> {
return new Map<string, FakePreset>([
['standard', { trust: 'system', content: '- id: tool-bash\n' }],
['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }],
['mine', { trust: 'user', content: '- id: tool-read\n' }],
])
}
@@ -115,466 +145,436 @@ function harness(options: FakeOptions = {}) {
const presets = seed()
const defaultId = { id: 'standard' }
const calls: Recorded[] = []
let rosterChanges = 0
const controller = new AgentPresetSectionController(
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
() => { rosterChanges += 1 },
)
return { controller, presets, defaultId, calls }
return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
}
function draftOf(controller: AgentPresetSectionController): PresetDraft {
const { draft } = controller.store.getSnapshot()
if (draft === null) throw new Error('expected an open draft')
return draft
function copyOf(controller: AgentPresetSectionController): CopyDraft {
const { copy } = controller.store.getSnapshot()
if (copy === null) throw new Error('expected an open copy dialog')
return copy
}
describe('loading the roster', () => {
it('reports the presets, their trust, the default, and whether authoring is possible', async () => {
const { controller } = harness()
it('maps the roster onto rows with the capability flags', async () => {
const { controller } = harness({ authorable: true, hasDocument: false })
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 },
])
expect(state.hasDocument).toBe(false)
expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' })
})
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 }))
it('reports an empty roster as unavailable, not as an error', async () => {
const { controller, presets } = harness()
presets.clear()
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 () => {
it('keeps one load in flight rather than stacking reads', async () => {
const { controller, calls } = harness()
await Promise.all([controller.load(), controller.load()])
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
})
it('surfaces a refusal as the page error', async () => {
const { controller } = harness({ failList: 'not for you' })
await controller.load()
const state = controller.store.getSnapshot()
expect(state.status).toBe('error')
expect(state.error).toBe('not for you')
})
it('folds a dead transport into the same error surface', async () => {
const { controller } = harness({ throwList: true })
await controller.load()
expect(controller.store.getSnapshot().status).toBe('error')
expect(controller.store.getSnapshot().error).toContain('socket closed')
})
})
describe('opening a composition', () => {
it('opens a locally authored preset for editing', async () => {
describe('the read-only viewer', () => {
it('opens a shipped composition under its display name', async () => {
const { controller } = harness()
await controller.load()
await controller.open('mine')
await controller.view('standard')
expect(draftOf(controller)).toMatchObject({
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n', writable: true,
expect(controller.store.getSnapshot().view).toEqual({
id: 'standard', title: '标准模式', content: '- id: tool-bash\n',
})
})
it('opens a shipped preset read-only', async () => {
it('falls back to the id when the preset published no name', async () => {
const { controller } = harness()
await controller.load()
await controller.open('standard')
await controller.view('mine')
// 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' })
expect(controller.store.getSnapshot().view?.title).toBe('mine')
})
it('surfaces a rejected read on the page rather than opening an empty editor', async () => {
const { controller } = harness({ failRead: 'permission denied' })
it('closes without touching the list', async () => {
const { controller } = harness()
await controller.load()
await controller.view('standard')
controller.closeView()
expect(controller.store.getSnapshot().view).toBeNull()
expect(controller.store.getSnapshot().rows).toHaveLength(2)
})
it('puts a read refusal on the page rather than opening empty', async () => {
const { controller } = harness({ failRead: 'no peeking' })
await controller.load()
await controller.open('mine')
await controller.view('standard')
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'permission denied' })
expect(controller.store.getSnapshot().view).toBeNull()
expect(controller.store.getSnapshot().error).toBe('no peeking')
})
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')) },
it('folds a dead transport into the same error surface', async () => {
const { controller } = harness({ throwRead: true })
await controller.load()
await controller.view('standard')
expect(controller.store.getSnapshot().error).toContain('socket closed')
})
})
describe('the copy dialog', () => {
it('opens over the source with its display name in the title', async () => {
const { controller } = harness()
await controller.load()
controller.beginCopy('standard')
expect(copyOf(controller)).toMatchObject({
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false,
})
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('starts blank when no source is named', async () => {
const { controller, calls } = harness()
await controller.load()
const before = calls.length
await controller.createFrom()
// Copying is its own action on the row being copied, so this one is a copy
// of nothing: no source to name, and no read to make.
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, writable: true, content: '' })
expect(draftOf(controller).source).toBeUndefined()
expect(calls).toHaveLength(before)
})
it('copies a named preset', async () => {
it('falls back to the source id when it published no name', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom('mine')
controller.beginCopy('mine')
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
expect(copyOf(controller).fromTitle).toBe('mine')
})
it('opens the blank editor without the roster, which it no longer reads', async () => {
it('cancel discards whatever was typed', async () => {
const { controller } = harness()
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('half-typed')
controller.cancelCopy()
expect(controller.store.getSnapshot().copy).toBeNull()
})
it('ignores field edits and submits with no dialog open', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.createFrom()
controller.setCopyId('typed-into-nothing')
controller.setCopyName('nameless')
await controller.confirmCopy()
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, content: '' })
expect(calls).toHaveLength(0)
expect(controller.store.getSnapshot().copy).toBeNull()
expect(calls.some(call => call.method === 'copy')).toBe(false)
})
it('typing clears the previous failure', async () => {
const { controller } = harness({ failCopy: 'disk full' })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(copyOf(controller).error).toBe('disk full')
controller.setCopyName('renamed')
expect(copyOf(controller).error).toBeNull()
})
})
describe('the save blocker', () => {
const base: PresetDraft = {
id: '', source: 'standard', creating: true, content: '', writable: true,
name: '', description: '', saving: false, error: null,
}
const rows: readonly PresetRow[] = [{ id: 'mine', trust: 'user', isDefault: false }]
describe('the copy blocker', () => {
const rows: PresetRow[] = [
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'mine', trust: 'user', isDefault: false },
]
const draft = (id: string): CopyDraft =>
({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null })
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()
it('requires an id, a containable shape, and a free name', () => {
expect(draftBlocker(draft(''), rows)).toBe('idRequired')
expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid')
expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid')
expect(draftBlocker(draft('mine'), rows)).toBe('idTaken')
expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined()
})
})
describe('saving', () => {
it('creates the preset and re-reads the roster', async () => {
const { controller, presets } = harness()
describe('submitting a copy', () => {
it('copies, re-reads the roster, announces the change, and opens the files', async () => {
const { controller, calls, rosterChanges } = harness()
await controller.load()
await controller.createFrom()
controller.setId('my-agent')
controller.setContent('- id: tool-web-search\n')
controller.beginCopy('standard')
controller.setCopyId('my-copy')
controller.setCopyName('我的模式')
await controller.save()
await controller.confirmCopy()
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')
const state = controller.store.getSnapshot()
expect(state.copy).toBeNull()
expect(state.rows.map(row => row.id)).toContain('my-copy')
expect(rosterChanges()).toBe(1)
expect(calls.find(call => call.method === 'copy')?.payload)
.toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' })
// A preset is its files from here on, so landing in them completes the
// copy rather than following it.
expect(calls.find(call => call.method === 'openDocument')?.payload)
.toEqual({ agentPreset: 'my-copy' })
})
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 () => {
it('omits an empty name so the copy falls back to its id', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.createFrom()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
controller.setCopyName(' ')
await controller.save()
await controller.confirmCopy()
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(controller.store.getSnapshot().draft).not.toBeNull()
expect(calls.find(call => call.method === 'copy')?.payload)
.toEqual({ from: 'standard', agentPreset: 'my-copy' })
})
it('refuses to write a read-only draft', async () => {
it('reveals the new directory as text where the host has no desktop', async () => {
const { controller } = harness({ hasDocument: false })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy')
})
it('keeps the dialog open with the refusal on it', async () => {
const { controller, rosterChanges } = harness({ failCopy: 'id already exists' })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' })
expect(rosterChanges()).toBe(0)
})
it('folds a dead transport into the dialog error', async () => {
const { controller } = harness({ throwCopy: true })
await controller.load()
controller.beginCopy('standard')
controller.setCopyId('my-copy')
await controller.confirmCopy()
expect(copyOf(controller).error).toContain('socket closed')
})
it('refuses to submit while blocked or already saving', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('standard')
controller.beginCopy('standard')
controller.setCopyId('mine')
await controller.save()
await controller.confirmCopy()
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(calls.some(call => call.method === 'copy')).toBe(false)
})
})
it('does nothing without an open draft', async () => {
describe('the location action', () => {
it('opens the directory and leaves the page alone on a desktop host', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.save()
await controller.openLocation('mine')
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' })
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
})
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('carries the display name and description through a save', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('mine')
controller.setName('我的模式')
controller.setDescription('只做检索。')
await controller.save()
expect(calls.find(call => call.method === 'write')?.payload)
.toMatchObject({ agentPreset: 'mine', name: '我的模式', description: '只做检索。' })
})
it('leaves a copy unnamed so two rows cannot present themselves alike', async () => {
const { controller } = harness()
it('reveals the path on the row where the host has none', async () => {
const { controller } = harness({ hasDocument: false })
await controller.load()
await controller.createFrom('mine')
await controller.openLocation('mine')
// The composition is copied verbatim; the display name is not.
expect(draftOf(controller)).toMatchObject({ id: '', name: '' })
expect(draftOf(controller).content).toBe('- id: tool-read\n')
expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' })
})
it('ignores an edit with no draft open', () => {
const { controller } = harness()
it('drops a revealed path once its preset leaves the roster', async () => {
const { controller, presets } = harness({ hasDocument: false })
await controller.load()
await controller.openLocation('mine')
presets.delete('mine')
controller.setId('x')
controller.setContent('y')
controller.setName('n')
controller.setDescription('d')
await controller.load()
expect(controller.store.getSnapshot().draft).toBeNull()
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
})
it('surfaces a refusal as the page error', async () => {
const { controller } = harness({ failOpen: 'not yours' })
await controller.load()
await controller.openLocation('mine')
expect(controller.store.getSnapshot().error).toBe('not yours')
})
it('folds a dead transport into the same error surface', async () => {
const { controller } = harness({ throwOpen: true })
await controller.load()
await controller.openLocation('mine')
expect(controller.store.getSnapshot().error).toContain('socket closed')
})
})
describe('deleting', () => {
it('deletes the confirmed preset and re-reads the roster', async () => {
const { controller, presets } = harness()
it('asks first, then deletes, re-reads, and announces the change', async () => {
const { controller, rosterChanges } = harness()
await controller.load()
controller.confirmDelete('mine')
expect(controller.store.getSnapshot().pendingDelete).toBe('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 })
const state = controller.store.getSnapshot()
expect(state.pendingDelete).toBeNull()
expect(state.rows.map(row => row.id)).not.toContain('mine')
expect(rosterChanges()).toBe(1)
})
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()])
controller.confirmDelete(null)
await controller.remove()
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine')
expect(calls.some(call => call.method === 'remove')).toBe(false)
})
it('ignores a second confirmation while one delete is in flight', async () => {
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const { controller, calls } = harness({ holdRemove: gate })
await controller.load()
controller.confirmDelete('mine')
const removal = controller.remove()
controller.confirmDelete('standard')
await controller.remove()
release()
await removal
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 }),
)
it('surfaces a refusal and clears the confirmation', async () => {
const { controller } = harness({ failRemove: 'shipped preset' })
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
await controller.remove()
expect(presets.has('mine')).toBe(false)
const state = controller.store.getSnapshot()
expect(state.error).toBe('shipped preset')
expect(state.pendingDelete).toBeNull()
expect(state.deleting).toBe(false)
})
it('folds a dead transport into the same error surface', async () => {
const { controller, presets } = harness()
await controller.load()
presets.clear()
const broken = new AgentPresetSectionController({
agentPresets: {
list: () => Promise.reject(new Error('gone')),
remove: () => Promise.reject(new Error('socket closed')),
},
settings: {},
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>)
broken.confirmDelete('mine')
await broken.remove()
expect(broken.store.getSnapshot().error).toContain('socket closed')
})
})
describe('a controller with no roster listener', () => {
it('completes a delete without anyone to notify', async () => {
// The rosterChanged callback is optional wiring, not a requirement: a
// page composed without sibling surfaces still deletes cleanly.
const presets = seed()
const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' }))
await alone.load()
alone.confirmDelete('mine')
await alone.remove()
expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine')
})
})
describe('the default preset', () => {
it('writes the settings field and re-reads the roster', async () => {
const { controller, calls, defaultId } = harness()
it('writes the setting and re-reads the roster', async () => {
const { controller, 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')
expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true)
})
it('reports a refused write and leaves the roster alone', async () => {
const { controller, defaultId } = harness({ failSettings: 'settings are read-only' })
it('surfaces a settings refusal as the page error', async () => {
const { controller } = harness({ failSettings: 'read-only settings' })
await controller.load()
await controller.makeDefault('mine')
expect(controller.store.getSnapshot().error).toBe('settings are read-only')
expect(defaultId.id).toBe('standard')
expect(controller.store.getSnapshot().error).toContain('read-only settings')
})
})
@@ -1,9 +1,9 @@
// @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.
* on its trust, a shipped composition opens in a read-only viewer, creation is
* a copy dialog that collects an id and an optional name, and the location
* action follows the host's desktop capability.
*/
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
@@ -12,7 +12,7 @@ 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 type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -21,13 +21,16 @@ const READY: AgentPresetSectionState = {
status: 'ready',
error: null,
authorable: true,
hasDocument: true,
rows: [
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
{ id: 'mine', trust: 'user', isDefault: false },
],
draft: null,
copy: null,
view: null,
pendingDelete: null,
deleting: false,
revealedPaths: {},
}
/**
@@ -39,14 +42,14 @@ 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(),
setName: vi.fn(),
setDescription: vi.fn(),
save: vi.fn(() => Promise.resolve()),
view: vi.fn(() => Promise.resolve()),
closeView: vi.fn(),
beginCopy: vi.fn(),
cancelCopy: vi.fn(),
setCopyId: vi.fn(),
setCopyName: vi.fn(),
confirmCopy: vi.fn(() => Promise.resolve()),
openLocation: vi.fn(() => Promise.resolve()),
confirmDelete: vi.fn(),
remove: vi.fn(() => Promise.resolve()),
makeDefault: vi.fn(() => Promise.resolve()),
@@ -105,8 +108,6 @@ describe('the preset list', () => {
// read-only, the other is the user's own.
expect(screen.getByRole('heading', { name: en.builtInGroup })).toBeTruthy()
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
expect(within(rowFor('standard')).getByText(en.builtIn)).toBeTruthy()
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
})
it('shows no group heading for a set nobody has', () => {
@@ -115,6 +116,14 @@ describe('the preset list', () => {
expect(screen.queryByRole('heading', { name: en.customGroup })).toBeNull()
})
it('leads with the guidance that creation starts from a copy', () => {
renderSection()
// The page has no create button: the intro is what tells a first-time
// reader that duplicating a built-in preset IS the way to make one.
expect(screen.getByText(new RegExp(en.copyHint))).toBeTruthy()
})
it('picks a preset by clicking its card, and the one in use is inert', () => {
const actions = renderSection()
@@ -127,27 +136,49 @@ describe('the preset list', () => {
expect(actions.makeDefault).not.toHaveBeenCalled()
})
it('offers Edit for a local preset and View for a shipped one', () => {
it('offers View on a shipped row and the location on a custom one', () => {
renderSection()
expect(within(rowFor('mine')).getByRole('button', { name: 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')).getByRole('button', { name: en.view })).toBeTruthy()
// A shipped preset is the composition a copy starts from — reading it is
// the point. A custom preset is edited in its files, so its row leads
// there instead; there is no editor for either.
const standard = rowFor('standard')
expect(within(standard).getByRole('button', { name: `${en.view}: 标准模式` })).toBeTruthy()
expect(within(standard).queryByRole('button', { name: `${en.openLocation}: 标准模式` })).toBeNull()
const mine = rowFor('mine')
expect(within(mine).getByRole('button', { name: `${en.openLocation}: mine` })).toBeTruthy()
expect(within(mine).queryByRole('button', { name: `${en.view}: mine` })).toBeNull()
})
it('offers Delete only for a locally authored preset', () => {
renderSection()
expect(within(rowFor('mine')).getByRole('button', { name: en.delete })).toBeTruthy()
expect(within(rowFor('standard')).queryByRole('button', { name: en.delete })).toBeNull()
expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy()
expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: 标准模式` })).toBeNull()
})
it('hides duplication and disables creation when nothing is writable', () => {
it('disables duplication when nothing is writable, and says why', () => {
renderSection({ authorable: false })
expect(screen.queryByRole('button', { name: en.duplicate })).toBeNull()
expect(screen.getByText(`+ ${en.newPreset}`)).toHaveProperty('disabled', true)
const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: 标准模式` })
expect(duplicate).toHaveProperty('disabled', true)
expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable)
})
it('labels the location by what it will do without a desktop', () => {
renderSection({ hasDocument: false })
expect(within(rowFor('mine')).getByRole('button', { name: `${en.showLocation}: mine` })).toBeTruthy()
})
it('shows a revealed directory on its row', () => {
renderSection({ revealedPaths: { mine: '/home/user/.dsh/.agent-presets/mine' } })
const mine = rowFor('mine')
expect(within(mine).getByText('/home/user/.dsh/.agent-presets/mine')).toBeTruthy()
expect(within(mine).getByText(en.revealedPathLabel)).toBeTruthy()
// The reveal belongs to its row alone.
expect(within(rowFor('standard')).queryByText(en.revealedPathLabel)).toBeNull()
})
it('routes the row actions to the controller', () => {
@@ -155,15 +186,14 @@ describe('the preset list', () => {
// The card body is the control that picks a preset.
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` }))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.edit }))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.duplicate }))
fireEvent.click(screen.getByText(`+ ${en.newPreset}`))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.openLocation}: mine` }))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.duplicate}: mine` }))
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: 标准模式` }))
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()
expect(actions.openLocation).toHaveBeenCalledWith('mine')
expect(actions.beginCopy).toHaveBeenCalledWith('mine')
expect(actions.view).toHaveBeenCalledWith('standard')
})
it('shows a page-level failure without hiding the list', () => {
@@ -194,126 +224,97 @@ describe('the preset list', () => {
})
})
describe('the composition editor', () => {
const draft = {
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n',
writable: true, name: '我的预设', description: '', saving: false, error: null,
describe('the copy dialog', () => {
const draft: CopyDraft = {
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, error: null,
}
it('opens a blank draft without naming a preset it came from', () => {
const { source: _copied, ...blank } = draft
renderSection({ draft: { ...blank, id: '', creating: true } })
it('names its source and collects only an id and a display name', () => {
const actions = renderSection({ copy: draft })
// "New preset" starts empty — copying is what the per-row Duplicate does,
// so a blank draft has no source to name and shows no copied-from hint.
expect(screen.getByText(en.newPreset)).toBeTruthy()
expect(screen.queryByText(new RegExp(en.copyOf))).toBeNull()
const dialog = screen.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} 标准模式`)
expect(within(dialog).getByText(en.copyIntro)).toBeTruthy()
fireEvent.change(within(dialog).getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } })
fireEvent.change(within(dialog).getByPlaceholderText(en.displayNamePlaceholder), { target: { value: '我的模式' } })
expect(actions.setCopyId).toHaveBeenCalledWith('my-agent')
expect(actions.setCopyName).toHaveBeenCalledWith('我的模式')
// Nothing else is collected: the description and the composition are
// edited in the preset's own files.
expect(within(dialog).queryByRole('textbox', { name: /description/i })).toBeNull()
})
it('replaces the list while editing, and returns to it', () => {
const actions = renderSection({ draft })
it('creates and cancels through the controller', () => {
const actions = renderSection({ copy: { ...draft, id: 'my-agent' } })
// The form is tall and a card column is ~268px: squeezing it into one is
// unusable, and hanging it off the end orphans it from the card it edits.
expect(screen.queryByRole('heading', { name: en.builtInGroup })).toBeNull()
const editor = screen.getByLabelText(en.composition)
expect(editor).toHaveProperty('value', '- id: tool-read\n')
fireEvent.change(editor, { target: { value: '- id: tool-edit\n' } })
fireEvent.click(screen.getByRole('button', { name: `${en.backToList}` }))
const dialog = screen.getByRole('dialog')
fireEvent.click(within(dialog).getByText(en.create))
fireEvent.click(within(dialog).getByText(en.cancel))
expect(actions.setContent).toHaveBeenCalledWith('- id: tool-edit\n')
expect(actions.close).toHaveBeenCalledTimes(1)
expect(actions.confirmCopy).toHaveBeenCalledTimes(1)
expect(actions.cancelCopy).toHaveBeenCalledTimes(1)
})
it('saves and cancels through the controller', () => {
const actions = renderSection({ draft })
it('blocks a copy the host would refuse, and says why', () => {
const actions = renderSection({ copy: { ...draft, id: 'Upper Case' } })
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.getByLabelText(en.composition)).toHaveProperty('readOnly', true)
expect(screen.getByText(en.readOnlyNotice)).toBeTruthy()
expect(screen.queryByText(en.save)).toBeNull()
// Nothing to commit or abandon, and the back link above already leaves —
// a lone Close button would be a second way out of the same screen.
expect(screen.queryByText(en.cancel)).toBeNull()
expect(screen.getByRole('button', { name: `${en.backToList}` })).toBeTruthy()
})
it('titles the panel by what it is doing', () => {
renderSection({ draft })
expect(screen.getByText(`${en.edit} · 我的预设`)).toBeTruthy()
cleanup()
// An unnamed draft falls back to what it was copied from.
renderSection({ draft: { ...draft, name: '' } })
expect(screen.getByText(`${en.edit} · mine`)).toBeTruthy()
cleanup()
renderSection({ draft: { ...draft, writable: false } })
expect(screen.getByText(`${en.view} · 我的预设`)).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.presetIdPlaceholder), { target: { value: 'my-agent' } })
expect(actions.setId).toHaveBeenCalledWith('my-agent')
})
it('edits the display name and description through the controller', () => {
const actions = renderSection({ draft })
fireEvent.change(screen.getByLabelText(en.displayName), { target: { value: '我的模式' } })
fireEvent.change(screen.getByLabelText(en.displayDescription), { target: { value: '只做检索。' } })
expect(actions.setName).toHaveBeenCalledWith('我的模式')
expect(actions.setDescription).toHaveBeenCalledWith('只做检索。')
})
it('offers no display fields on a read-only preset', () => {
renderSection({ draft: { ...draft, writable: false } })
// Nothing here can be saved, so an editable name would be a lie.
expect(screen.queryByLabelText(en.displayName)).toBeNull()
})
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))
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByRole('alert').textContent).toBe(en.idInvalid)
fireEvent.click(within(dialog).getByText(en.create))
// Disabled rather than round-tripping: the id is a directory name and the
// rule is the host's own.
expect(actions.save).not.toHaveBeenCalled()
expect(actions.confirmCopy).not.toHaveBeenCalled()
})
it('shows the host\'s refusal instead of the local blocker', () => {
renderSection({ draft: { ...draft, error: 'composition is not an entry list' } })
renderSection({ copy: { ...draft, id: 'my-agent', error: 'already exists' } })
expect(screen.getByRole('alert').textContent).toBe('composition is not an entry list')
expect(within(screen.getByRole('dialog')).getByRole('alert').textContent).toBe('already exists')
})
it('reports a copy in flight and blocks a second click', () => {
const actions = renderSection({ copy: { ...draft, id: 'my-agent', saving: true } })
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.creating))
expect(actions.confirmCopy).not.toHaveBeenCalled()
})
it('dismisses on Escape', () => {
const actions = renderSection({ copy: draft })
fireEvent.keyDown(document, { key: 'Escape' })
expect(actions.cancelCopy).toHaveBeenCalledTimes(1)
})
})
describe('the read-only viewer', () => {
it('shows the composition text under the preset\'s name', () => {
renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } })
const dialog = screen.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · 标准模式`)
expect(within(dialog).getByText(en.composition)).toBeTruthy()
expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n')
})
it('closes through the controller', () => {
const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } })
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.close))
expect(actions.closeView).toHaveBeenCalledTimes(1)
})
it('dismisses on Escape', () => {
const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } })
fireEvent.keyDown(document, { key: 'Escape' })
expect(actions.closeView).toHaveBeenCalledTimes(1)
})
})
@@ -321,7 +322,7 @@ describe('deleting a preset', () => {
it('asks before deleting', () => {
const actions = renderSection()
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.delete }))
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` }))
expect(actions.confirmDelete).toHaveBeenCalledWith('mine')
})
@@ -101,8 +101,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Read one preset\'s composition text.\n * @param id - the preset 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, metadata: PresetMetadata = {}): Promise<void>',
jsDoc: '/**\n * Create or replace a locally authored preset.\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 preset id, which becomes its directory name.\n * @param content - the composition text.\n * @param metadata - display name and description; clearing both removes the file.\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 copy(from: string, id: string, name?: string): Promise<void>',
jsDoc: '/**\n * Create a locally authored preset by copying an existing one whole.\n *\n * Copy is the only authoring write. Composition text never crosses this\n * seam: the source is named by id and its directory is copied as it stands,\n * so the copy is exactly as loadable as its source and authoring grants no\n * capability the roster did not already carry. The copy is NOT mounted to\n * validate — a source that mounts today yields a copy that mounts today.\n * @param from - the preset the copy starts from; shipped presets are the\n * primary source, so any trust is accepted.\n * @param id - the new preset\'s id, which becomes its directory name.\n * @param name - display name for the copy; absent falls back to the id.\n * @throws when the source is unknown, the id is unusable or already taken,\n * or the deployment configures no writable root.\n */',
},
{
signature: 'async remove(id: string): Promise<void>',
@@ -2269,10 +2269,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PrepareSessionOptions',
declaration: 'export type PrepareSessionOptions = (CreateSessionOptions & {\n readonly seedSource?: undefined;\n}) | RestoredSessionOptions;',
},
{
name: 'PresetMetadata',
declaration: 'export interface PresetMetadata {\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n}',
},
{
name: 'PresetOption',
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 9c5c85ec1ab413c7f12f8f8f222935229d259973
README.zh.md: ad1756bba5b5f618059a878d175a3213cbfaa3f9
README.md: 125ba12a64ae9c26e585b50dbe0cc5651febd474
README.zh.md: e9fe87ecad30a446745668bc6f2a35c7dfc96bc5
+1 -1
View File
@@ -48,7 +48,7 @@ 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. These three 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` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports `authorable`, whether the deployment configures a root a new preset could be written to at all.
`agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. 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 copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path.
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.
+1 -1
View File
@@ -48,7 +48,7 @@ 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` 抵达调用方。这个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力`list``select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告 `authorable`,即部署是否配置了可供写入新 preset 的根目录。
`agentPreset.read``copy``openDocument``remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid``remove` 对随附 preset 回答 `agent-preset-read-only``openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面`list``select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径
`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` 而不是做差分。
+49 -13
View File
@@ -5,7 +5,7 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -25,7 +25,7 @@ import {
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import {
InvalidCompositionError, InvalidPresetIdError, PresetMountError,
InvalidPresetIdError, PresetExistsError, PresetMountError,
PresetNotWritableError, resolveSessionPreset,
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
@@ -83,7 +83,7 @@ import {
hasApiRemoteSubagentOwner,
inspectApiRemoteSession,
} from '@deepseek-ai/dsh-api-remotes'
import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -415,6 +415,14 @@ export interface ApiProxyDefaults {
openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Native text-editor handoff; injectable for settings-document tests. */
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
/**
* Whether handing a path to the native opener can work at all — the
* `hasDocument` capability the preset roster reports, and the switch
* between opening a preset directory and answering its path as text.
* Absent, an injected `openPath` counts as openable and everything else
* falls back to platform detection ({@link canOpenNativePath}).
*/
canOpenPath?: () => boolean
}
/** The tool/call payload fields the presenter path reads. */
@@ -757,7 +765,7 @@ function presetError(agentPreset: string, error: unknown): RpcError {
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) {
if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
}
return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
@@ -1543,6 +1551,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return openTarget(request, path, signal, open)
}
/** Whether this deployment can hand a path to a native opener at all. */
function canOpenPaths(): boolean {
if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
// An injected opener is by definition usable; otherwise ask the platform.
return defaults.openPath !== undefined || canOpenNativePath()
}
/** Missing-service report shared by the credentials domain. */
function credentialsAbsent(): RpcError {
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
@@ -2614,7 +2629,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: [], authorable: false })
if (presets === undefined) return ok(request, { presets: [], authorable: false, hasDocument: false })
const defaultId = presets.defaultId
return ok(request, {
presets: (await presets.list()).map(preset => ({
@@ -2625,6 +2640,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
...preset.description === undefined ? {} : { description: preset.description },
})),
authorable: presets.authorable,
hasDocument: canOpenPaths(),
})
},
@@ -2682,7 +2698,8 @@ 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.
// reconnaissance, and copy/remove/openDocument manage the roster and
// drive the host desktop.
async read(request) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
@@ -2693,7 +2710,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
agentPreset: preset.id,
trust: preset.trust,
content: await presets.read(preset.id),
writable: preset.trust === 'user' && presets.authorable,
...preset.name === undefined ? {} : { name: preset.name },
...preset.description === undefined ? {} : { description: preset.description },
})
@@ -2702,21 +2718,41 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async write(request) {
const { agentPreset, content, name, description } = request.payload
async copy(request) {
const { from, agentPreset, name } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
await presets.write(agentPreset, content, {
...name === undefined ? {} : { name },
...description === undefined ? {} : { description },
})
await presets.copy(from, agentPreset, name)
return ok(request, { agentPreset })
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async openDocument(request, signal) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
const preset = await presets.resolve(agentPreset)
// Same line as copy/remove draw: the shipped install is not the
// user's to manage, and pointing an editor into it invites edits an
// upgrade will silently overwrite.
if (preset.trust !== 'user') {
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
}
// The id resolved against the Host's own roots is what selects the
// directory — no browser payload carries a path in either direction
// unless the deployment has no opener to hand it to.
const directory = dirname(preset.path)
if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory })
return await openPath(request, directory, signal)
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async remove(request) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
@@ -26,6 +26,7 @@ export const agentPresetListRequestSchema = z.object({
export const agentPresetListValueSchema = z.object({
presets: z.array(agentPresetEntrySchema),
authorable: z.boolean(),
hasDocument: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
/** agentPreset.select request payload. */
@@ -49,23 +50,32 @@ export const agentPresetReadValueSchema = z.object({
agentPreset: z.string(),
trust: z.union([z.literal('system'), z.literal('user')]),
content: z.string(),
writable: z.boolean(),
name: z.string().optional(),
description: z.string().optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
/** agentPreset.write request payload. */
export const agentPresetWriteRequestSchema = z.object({
/** agentPreset.copy request payload. */
export const agentPresetCopyRequestSchema = z.object({
from: z.string().min(1),
agentPreset: z.string().min(1),
content: z.string(),
name: z.string().optional(),
description: z.string().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.write'>>>
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.copy'>>>
/** agentPreset.write response value. */
export const agentPresetWriteValueSchema = z.object({
/** agentPreset.copy response value. */
export const agentPresetCopyValueSchema = z.object({
agentPreset: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.write'>>>
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.copy'>>>
/** agentPreset.openDocument request payload. */
export const agentPresetOpenDocumentRequestSchema = z.object({
agentPreset: z.string().min(1),
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.openDocument'>>>
/** agentPreset.openDocument response value. */
export const agentPresetOpenDocumentValueSchema = z.union([
z.object({ opened: z.literal(true) }),
z.object({ opened: z.literal(false), path: z.string() }),
]) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.openDocument'>>>
/** agentPreset.remove request payload. */
export const agentPresetRemoveRequestSchema = z.object({
+33 -15
View File
@@ -3,10 +3,10 @@
* 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.
* it. The authoring calls are privileged and loopback-pinned — a composition
* names the plugins a session runs, so reading one is reconnaissance, and
* although authoring is copy-only (no caller supplies composition text or a
* path), copying and deleting still rearrange what the deployment offers.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -45,11 +45,13 @@ export interface AgentPresetsApi {
* shipped ids.
* An empty roster means the deployment composes no presets at all, and
* 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.
* the deployment configures a root new presets can be written to, and
* `hasDocument` whether `openDocument` can hand a preset directory to a
* native opener — both deployment facts rather than per-preset ones, and
* neither exposes a Host path.
*/
list(request: RpcRequest<{}>):
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean }>>
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean; hasDocument: boolean }>>
/**
* Recompose one session's agent from a different preset.
@@ -63,29 +65,45 @@ export interface AgentPresetsApi {
Promise<RpcResponse<{ agentPreset: string }>>
/**
* Read one preset's composition text, for an editor.
* Read one preset's composition text, for the read-only viewer.
*
* Privileged: a composition names the plugins a session runs, so reading one
* is reconnaissance and writing one is arbitrary capability.
* Privileged: a composition names the plugins a session runs, so reading
* one is reconnaissance.
*/
read(request: RpcRequest<{ agentPreset: string }>):
Promise<RpcResponse<{
agentPreset: string
trust: 'system' | 'user'
content: string
writable: boolean
name?: string
description?: string
}>>
/**
* 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.
* Create a locally authored preset by copying an existing one whole.
*
* The only authoring write. No composition text and no path crosses the
* wire: `from` and `agentPreset` are ids the Host resolves against its own
* roots, so a copy is exactly as loadable as its source and grants nothing
* the roster did not already carry. The copy keeps the source's description
* (the file is the author's to edit afterwards) but not its name — `name`
* here or the id fallback is what distinguishes the rows.
*/
write(request: RpcRequest<{ agentPreset: string; content: string; name?: string; description?: string }>):
copy(request: RpcRequest<{ from: string; agentPreset: string; name?: string }>):
Promise<RpcResponse<{ agentPreset: string }>>
/**
* Hand one locally authored preset's DIRECTORY to the platform opener, for
* editing the files that are now the only composition editor. The request
* carries an id, never a path — the Host resolves it — so no browser
* payload can select an arbitrary filesystem target. Where the deployment
* has no native opener (`hasDocument: false` on `list`), the reply carries
* the resolved directory for the surface to show as text instead. Shipped
* presets are refused: their install is not the user's to manage.
*/
openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal):
Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>>
/** Delete a locally authored preset. Shipped presets are refused. */
remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
}
+2 -1
View File
@@ -54,7 +54,8 @@ export interface RpcMethodMap {
'agentPreset.list': AgentPresetsApi['list']
'agentPreset.select': AgentPresetsApi['select']
'agentPreset.read': AgentPresetsApi['read']
'agentPreset.write': AgentPresetsApi['write']
'agentPreset.copy': AgentPresetsApi['copy']
'agentPreset.openDocument': AgentPresetsApi['openDocument']
'agentPreset.remove': AgentPresetsApi['remove']
'goal.create': GoalsApi['create']
'goal.edit': GoalsApi['edit']
+8 -5
View File
@@ -41,8 +41,8 @@ import {
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
import { skillListValueSchema } from '../api/skills.schema.ts'
import {
agentPresetListValueSchema, agentPresetReadValueSchema, agentPresetRemoveValueSchema,
agentPresetSelectValueSchema, agentPresetWriteValueSchema,
agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema,
} from '../api/agent-presets.schema.ts'
import {
goalCreateValueSchema,
@@ -127,7 +127,8 @@ export interface IApiClient {
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'>>>
copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.copy'>>>
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
}
events: {
@@ -199,7 +200,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'agentPreset.list': agentPresetListValueSchema,
'agentPreset.select': agentPresetSelectValueSchema,
'agentPreset.read': agentPresetReadValueSchema,
'agentPreset.write': agentPresetWriteValueSchema,
'agentPreset.copy': agentPresetCopyValueSchema,
'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
'agentPreset.remove': agentPresetRemoveValueSchema,
'goal.create': goalCreateValueSchema,
'goal.edit': goalEditValueSchema,
@@ -468,7 +470,8 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('agentPreset.list', payload, signal),
select: (payload, signal) => this.callUnary('agentPreset.select', payload, signal),
read: (payload, signal) => this.callUnary('agentPreset.read', payload, signal),
write: (payload, signal) => this.callUnary('agentPreset.write', payload, signal),
copy: (payload, signal) => this.callUnary('agentPreset.copy', payload, signal),
openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal),
remove: (payload, signal) => this.callUnary('agentPreset.remove', payload, signal),
}
+4 -3
View File
@@ -43,8 +43,8 @@ import {
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
import { skillListRequestSchema } from '../api/skills.schema.ts'
import {
agentPresetListRequestSchema, agentPresetReadRequestSchema, agentPresetRemoveRequestSchema,
agentPresetSelectRequestSchema, agentPresetWriteRequestSchema,
agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema,
} from '../api/agent-presets.schema.ts'
import {
goalCreateRequestSchema,
@@ -116,7 +116,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'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.copy': { schema: agentPresetCopyRequestSchema, invoke: (api, r) => api.agentPresets.copy(r) },
'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
'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) },
+10
View File
@@ -71,6 +71,14 @@ export interface Config {
model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
/**
* Whether this deployment can hand paths to a native desktop opener —
* the `hasDocument` capability the agent-preset roster reports. Absent,
* the platform is asked (macOS/Windows/WSL yes; Linux only with a display
* server); set it explicitly where detection misleads, e.g. `false` in a
* container whose DISPLAY points nowhere a user can see.
*/
nativeOpen?: boolean
}
/**
@@ -109,6 +117,7 @@ export class ApiProxyService extends Service implements ApiProxy {
provider: z.string().required(),
model: z.string().required(),
workspaceRoot: z.string(),
nativeOpen: z.boolean(),
})
readonly sessions: ApiProxy['sessions']
@@ -154,6 +163,7 @@ export class ApiProxyService extends Service implements ApiProxy {
},
cwd,
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
})
this.sessions = api.sessions
this.subagents = api.subagents
@@ -152,6 +152,25 @@ async function openNativePathWithIntent(
throw new Error(`native path opener is unsupported on ${platform}`)
}
/**
* Whether {@link openNativePath} plausibly reaches a desktop on this host.
*
* macOS and Windows always carry a desktop opener; Linux does when it is WSL
* (the Windows desktop takes the path) or a display server is announced.
* A headless or containerised Linux host answers false, which is what lets a
* surface show a path as text instead of offering a button that would spawn
* `xdg-open` into nothing.
* @param internals - platform and environment seam for deterministic tests.
* @returns true when handing a path to the native opener can work at all.
*/
export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean {
const platform = internals.platform ?? process.platform
if (platform === 'darwin' || platform === 'win32') return true
if (platform !== 'linux') return false
const env = internals.env ?? process.env
return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY)
}
/**
* Open a filesystem path with the operating system's default application, or
* with the default browser when the path names a document a browser renders.
@@ -15,7 +15,7 @@ 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 {
InvalidCompositionError, InvalidPresetIdError, resolveSessionPreset, UnknownPresetError,
InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
import { GoalId } from '@deepseek-ai/dsh-goal'
import { createApiProxy } from '../src/api-proxy.ts'
@@ -34,19 +34,22 @@ function stubAgent(session: Session): Agent {
/**
* A roster whose `mount` is a no-op: this spec is about the gateway's identity
* rules, and the composition itself is covered by the real-composition test in
* `apps/cli`.
* `apps/cli`. Ids listed in `userIds` present as locally authored; the rest
* ship with the deployment.
*/
function roster(ids: readonly string[]): unknown {
function roster(ids: readonly string[], userIds: readonly string[] = []): unknown {
const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system')
const presetOf = (id: string): object =>
({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` })
return {
defaultId: ids[0],
list: () => Promise.resolve(ids.map(id => ({ id, trust: 'system', path: `/presets/${id}.yml` }))),
list: () => Promise.resolve(ids.map(presetOf)),
resolve: (id?: string) => {
const wanted = id ?? ids[0] ?? ''
if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
return Promise.resolve({ id: wanted, trust: 'system', path: `/presets/${wanted}.yml` })
return Promise.resolve(presetOf(wanted))
},
mount: (_ctx: Context, id?: string) =>
Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }),
mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
// What a real mount leaves behind: a service instance only the agent that
// mounted it can be used to address. The doubles are per agent so a test
// can tell "this session's" from "some session's".
@@ -56,11 +59,10 @@ function roster(ids: readonly string[]): unknown {
},
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'))
copy: (from: string, id: string) => {
if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids))
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id))
if (ids.includes(id)) return Promise.reject(new PresetExistsError(id))
return Promise.resolve()
},
remove: (id: string) => {
@@ -97,14 +99,18 @@ const failingStandingKeys = new Set<string>()
/** Per-agent service instances a mounted preset would own, keyed by session id. */
const services = new Map<string, Record<string, unknown>>()
async function harness(presets?: readonly string[], persistence?: unknown) {
async function harness(
presets?: readonly string[],
persistence?: unknown,
options: { userIds?: readonly string[]; defaults?: Record<string, unknown> } = {},
) {
const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never)
if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never)
if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
const factory: AgentFactory = {
async createAgent(_ownerCtx, options) {
@@ -131,6 +137,7 @@ async function harness(presets?: readonly string[], persistence?: unknown) {
defaultTarget: () => ({ provider: 'test', model: 'test-model' }),
cwd,
workspaceRoot: cwd,
...options.defaults,
})
return { api, ctx, cwd }
}
@@ -404,38 +411,59 @@ describe('agentPreset.select', () => {
})
describe('authoring over the wire', () => {
it('reads a composition and reports whether it may be edited', async () => {
it('reads a composition with its trust', 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.
// The shipped set is readable: it is the known-good composition a copy
// starts from, and trust is what tells a surface to say so.
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 () => {
it('copies a preset under a new id', async () => {
const { api } = await harness(['standard'])
const response = await api.agentPresets.write(request({ agentPreset: '../escape', content: '- id: x\n' }))
const response = await api.agentPresets.copy(
request({ from: 'standard', agentPreset: 'mine', name: '我的模式' }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value.agentPreset).toBe('mine')
})
it('rejects a copy target that could escape the preset root', async () => {
const { api } = await harness(['standard'])
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: '../escape' }))
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'])
it('rejects a copy target the roster already supplies', async () => {
const { api } = await harness(['standard', 'minimal'])
const response = await api.agentPresets.write(request({ agentPreset: 'mine', content: 'tools: []\n' }))
const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: 'minimal' }))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('agent-preset-invalid')
expect(response.result.error.message).toMatch(/already exists/)
})
it('rejects a copy whose source is unknown', async () => {
const { api } = await harness(['standard'])
const response = await api.agentPresets.copy(request({ from: 'never-existed', agentPreset: 'mine' }))
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 a deployment that composes no presets', async () => {
@@ -459,6 +487,81 @@ describe('authoring over the wire', () => {
})
})
describe('opening a preset directory', () => {
it('hands the resolved directory to the native opener', async () => {
const opened: string[] = []
const { api } = await harness(['standard', 'my-preset'], undefined, {
userIds: ['my-preset'],
defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
})
const response = await api.agentPresets.openDocument(
request({ agentPreset: 'my-preset' }), new AbortController().signal)
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value).toEqual({ opened: true })
// The id selected the directory; the browser supplied no path.
expect(opened).toEqual(['/presets/my-preset'])
})
it('answers the path as text where the deployment has no opener', async () => {
const { api } = await harness(['standard', 'my-preset'], undefined, {
userIds: ['my-preset'],
defaults: { canOpenPath: () => false },
})
const response = await api.agentPresets.openDocument(
request({ agentPreset: 'my-preset' }), new AbortController().signal)
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' })
})
it('refuses a preset that ships with the deployment', async () => {
const opened: string[] = []
const { api } = await harness(['standard'], undefined, {
defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
})
const response = await api.agentPresets.openDocument(
request({ agentPreset: 'standard' }), new AbortController().signal)
// Pointing an editor into the install invites edits an upgrade will
// silently overwrite; the refusal mirrors copy/remove.
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('unreachable')
expect(response.result.error.code).toBe('agent-preset-read-only')
expect(opened).toEqual([])
})
it('reports the roster capability on list', async () => {
const openable = await harness(['standard'], undefined, {
defaults: { canOpenPath: () => true },
})
const headless = await harness(['standard'], undefined, {
defaults: { canOpenPath: () => false },
})
const yes = await openable.api.agentPresets.list(request({}))
const no = await headless.api.agentPresets.list(request({}))
expect(yes.result.ok && yes.result.value.hasDocument).toBe(true)
expect(no.result.ok && no.result.value.hasDocument).toBe(false)
})
it('counts an injected opener as openable', async () => {
const { api } = await harness(['standard'], undefined, {
defaults: { openPath: () => Promise.resolve() },
})
const response = await api.agentPresets.list(request({}))
expect(response.result.ok && response.result.value.hasDocument).toBe(true)
})
})
describe('session.history presenter scope', () => {
it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
const { api } = await harness(['standard', 'core-web'])
@@ -89,10 +89,11 @@ function scriptedApi(overrides: {
},
skills: { list: r => ok(r, { skills: [] }), ...overrides.skills },
agentPresets: {
list: r => ok(r, { presets: [], authorable: false }),
list: r => ok(r, { presets: [], authorable: false, hasDocument: 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 }),
read: r => ok(r, { agentPreset: r.payload.agentPreset, trust: 'user' as const, content: '' }),
copy: r => ok(r, { agentPreset: r.payload.agentPreset }),
openDocument: r => ok(r, { opened: true as const }),
remove: r => ok(r, {}),
...overrides.agentPresets,
},
@@ -234,7 +235,7 @@ describe('unary round trip', () => {
const c = client(scriptedApi())
const listed = await c.agentPresets.list({})
expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false } })
expect(listed.result).toEqual({ ok: true, value: { presets: [], authorable: false, hasDocument: false } })
// The switch carries the session it is about: the host refuses one whose
// conversation has started, and it can only know which by id.
@@ -198,7 +198,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
list(request: RpcRequest<{}>) {
return Promise.resolve({
rpcId: request.rpcId,
result: { ok: true as const, value: { presets: [], authorable: false } },
result: { ok: true as const, value: { presets: [], authorable: false, hasDocument: false } },
})
},
select(request: RpcRequest<{ agentPreset: string }>) {
@@ -206,13 +206,16 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
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 }
const value = { agentPreset: request.payload.agentPreset, trust: 'user' as const, content: '' }
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
},
write(request: RpcRequest<{ agentPreset: string }>) {
copy(request: RpcRequest<{ from: string; agentPreset: string }>) {
const value = { agentPreset: request.payload.agentPreset }
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value } })
},
openDocument(request: RpcRequest<{ agentPreset: string }>) {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { opened: true as const } } })
},
remove(request: RpcRequest<{ agentPreset: string }>) {
return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: {} } })
},
@@ -364,19 +367,21 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const c = client()
// The whole domain crosses the carrier: the roster a picker reads, the
// per-session switch, and the three authoring calls the settings editor
// makes. Each has its own request schema, so a registration missing from
// either half fails here rather than in the browser.
// per-session switch, and the authoring calls the settings page makes.
// Each has its own request schema, so a registration missing from either
// half fails here rather than in the browser.
expect((await c.agentPresets.list({})).result).toEqual({
ok: true, value: { presets: [], authorable: false },
ok: true, value: { presets: [], authorable: false, hasDocument: false },
})
expect((await c.agentPresets.select({ sessionId: 's' as never, agentPreset: 'minimal' })).result)
.toEqual({ ok: true, value: { agentPreset: 'minimal' } })
expect((await c.agentPresets.read({ agentPreset: 'mine' })).result).toEqual({
ok: true, value: { agentPreset: 'mine', trust: 'user', content: '', writable: true },
ok: true, value: { agentPreset: 'mine', trust: 'user', content: '' },
})
expect((await c.agentPresets.write({ agentPreset: 'mine', content: '- id: x\n' })).result)
expect((await c.agentPresets.copy({ from: 'standard', agentPreset: 'mine' })).result)
.toEqual({ ok: true, value: { agentPreset: 'mine' } })
expect((await c.agentPresets.openDocument({ agentPreset: 'mine' })).result)
.toEqual({ ok: true, value: { opened: true } })
expect((await c.agentPresets.remove({ agentPreset: 'mine' })).result).toEqual({ ok: true, value: {} })
})
@@ -16,7 +16,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { release as osRelease } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
const signal = () => new AbortController().signal
@@ -287,3 +287,35 @@ describe('browser-renderable documents', () => {
])
})
})
describe('canOpenNativePath', () => {
it('always answers yes where the desktop is part of the platform', () => {
expect(canOpenNativePath({ platform: 'darwin', env: {} })).toBe(true)
expect(canOpenNativePath({ platform: 'win32', env: {} })).toBe(true)
})
it('requires a display server or WSL interop on linux', () => {
const linux = { platform: 'linux' as const, osRelease: '6.8.0-generic' }
// Headless is the case the capability exists for: `xdg-open` would spawn
// into nothing, so a surface should show the path as text instead.
expect(canOpenNativePath({ ...linux, env: {} })).toBe(false)
expect(canOpenNativePath({ ...linux, env: { DISPLAY: ':0' } })).toBe(true)
expect(canOpenNativePath({ ...linux, env: { WAYLAND_DISPLAY: 'wayland-0' } })).toBe(true)
expect(canOpenNativePath({
platform: 'linux', osRelease: '5.15.153.1-microsoft-standard-WSL2', env: {},
})).toBe(true)
})
it('answers no on a platform the opener does not support', () => {
expect(canOpenNativePath({ platform: 'freebsd', env: {} })).toBe(false)
})
it('samples the ambient environment when no override is supplied', () => {
const env = process.env
const marked = (value: string | undefined): boolean => value !== undefined && value !== ''
const expected = marked(env.WSL_DISTRO_NAME) || marked(env.WSL_INTEROP)
|| marked(env.DISPLAY) || marked(env.WAYLAND_DISPLAY)
expect(canOpenNativePath({ platform: 'linux', osRelease: '6.8.0-generic' })).toBe(expected)
})
})
@@ -32,7 +32,9 @@ import {
commandListRequestSchema, commandListValueSchema,
} from '../src/api/commands.schema.ts'
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
import { agentPresetEntrySchema, agentPresetListValueSchema } from '../src/api/agent-presets.schema.ts'
import {
agentPresetEntrySchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
} from '../src/api/agent-presets.schema.ts'
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts'
@@ -516,9 +518,17 @@ describe('agent-preset schemas', () => {
})
it('accepts an empty roster', () => {
// 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 })
// A deployment composing no presets still reports its authoring and
// native-open capabilities, so a surface knows what to offer.
expect(agentPresetListValueSchema.parse({ presets: [], authorable: false, hasDocument: false }))
.toEqual({ presets: [], authorable: false, hasDocument: false })
})
it('answers the open-document union by its discriminant', () => {
expect(agentPresetOpenDocumentValueSchema.parse({ opened: true })).toEqual({ opened: true })
expect(agentPresetOpenDocumentValueSchema.parse({ opened: false, path: '/presets/mine' }))
.toEqual({ opened: false, path: '/presets/mine' })
// A closed reply must carry the path the surface shows instead.
expect(() => agentPresetOpenDocumentValueSchema.parse({ opened: false })).toThrow()
})
})
@@ -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/preset/agent-presets/README.md
README.md: 66145b1870fdfa2b0ca7395f02dc5ac21fa46cab
README.zh.md: aef118435d0957e79fc9b5baee531f9bfe206586
README.md: 26f54f3efe4eadc933b0b3aed7b0ed8f8816b7d4
README.zh.md: 6688e84994beac9f937a8f01501d726267af08cf
+10 -10
View File
@@ -16,16 +16,16 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` Compose one agent from a preset — ensure its standing mount (single-flight) and parent the agent's scope key to it — returning the preset for the caller to record.
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` Re-link one agent to a different preset's standing composition. Valid only while the agent has produced nothing — **the caller owns that check**; the new mount is ensured before the link moves, so a failure leaves the agent as it was.
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` The standing scope key a host reader with no agent (a cold transcript read) resolves preset registrations in; ensures the mount without starting an agent, session, or turn.
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be written at all.
- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created 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. Edits reach only future generations: the standing pointer drops, sessions already joined keep the mount they run on.
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` Create a locally authored preset by copying an existing one's whole directory — the only authoring write. No composition text crosses this seam, so a copy is exactly as loadable as its source; the copied metadata keeps the source's description but never its name or roster order, and `name` (or the id fallback) is what distinguishes the rows.
- `ctx.agentPresets.remove(id): Promise<void>` Delete a locally authored preset; joined sessions keep their standing mount. Clears the user default when it named the preset just deleted: storing a default that does not exist yet is deliberate, but one this call removed will never be supplied again and would fail every session created without an explicit pick.
`AgentPreset` carries `id` (the directory name), `trust` (`system` or `user`, from the root it was found under), and `path` (the absolute composition file).
### Where to call `mount()`
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. A settled mount is permanent for the process: the composition a running session joined must outlive its file changing or disappearing underneath it, so file edits reach only future generations.
The agent factory's `setup(agentCtx)` hook is the one supported call site. Only there is the join installed while the agent is still unpublished, so a rejected composition rolls the whole creation back rather than leaving a half-composed session. The standing subtree is owned by the roster service's own fiber — deliberately its UNTRACED context, because a subtree minted from a traced `this.ctx` resolves every service through the caller's shadow fiber instead of each entry's own inject store — so it survives every agent and unwinds only with the whole tree. Each generation records its composition file's stamp (mtime and size): a session that finds the stamp stale starts the next generation, while every session already joined keeps the one it runs on — the composition a running session joined outlives its file changing or disappearing underneath it, and files are the only composition editor, so the stamp is what carries an edit to later sessions.
### Which preset a session runs
@@ -41,13 +41,13 @@ The restriction to a produced-nothing agent is a product rule, not a mechanical
## 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:
Authoring is copy-only. A new preset is a whole-directory copy of an existing one — composition, metadata, skill directories, assets — landed under the first `user` root; the inputs are two ids the service resolves against its own roots plus an optional display name, so no caller ever supplies composition text and a copy grants nothing the roster did not already carry. Everything after creation happens in the preset's own files. `copy()` 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.
- **An id that is already taken.** A copy never overwrites: any root supplying the id refuses it (a user directory named like a shipped preset would be shadowed by it), and a directory occupying the name on disk without being a preset refuses it too.
- **An unknown source.** The source may be any trust — copying a shipped preset is the primary case — but it must exist; a failed copy rolls its half-made directory back rather than leaving one discovery cannot see.
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.
The copied tree is re-tightened to owner-only (`0o600` files keeping their owner-execute bit, `0o700` directories), symlinks are dereferenced so the copy is self-contained, and the root is created on first copy — a deployment configuring a user root that does not exist yet is the normal first-run state. The copied `preset.yml` is rewritten: the source's description is kept for the author to edit in place, but its name and roster `order` are dropped — a copy presenting itself identically to its source, or sorted into the shipped set's declared order, would make the roster stop distinguishing them. `remove()` refuses a preset that ships with the deployment; the shipped set is the known-good compositions copies start from.
### How a preset's rows resolve
@@ -121,7 +121,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 once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards.
- **A standing mount reads its file once per generation** — the first session to name a preset fixes its composition until an authoring `write()`/`remove()` drops the pointer or the whole tree unloads; sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions).
- **A written composition is never mounted to validate** — `write()` checks 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.
- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions).
- **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy that fails at the next session that selects it, exactly as the source would.
- **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — so the whole assembly stays readable in one file.
- **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()`.
+10 -10
View File
@@ -16,16 +16,16 @@
- `ctx.agentPresets.mount(agentCtx, id?): Promise<AgentPreset>` 用一个 preset 组装一个 agent——确保其常驻挂载(并发去重)并把 agent 的 scope key 认父到它——返回该 preset 供调用方记录。
- `ctx.agentPresets.recompose(agentCtx, id): Promise<AgentPreset>` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。
- `ctx.agentPresets.standingKeyFor(id?): Promise<ScopeKey>` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可
- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建
- `ctx.agentPresets.read(id): Promise<string>` 某个 preset 的组装文本,与存储内容逐字一致。
- `ctx.agentPresets.write(id, content): Promise<void>` 创建或替换一个本地创作的 preset。编辑只影响未来的代际:常驻指针被丢弃,已加入的会话保持其正在运行的挂载
- `ctx.agentPresets.copy(from, id, name?): Promise<void>` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据
- `ctx.agentPresets.remove(id): Promise<void>` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。
`AgentPreset` 携带 `id`(目录名)、`trust``system``user`,取自它所在的根目录)以及 `path`(组装文件的绝对路径)。
### 应在何处调用 `mount()`
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。挂载一旦成功即进程级永久:正在运行的会话所加入的组装必须在其文件被修改或删除后继续存活,因此文件编辑只影响未来的代际
agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有在那里,认父是在 agent 尚未发布时完成的,因此组装被拒绝会让整次创建回滚,而不会留下一个组装到一半的会话。常驻子树归 roster 服务自己的 fiber 所有——刻意用其未追踪的上下文,因为从被追踪的 `this.ctx` 派生的子树会经调用方的 shadow fiber 解析一切服务、无视各 entry 自己的 inject store——所以它比任何 agent 都活得久,只随整棵树卸载。每个代际记录其组装文件的 stamp(mtime 与大小):发现 stamp 过期的会话会开启下一个代际,而所有已加入的会话保持各自正在运行的那个——正在运行的会话所加入的组装在其文件被修改或删除后继续存活;文件是唯一的组装编辑器,stamp 正是把编辑送达后续会话的机制
### 会话实际运行的是哪个 preset
@@ -41,13 +41,13 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
## 创作
本地创作的 preset 是首个 `user` 根目录下的一个目录,其中放置一份 `agent.cordis.yml``write()` 在任何内容落盘之前拒绝三种情况:
创作即复制。新 preset 是某个既有 preset 的整目录副本——组装、元数据、skill 目录、附带资产——落在首个 `user` 根目录之下;输入只有两个由服务对照自身根目录解析的 id 加一个可选显示名,因此调用方从不提供组装文本,一次复制不会授予 roster 尚未携带的任何能力。创建之后的一切都发生在 preset 自己的文件里。`copy()` 在任何内容落盘之前拒绝三种情况:
- **不符合 `[a-z0-9][a-z0-9-]*` 的 id。** id 会成为目录名,因此约束是 id 自身的性质,而非事后再做一次路径检查——`../escape``a/b` 与绝对路径都作为 id 被拒绝。
- **不是 Cordis entry 列表的文本。** 内容使用 loader 自身的 schema 与方言(含 `!!js`)解析,因此保存不会留下任何会话都无法加载的文件。只校验形状:引用了不存在插件的组装在此被接受,并在下一个选择它的会话处失败
- **随部署提供的 preset。** 覆写它会抹掉那份用来对照有问题的本地 preset 的已知良好组装。`remove()` 同样拒绝
- **已被占用的 id。** 复制从不覆写:任一根目录已提供该 id 即拒绝(与随附 preset 同名的用户目录只会被它遮蔽),磁盘上占着该名字却不是 preset 的目录同样拒绝
- **未知的来源。** 来源可以是任何信任级别——复制随附 preset 正是主要用途——但必须存在;复制失败会回滚做到一半的目录,而不是留下一个 discovery 看不见的目录
写入是原子的、仅属主可读写(`0o600`,位于 `0o700` 的目录内),且根目录在首次写入时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。
复制出的目录树被收紧为仅属主可用(文件 `0o600` 并保留属主执行位,目录 `0o700`),符号链接被解引用以保证副本自包含,且根目录在首次复制时创建——部署配置了尚不存在的用户根目录,正是首次运行的正常状态。复制出的 `preset.yml` 会被重写:保留来源的描述供作者就地编辑,但丢弃其名称与 roster `order`——副本若与来源呈现得一模一样、或按随附集合声明的顺序排序,roster 就不再能区分它们。`remove()` 拒绝随部署提供的 preset;随附集合正是副本的已知良好起点。
### preset 的各行如何解析
@@ -121,7 +121,7 @@ Indirectly, through the plugins a standing composition registers, which own ever
## Known Limitations and Deferred Work
- **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。
- **常驻挂载每个代际只读一次文件** —— 首个命名某 preset 的会话固定其组装,直到创作面的 `write()`/`remove()` 丢弃指针或整棵树卸载;已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
- **写入的组装从不被实际挂载以校验** —— `write()` 校验形状而非可解析性,因此引用了缺失插件的 preset 会被存下,并在下一个选择它的会话处失败
- **展示名称就是目录 id** —— preset 不携带 manifest,因此选择器与设置界面在有消费方需要更丰富的元数据之前,只显示 id
- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。
- **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出同样在下一个选择它的会话处失败的副本,与来源的失败方式完全一致
- **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis``code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读
- **根目录扫描不做监听** —— 每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`
+96 -62
View File
@@ -1,20 +1,22 @@
/**
* Creating, reading, and deleting locally authored presets.
* Copying, 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.
*
* The only authoring write is a whole-directory copy of an existing preset.
* No caller supplies composition text: the inputs are ids the host resolves
* against its own roots plus an optional display name, so authoring grants no
* capability the copied preset did not already carry.
* @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 { chmod, cp, readdir, readFile, rm, stat } from 'node:fs/promises'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { expandHomePath } from '@deepseek-ai/dsh-paths'
import { COMPOSITION_FILE } from './discovery.ts'
import { METADATA_FILE, renderPresetMetadata, type PresetMetadata } from './metadata.ts'
import { METADATA_FILE, renderPresetMetadata } from './metadata.ts'
import type { AgentPreset, PresetRoot } from './types.ts'
/**
@@ -39,13 +41,16 @@ export class InvalidPresetIdError extends Error {
}
}
/** A composition that is not a usable entry list. */
export class InvalidCompositionError extends Error {
/** A copy target that is already occupied — a copy never overwrites. */
export class PresetExistsError extends Error {
constructor(
/** Why the text cannot be a composition. */
readonly reason: string,
/** The id that is already taken. */
readonly presetId: string,
) {
super(`agent-presets: composition is not a valid entry list: ${reason}`)
super(
`agent-presets: preset "${presetId}" already exists — `
+ 'a copy never overwrites; delete the existing preset first or choose another id',
)
}
}
@@ -74,30 +79,6 @@ export function writableRoot(roots: readonly PresetRoot[]): string {
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.
@@ -107,40 +88,93 @@ export async function readComposition(preset: AgentPreset): Promise<string> {
return await readFile(preset.path, 'utf8')
}
/** Whether anything occupies the path (cp's own errorOnExist backstops races). */
async function occupied(path: string): Promise<boolean> {
let present = true
try {
await stat(path)
} catch {
// Every stat failure means the same thing here: nothing usable occupies
// the path, so the copy may claim it.
present = false
}
return present
}
/**
* 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.
* @param metadata - display name and description; clearing both removes the file.
* @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.
* Re-tighten a copied tree to owner-only. A shipped preset is world-readable
* in its install and `cp` preserves that; the copy carries the same weight as
* the settings document beside it, so group/other access is stripped. A
* file's owner-execute bit survives — a preset may ship runnable helpers.
*/
export async function writeComposition(
async function tightenModes(dir: string): Promise<void> {
await chmod(dir, 0o700)
for (const entry of await readdir(dir, { withFileTypes: true })) {
const target = join(dir, entry.name)
if (entry.isDirectory()) {
await tightenModes(target)
} else {
await chmod(target, ((await stat(target)).mode & 0o100) === 0 ? 0o600 : 0o700)
}
}
}
/**
* Create a preset by copying an existing one's whole directory.
*
* The copy carries everything the source directory holds — composition,
* metadata, skill directories, assets — because a preset is its directory,
* not one file. Symlinks are dereferenced so the copy is self-contained
* rather than a set of links back into the install it was copied from.
*
* The copied metadata is then rewritten: the source's description is kept
* (the file is the author's to edit afterwards), but its name and roster
* `order` are not — a copy presenting itself identically to its source, or
* sorted into the shipped set's declared order, would make the roster stop
* distinguishing them. With no name given and no description to keep, the
* file is removed so the copy publishes nothing rather than a blank.
* @param roots - the configured roots; the first `user` one receives the copy.
* @param source - the resolved preset the copy starts from.
* @param id - the new preset's id, which becomes its directory name.
* @param name - display name for the copy; omitted falls back to the id.
* @returns the absolute path of the new preset directory.
* @throws when the id is unusable or already occupied on disk, or the
* deployment configures no writable root.
*/
export async function copyComposition(
roots: readonly PresetRoot[],
source: AgentPreset,
id: string,
content: string,
metadata: PresetMetadata = {},
name?: 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 })
// Display text lands after the composition, and only when there is any: a
// preset with no name should carry no metadata file rather than an empty
// one. Clearing both fields therefore removes the file.
const rendered = renderPresetMetadata(metadata)
const metadataPath = join(dir, METADATA_FILE)
if (rendered === undefined) {
await rm(metadataPath, { force: true })
} else {
await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 })
// The roster check upstream only sees discovered presets; a directory with
// no composition file still occupies the name and deserves a readable
// refusal rather than a filesystem error code.
if (await occupied(dir)) throw new PresetExistsError(id)
try {
await cp(dirname(source.path), dir, {
recursive: true, dereference: true, force: false, errorOnExist: true,
})
await tightenModes(dir)
const rendered = renderPresetMetadata({
...name === undefined ? {} : { name },
...source.description === undefined ? {} : { description: source.description },
})
const metadataPath = join(dir, METADATA_FILE)
if (rendered === undefined) {
await rm(metadataPath, { force: true })
} else {
await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 })
}
} catch (error) {
// A half-copied directory would be invisible to discovery at best and a
// mountable-but-incomplete preset at worst; a failed copy leaves nothing.
await rm(dir, { recursive: true, force: true })
throw error
}
return path
return dir
}
/**
+87 -35
View File
@@ -21,16 +21,16 @@
* @module @deepseek-ai/dsh-agent-presets
*/
import { stat } from 'node:fs/promises'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { createScope, scopeOf, setScopeParent, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope'
import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
import { discoverPresets } from './discovery.ts'
import { deleteComposition, readComposition, writeComposition } from './authoring.ts'
import type { PresetMetadata } from './metadata.ts'
import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
import { mountPreset, serviceForAgent } from './mount.ts'
import { PresetNotWritableError } from './authoring.ts'
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
import { PresetExistsError } from './authoring.ts'
import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts'
/** Settings namespace carrying the user's chosen default preset. */
export const SETTINGS_NAMESPACE = 'agent-presets'
@@ -55,8 +55,8 @@ export {
type PresetMount,
} from './mount.ts'
export {
assertComposition, deleteComposition, InvalidCompositionError, InvalidPresetIdError,
PresetNotWritableError, readComposition, writableRoot, writeComposition,
copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError,
PresetNotWritableError, readComposition, writableRoot,
} from './authoring.ts'
export { resolveSessionPreset, type PresetBearingSession } from './session.ts'
export { PresetMountError, UnknownPresetError } from './types.ts'
@@ -171,10 +171,12 @@ export class AgentPresets extends Service {
* Standing mounts by preset id, single-flight so two agents racing the
* first use of one preset share one composition. A settled failure is
* removed so a later session retries a preset whose file has been fixed; a
* settled success is permanent for the process — the composition a running
* session joined must survive the file changing or disappearing underneath
* it, so file edits reach only future generations (a later authoring layer
* swaps this pointer; it never disposes a joined generation).
* settled success serves until the composition FILE visibly changes — each
* generation records its file stamp, and a stale stamp starts the next
* generation for sessions created afterwards. Sessions already joined keep
* the generation they run on; a superseded one is never disposed while the
* process lives (reclaimed only by whole-tree teardown), so editing files
* is bounded by how often compositions change, not by session count.
*/
private readonly standing = new Map<string, Promise<StandingMount>>()
@@ -218,29 +220,32 @@ export class AgentPresets extends Service {
}
/**
* Create or replace a locally authored preset.
* Create a locally authored preset by copying an existing one whole.
*
* 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.
* @param metadata - display name and description; clearing both removes the file.
* @throws when the id is unusable, the text is not an entry list, or the
* deployment configures no writable root.
* Copy is the only authoring write. Composition text never crosses this
* seam: the source is named by id and its directory is copied as it stands,
* so the copy is exactly as loadable as its source and authoring grants no
* capability the roster did not already carry. The copy is NOT mounted to
* validate — a source that mounts today yields a copy that mounts today.
* @param from - the preset the copy starts from; shipped presets are the
* primary source, so any trust is accepted.
* @param id - the new preset's id, which becomes its directory name.
* @param name - display name for the copy; absent falls back to the id.
* @throws when the source is unknown, the id is unusable or already taken,
* or the deployment configures no writable root.
*/
async write(id: string, content: string, metadata: PresetMetadata = {}): 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')
async copy(from: string, id: string, name?: string): Promise<void> {
const source = await this.resolve(from)
// The roster check refuses ids any root supplies — shipped ones included,
// since a user directory named like a shipped preset is shadowed by it.
// The disk check inside copyComposition only sees the writable root.
if ((await this.list()).some(preset => preset.id === id)) {
throw new PresetExistsError(id)
}
await writeComposition(this.config.roots, id, content, metadata)
// Future generations only: the standing pointer is dropped so the NEXT
// session composes the edited file, while every session already joined
// keeps the mount it runs on — a superseded generation is never disposed
// while the process lives (reclaimed only by whole-tree teardown).
await copyComposition(this.config.roots, source, id, name)
// A settled mount under this id can only be stale (its preset was deleted
// from disk outside `remove`); the new preset must not inherit it. Every
// session already joined keeps the generation it runs on regardless.
this.standing.delete(id)
}
@@ -251,8 +256,8 @@ export class AgentPresets extends Service {
*/
async remove(id: string): Promise<void> {
await deleteComposition(this.config.roots, await this.resolve(id))
// Same generation rule as `write`: sessions on the deleted preset keep
// their standing mount; only new sessions see the roster without it.
// Sessions on the deleted preset keep their standing mount; only new
// sessions see the roster without it.
this.standing.delete(id)
// Storing a default that does not exist YET is deliberate — the roster is a
// live directory, so a name absent now may exist by the time a session asks
@@ -332,32 +337,79 @@ export class AgentPresets extends Service {
}
/** Resolve (or create, single-flight) the standing mount of one preset. */
private ensureStanding(preset: AgentPreset): Promise<StandingMount> {
private async ensureStanding(preset: AgentPreset): Promise<StandingMount> {
const pending = this.standing.get(preset.id)
if (pending !== undefined) return pending
if (pending !== undefined) {
const mounted = await pending
// Files are the only composition editor (authoring is copy/delete), so
// the stamp is what notices an edit: a changed file starts the next
// generation here, for this and later sessions. An unreadable stamp
// serves the current generation — a mount must survive its file
// disappearing, and failing the session over a stat would not.
const current = await compositionStamp(preset.path)
if (current === undefined || sameStamp(mounted.stamp, current)) return mounted
// Guarded delete: a caller that raced this one may have already started
// the next generation, and dropping THAT pointer would fork a third.
if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id)
return this.ensureStanding(preset)
}
const created = (async (): Promise<StandingMount> => {
const key: ScopeKey = { agentPreset: preset.id }
const scope = createScope(this.selfCtx, key)
try {
// Stamped before the file is read: an edit racing the mount makes the
// stamp stale rather than silently current, so the next session
// refreshes instead of trusting a composition older than its stamp.
const stamp = await compositionStamp(preset.path)
if (stamp === undefined) {
throw new PresetMountError(preset.id, `composition file is unreadable: ${preset.path}`)
}
await mountPreset(scope.ctx, preset)
return { key, scope, stamp }
} catch (error) {
this.standing.delete(preset.id)
await scope.dispose()
throw error
}
return { key, scope }
})()
this.standing.set(preset.id, created)
return created
}
}
/** The composition file identity one standing generation was mounted from. */
interface CompositionStamp {
/** Modification time in milliseconds, as `stat` reports it. */
readonly mtimeMs: number
/** File size in bytes, the tiebreak for edits within one mtime tick. */
readonly size: number
}
/** Read one composition file's stamp, or undefined when it cannot be statted. */
async function compositionStamp(path: string): Promise<CompositionStamp | undefined> {
try {
const { mtimeMs, size } = await stat(path)
return { mtimeMs, size }
} catch {
// Deleted, replaced by an unreadable entry, or otherwise unstattable all
// mean the same to the caller: the file offers no identity to compare.
return undefined
}
}
/** Whether two stamps name the same file state. */
function sameStamp(a: CompositionStamp, b: CompositionStamp): boolean {
return a.mtimeMs === b.mtimeMs && a.size === b.size
}
/** One preset's standing composition. */
interface StandingMount {
/** Scope key agents are parented to; also the mount's registration scope. */
readonly key: ScopeKey
/** Disposal boundary; held for whole-tree teardown, never per-session. */
readonly scope: Scope
/** Stamp of the composition file this generation was mounted from. */
readonly stamp: CompositionStamp
}
export default AgentPresets
@@ -1,10 +1,12 @@
/**
* 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.
* Authoring a preset copies an existing one's directory into the deployment's
* `user` root — copy is the only authoring write, so no caller ever supplies
* composition text. 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 { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
@@ -14,7 +16,7 @@ import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { beforeEach, describe, expect, it } from 'vitest'
import AgentPresets, {
COMPOSITION_FILE, METADATA_FILE, assertComposition,
COMPOSITION_FILE, copyComposition, METADATA_FILE,
} from '@deepseek-ai/dsh-agent-presets'
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
@@ -23,6 +25,21 @@ const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\
let ctx: Context
let userRoot: string
/** Hand-craft a preset directory (tests cannot author text through the service). */
async function seedPreset(
root: string, id: string, options: { composition?: string; metadata?: string; extras?: Record<string, string> } = {},
): Promise<void> {
await mkdir(join(root, id), { recursive: true })
await writeFile(join(root, id, COMPOSITION_FILE), options.composition ?? VALID)
if (options.metadata !== undefined) {
await writeFile(join(root, id, METADATA_FILE), options.metadata)
}
for (const [name, content] of Object.entries(options.extras ?? {})) {
await mkdir(dirname(join(root, id, name)), { recursive: true })
await writeFile(join(root, id, name), content)
}
}
beforeEach(async () => {
userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-'))
ctx = new Context()
@@ -38,76 +55,59 @@ beforeEach(async () => {
})
})
describe('authoring a preset', () => {
it('creates one in the user root and lists it', async () => {
await ctx.agentPresets.write('mine', VALID)
describe('copying a preset', () => {
it('copies a shipped preset into the user root and lists it', async () => {
await ctx.agentPresets.copy('standard', 'mine')
expect(await readFile(join(userRoot, 'mine', COMPOSITION_FILE), 'utf8')).toBe(VALID)
expect(await readFile(join(userRoot, 'mine', COMPOSITION_FILE), 'utf8'))
.toBe(await ctx.agentPresets.read('standard'))
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)
it('copies the whole directory, execute bits kept and group/other stripped', async () => {
await seedPreset(userRoot, 'source', {
extras: { 'skills/demo/SKILL.md': '# demo\n', 'skills/demo/run.sh': '#!/bin/sh\n' },
})
await chmod(join(userRoot, 'source', 'skills', 'demo', 'run.sh'), 0o755)
expect(await ctx.agentPresets.read('mine')).toBe(VALID)
await ctx.agentPresets.copy('source', 'mine')
expect(await readFile(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'), 'utf8')).toBe('# demo\n')
// A preset may ship runnable helpers; the copy keeps them runnable for the
// owner while withdrawing the world-readability of the install.
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'run.sh'))).mode & 0o777).toBe(0o700)
expect((await stat(join(userRoot, 'mine', 'skills', 'demo', 'SKILL.md'))).mode & 0o777).toBe(0o600)
expect((await stat(join(userRoot, 'mine'))).mode & 0o777).toBe(0o700)
})
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'
it('keeps the source description but never its name or order', async () => {
await seedPreset(userRoot, 'source', { metadata: 'name: 源模式\ndescription: 只做检索。\norder: 1\n' })
await ctx.agentPresets.write('mine', next)
await ctx.agentPresets.copy('source', 'mine')
expect(await ctx.agentPresets.read('mine')).toBe(next)
// Two rows presenting identically is how a roster stops being a chooser,
// and the shipped set's declared order is not the copy's to claim.
const metadata = await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8')
expect(metadata).toContain('description: 只做检索。')
expect(metadata).not.toContain('name:')
expect(metadata).not.toContain('order:')
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine'))
.toMatchObject({ description: '只做检索。' })
})
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('stores the display name the author supplied', async () => {
await ctx.agentPresets.copy('standard', 'mine', '我的模式')
expect(await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8')).toContain('name: 我的模式')
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine'))
.toMatchObject({ name: '我的模式' })
})
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/)
it('publishes no metadata file when there is nothing to publish', async () => {
await seedPreset(userRoot, 'source')
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('display metadata beside a composition', () => {
it('stores the name and description the author supplied', async () => {
await ctx.agentPresets.write('mine', VALID, { name: '我的模式', description: '只做检索。' })
expect(await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8'))
.toContain('name: 我的模式')
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
expect(listed).toMatchObject({ name: '我的模式', description: '只做检索。' })
})
it('removes the file when both fields are cleared', async () => {
await ctx.agentPresets.write('mine', VALID, { name: '我的模式' })
await ctx.agentPresets.write('mine', VALID, {})
await ctx.agentPresets.copy('source', 'mine')
// An empty metadata document would read as an intentional blank name;
// absence is what "this preset publishes no display text" looks like.
@@ -115,20 +115,56 @@ describe('display metadata beside a composition', () => {
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')?.name).toBeUndefined()
})
it('keeps a composition mountable when its metadata is unreadable', async () => {
await ctx.agentPresets.write('mine', VALID)
await writeFile(join(userRoot, 'mine', METADATA_FILE), 'name: [unclosed\n')
it('refuses an id that could escape the preset root', async () => {
for (const id of ['../escape', 'a/b', '/abs', '..', 'Upper']) {
await expect(ctx.agentPresets.copy('standard', id)).rejects.toThrow(/must match/)
}
// Nothing was created for any of them.
expect(existsSync(join(userRoot, 'escape'))).toBe(false)
})
// Presentation is not capability: discovery still yields the preset.
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
expect(listed?.name).toBeUndefined()
expect(await ctx.agentPresets.resolve('mine')).toMatchObject({ id: 'mine' })
it('refuses an id the roster already supplies, shipped ones included', async () => {
await ctx.agentPresets.copy('standard', 'mine')
await expect(ctx.agentPresets.copy('standard', 'mine')).rejects.toThrow(/already exists/)
// A user directory named like a shipped preset would be shadowed by it.
await expect(ctx.agentPresets.copy('standard', 'minimal')).rejects.toThrow(/already exists/)
})
it('refuses a directory that occupies the name without being a preset', async () => {
await mkdir(join(userRoot, 'occupied'), { recursive: true })
await writeFile(join(userRoot, 'occupied', 'README.txt'), 'nope\n')
// Discovery does not list it (no composition file), so only the disk
// check can refuse it with a readable error instead of a filesystem code.
await expect(ctx.agentPresets.copy('standard', 'occupied')).rejects.toThrow(/already exists/)
expect(await readFile(join(userRoot, 'occupied', 'README.txt'), 'utf8')).toBe('nope\n')
})
it('reports an unknown source rather than creating anything', async () => {
await expect(ctx.agentPresets.copy('never-existed', 'mine')).rejects.toThrow(/not found/)
expect(existsSync(join(userRoot, 'mine'))).toBe(false)
})
it('leaves nothing behind when the copy itself fails', async () => {
const source = {
id: 'gone',
trust: 'user' as const,
path: join(userRoot, 'gone', COMPOSITION_FILE),
}
// The source vanished between resolve and copy: the half-made target is
// rolled back rather than left invisible to discovery.
await expect(copyComposition(
[{ path: userRoot, trust: 'user' as const }], source, 'mine',
)).rejects.toThrow()
expect(existsSync(join(userRoot, 'mine'))).toBe(false)
})
})
describe('deleting a preset', () => {
it('removes a locally authored one', async () => {
await ctx.agentPresets.write('mine', VALID)
await ctx.agentPresets.copy('standard', 'mine')
await ctx.agentPresets.remove('mine')
@@ -149,8 +185,7 @@ describe('deleting a preset', () => {
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)
await seedPreset(second, 'elsewhere')
const layered = new Context()
layered.baseUrl = pathToFileURL(FIXTURES).href + '/'
await layered.plugin(Loader)
@@ -184,26 +219,42 @@ describe('a deployment with no writable root', () => {
})
expect(readOnly.agentPresets.authorable).toBe(false)
await expect(readOnly.agentPresets.write('mine', VALID))
await expect(readOnly.agentPresets.copy('standard', 'mine'))
.rejects.toThrow(/no user-writable preset root/)
})
})
describe('a user root that does not exist yet', () => {
it('is created by the first save', async () => {
it('is created by the first copy', 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 }],
default: 'standard',
roots: [
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
{ path: absent, trust: 'user' as const },
],
})
await fresh.agentPresets.write('mine', VALID)
await fresh.agentPresets.copy('standard', 'mine')
expect(await readFile(join(absent, 'mine', COMPOSITION_FILE), 'utf8')).toBe(VALID)
expect(await readFile(join(absent, 'mine', COMPOSITION_FILE), 'utf8'))
.toBe(await fresh.agentPresets.read('standard'))
})
})
describe('display metadata beside a composition', () => {
it('keeps a composition mountable when its metadata is unreadable', async () => {
await ctx.agentPresets.copy('standard', 'mine')
await writeFile(join(userRoot, 'mine', METADATA_FILE), 'name: [unclosed\n')
// Presentation is not capability: discovery still yields the preset.
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
expect(listed?.name).toBeUndefined()
expect(await ctx.agentPresets.resolve('mine')).toMatchObject({ id: 'mine' })
})
})
@@ -12,8 +12,11 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
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 AgentPresets, {
COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent,
} from '@deepseek-ai/dsh-agent-presets'
import type { Config } from '@deepseek-ai/dsh-agent-presets'
import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope'
declare module 'cordis' {
interface Context {
@@ -196,11 +199,35 @@ describe('rejecting a composition that cannot be used', () => {
})
it('answers undefined for a service the agent\'s preset does not mount', async () => {
// The isolated preset's standing instance exists in the same runtime, so
// the lookup finds the NAME and must still refuse it: the instance lives
// under another mount's fiber, not this agent's composition.
await agentOn(ctx, 'sess-reach-other', 'isolated')
const agent = await agentOn(ctx, 'sess-reach-none', 'standard')
expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined()
})
it('answers undefined for an agent outside the scope machinery', async () => {
// Unscoped, scoped-but-unparented, and parented to a key no live mount
// owns are the three ways a context can fail to name a standing mount;
// each is an answer, not a throw, because the caller asked a question.
expect(serviceForAgent(ctx, { ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
const loner = createScope(ctx, { test: 'loner' })
expect(serviceForAgent(ctx, { ctx: loner.ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
const orphan = createScope(ctx, { test: 'orphan' })
setScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' })
expect(serviceForAgent(ctx, { ctx: orphan.ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
})
it('refuses to mount a preset directly into an unscoped context', async () => {
// The service's own mount() guards this before delegating; the exported
// function is callable on its own, so the boundary holds there too.
const preset = await ctx.agentPresets.resolve('standard')
await expect(mountPreset(ctx, preset)).rejects.toThrow(/unscoped context/)
})
it('reports the known ids when a preset is unknown', async () => {
await expect(ctx.agentPresets.resolve('nope'))
.rejects.toThrow(/preset "nope" not found \(available: .*standard/)
@@ -409,3 +436,101 @@ describe('replacing a composition', () => {
.rejects.toThrow(/unscoped context/)
})
})
describe('editing a composition file', () => {
/** One-row composition whose single tool is named `tool`. */
const rowFor = (tool: string): string =>
`- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: ${tool}\n`
/**
* A context over a temp root holding one editable preset. The id is
* per-test because `livePresetMounts()` is a process-global registry.
*/
async function editable(id: string): Promise<{ scoped: Context; path: string }> {
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-'))
await mkdir(join(root, id))
const path = join(root, id, COMPOSITION_FILE)
await writeFile(path, rowFor('before'))
const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] })
return { scoped, path }
}
it('starts a new generation for later sessions while joined ones keep theirs', async () => {
const { scoped, path } = await editable('edited')
const first = await agentOn(scoped, 'sess-gen-first', 'edited')
expect(toolNames(scoped, first)).toEqual(['before'])
// Files are the only composition editor now (authoring is copy/delete),
// so the standing mount notices the file's stamp changing on its own.
await writeFile(path, rowFor('afterwards'))
const second = await agentOn(scoped, 'sess-gen-second', 'edited')
expect(toolNames(scoped, second)).toEqual(['afterwards'])
// The joined session keeps the generation it runs on.
expect(toolNames(scoped, first)).toEqual(['before'])
})
it('gives two sessions racing the refreshed file one shared new generation', async () => {
const { scoped, path } = await editable('raced')
await agentOn(scoped, 'sess-race-seed', 'raced')
await writeFile(path, rowFor('afterwards'))
// Whichever racer swaps the pointer first, the other must join it rather
// than fork a third generation off the same edit.
const [left, right] = await Promise.all([
agentOn(scoped, 'sess-race-left', 'raced'),
agentOn(scoped, 'sess-race-right', 'raced'),
])
expect(toolNames(scoped, left)).toEqual(['afterwards'])
expect(toolNames(scoped, right)).toEqual(['afterwards'])
expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2)
})
it('hands a host reader the standing key without starting an agent', async () => {
const { scoped } = await editable('cold-read')
const key = await scoped.agentPresets.standingKeyFor('cold-read')
// The mount exists for the reader; no agent, session, or turn started.
expect(key).toEqual({ agentPreset: 'cold-read' })
expect(livePresetMounts().filter(mount => mount.presetId === 'cold-read')).toHaveLength(1)
expect(scoped.agents.get(SessionId('cold-read'))).toBeUndefined()
// A second reader resolves the same generation, not a new mount.
expect(await scoped.agentPresets.standingKeyFor('cold-read')).toBe(key)
})
it('refuses to mount a generation it cannot stamp', async () => {
const { scoped, path } = await editable('unstampable')
await rm(path)
// Discovery would refuse the preset too; a caller that resolved just
// before the deletion must get a mount failure, not an unstamped
// generation that no later edit could ever refresh.
const racer = scoped.agentPresets as unknown as {
ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise<unknown>
}
await expect(racer.ensureStanding({ id: 'unstampable', trust: 'user', path }))
.rejects.toThrow(PresetMountError)
expect(livePresetMounts().filter(mount => mount.presetId === 'unstampable')).toHaveLength(0)
})
it('keeps serving the mounted generation when the file cannot be statted', async () => {
const { scoped, path } = await editable('stale')
await agentOn(scoped, 'sess-stale-served', 'stale')
expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1)
await rm(path)
// Discovery refuses a preset whose composition cannot be statted, so the
// public route cannot reach this state — but a caller that resolved just
// before the deletion still can, and it must be served the standing
// generation rather than failed over a stat.
const racer = scoped.agentPresets as unknown as {
ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise<unknown>
}
await racer.ensureStanding({ id: 'stale', trust: 'user', path })
expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1)
})
})
+1
View File
@@ -47,6 +47,7 @@
"apps/web/tests/permission-policy-context.e2e.ts",
"apps/web/tests/access-confirmation.e2e.ts",
"apps/web/tests/agent-preset-selection.e2e.ts",
"apps/web/tests/agent-preset-authoring.e2e.ts",
"apps/web/tests/shipped-composition.e2e.ts",
"apps/web/tests/goal-bar.e2e.ts",
"apps/web/tests/startup-auto-selection.e2e.ts",