From 5c7dd6f8eb5b1b74918a6e46a6dc316fa593c168 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 20:20:16 +0800 Subject: [PATCH 1/7] feat(agent-presets): own the writable preset root instead of awaiting an app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Config.roots` defaulted to `[]`, so a deployment that did not patch both roots in got a roster with none — `dsh run` once booted exactly that and failed resolving `standard`. The user root never needed an app: `/.agent-presets` is the same place in every deployment, resolvable here the way `dsh-skill-local` resolves `/skills`. The roster now derives that root itself unless `includeUserRoot` is false, and `apps/cli` supplies only the SHIPPED root, whose path an installed app alone can resolve. The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it and `writableRoot()` still prefers an explicitly configured `user` root; the set is resolved once at construction, because a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. Every test that pins an exact roster now says `includeUserRoot: false` — the machine's real harness home must not decide what a golden or an assertion counts. --- ...nt-validates-its-own-composition.i18n.yaml | 4 +- ...ing-agent-validates-its-own-composition.md | 4 +- ...-agent-validates-its-own-composition.zh.md | 4 +- apps/cli/src/profile-boot.ts | 14 +- apps/cli/tests/web-agent-presets.e2e.ts | 8 +- apps/web/tests/scaffold.ts | 10 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 5 + docs/config-catalog.zh.md | 5 + .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 11 ++ packages/preset/agent-presets/README.zh.md | 11 ++ .../preset/agent-presets/src/discovery.ts | 11 ++ packages/preset/agent-presets/src/index.ts | 33 +++-- packages/preset/agent-presets/src/preset.ts | 5 + .../agent-presets/tests/authoring.spec.ts | 7 + .../agent-presets/tests/invariant.spec.ts | 2 +- .../preset/agent-presets/tests/mount.spec.ts | 16 +-- .../agent-presets/tests/settings.spec.ts | 2 +- .../agent-presets/tests/user-root.spec.ts | 129 ++++++++++++++++++ .../tests/preset-inheritance.spec.ts | 2 +- 21 files changed, 253 insertions(+), 38 deletions(-) create mode 100644 packages/preset/agent-presets/tests/user-root.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml index 67d2b330b1..d243b435cc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md -2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 6b9cdf32b70e3ab4adc9f3b0e20bb3d2245486c7 -2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: e6e8dabcd886a6331d294744b667552caa01e7b4 +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 15efa02b703abe6b0b694dbdca7054051ce168ec +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 3d22799fffae2003b935e9360501164730313df3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md index 6b9cdf32b7..15efa02b70 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -32,7 +32,9 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora "Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service. -The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" — it is where every `dsh` launcher puts them — while routing the path an agent actually reads or edits through `list()` or `resolve()`. `Config.roots` defaults to `[]` and `apps/cli` patches both roots in, `writableRoot()` takes the first `user` one, and no call reports either path; `authorable` answers only whether a writable root exists, and `list()` cannot reveal a user root that holds nothing yet. Stating the path is therefore right for talking to a person and wrong for feeding a file tool. +The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: no call reports a root, `authorable` answers only whether a writable one exists, and `list()` cannot reveal a user root that holds nothing yet. + +That path is now a property of the package rather than of one launcher. `AgentPresets` derives `/.agent-presets` as a `user` root unless `includeUserRoot` is false, the way [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) derives `/skills`, and `apps/cli` supplies only the SHIPPED root — the one path an installed app alone can resolve. The asymmetry it replaces cost a bug: with both roots patched in by one launcher, `dsh run` booted a roster with no roots at all and failed resolving `standard` (fixed then by teaching every launcher the patch). The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it, and `writableRoot()` still prefers an explicitly configured `user` root. It is resolved once at construction: a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md index e6e8dabcd8..3d22799fff 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -32,7 +32,9 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂 「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。 -指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案——每个 `dsh` 启动器都把它们放在那里——同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。`Config.roots` 默认为 `[]`,两个根均由 `apps/cli` 补入,`writableRoot()` 取其中第一个 `user` 根,且没有任何调用会报告任一路径;`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此写出该路径对人讲是对的,喂给文件工具是错的。 +指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案,同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。写出该路径对人讲是对的,喂给文件工具是错的:没有任何调用会报告根目录,`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。 + +该路径如今是本包的属性,而非某个启动器的属性。除非 `includeUserRoot` 为 false,`AgentPresets` 自行推导 `/.agent-presets` 作为 `user` 根,正如 [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) 推导 `/skills`;`apps/cli` 只提供**随附**根——那是唯有已安装 app 才能解析的路径。它取代的那种不对称曾付出过代价:两个根都由单一启动器补入时,`dsh run` 启动的 roster 一个根都没有,解析 `standard` 直接失败(当时的修法是让每个启动器都执行该 patch)。推导出的根追加在全部已配置根之后,因此随附 id 仍会遮蔽占用它的家目录目录,而 `writableRoot()` 仍优先选择显式配置的 `user` 根。它在构造时解析一次:若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index dae6e992ec..462791d489 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -29,13 +29,11 @@ import { watchUserPatches, type Profile, } from '@deepseek-ai/dsh-app-boot' -import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) -/** Harness-home directory holding locally authored agent presets. */ -const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' @@ -159,16 +157,16 @@ function composeProfile( if (typeof row.id === 'string') rows.set(row.id, row) } const composedOverlays = [...overlays] - // Preset roots belong to every dsh composition that mounts the roster. + // The SHIPPED root is the part of the roster only this app can resolve: it + // sits beside this app's own config, in both the source and built layouts. + // The writable root the roster appends is `dsh-agent-presets`' own, so a + // launcher that never reaches this patch still finds a person's presets. if (rows.has('agent-presets')) { composedOverlays.push({ id: 'agent-presets', config: { ...(rows.get('agent-presets')?.config ?? {}) as Record, - roots: [ - { path: SHIPPED_PRESET_ROOT, trust: 'system' }, - { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }, - ], + roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }], }, }) } diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index f37da4c4a7..d554202b04 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -93,7 +93,11 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis // document overrides. { id: 'agent-presets', - config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, + config: { + default: 'standard', + roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], + includeUserRoot: false, + }, }, ...extra, ] @@ -447,6 +451,7 @@ describe('product subagent rows in user presets', () => { { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, { path: userRoot, trust: 'user' }, ], + includeUserRoot: false, }, }]) }, 120_000) @@ -647,6 +652,7 @@ describe('authoring a preset on the shipped composition', () => { // nothing is the normal first-run state. { path: userRoot, trust: 'user' }, ], + includeUserRoot: false, }, }]) }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 87f75b383d..344a539061 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -385,7 +385,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/.agent-presets` as a `user` root, after every configured root | An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution. +### The writable root is this package's, the shipped root is the app's + +`/.agent-presets` is where a person's own presets live, the way `/skills` is where their own skills live ([`dsh-skill-local`](../../skill/skill-local/README.md)), so the roster derives it rather than waiting for a deployment to remember it — a launcher that configures nothing still finds and authors presets. It is appended AFTER every configured root, which keeps an earlier root winning a duplicate id: a shipped `standard` still shadows a home directory that claimed the name, and `copy()` refuses that id rather than landing a preset nothing would resolve. + +The roots are resolved once, when the service is constructed. A root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. + +`includeUserRoot: false` mounts a roster over `roots` alone. A deployment that confines presets to its own directories needs it, and so does any test pinning an exact roster — otherwise the machine's real `` decides what the roster contains. + +The SHIPPED root stays an assembly fact: it sits beside the installed app's own config, a path only that app can resolve. + ### The default preset is a user setting When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default: diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 1d8d1481c2..ceb8f0284c 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -86,9 +86,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen |---|---|---| | `default` | 必填 | 调用方未指定时挂载的 preset id | | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) | +| `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `/.agent-presets` 作为 `user` 根目录 | 根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。 +### 可写根目录属于本包,随附根目录属于 app + +`/.agent-presets` 是个人自有 preset 的所在,正如 `/skills` 是其自有 skill 的所在([`dsh-skill-local`](../../skill/skill-local/README.md)),因此 roster 自行推导它,而不等某个部署记得配置——一个什么都没配的启动器同样能发现并创作 preset。它追加在全部已配置根目录**之后**,从而保持靠前的根目录赢得重复 id:随附的 `standard` 仍然遮蔽一个占用该名字的家目录目录,而 `copy()` 会拒绝该 id,不会落下一个无人解析得到的 preset。 + +根目录在服务构造时解析一次。若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 + +`includeUserRoot: false` 使 roster 只覆盖 `roots`。把 preset 限制在自有目录内的部署需要它,任何钉住确切 roster 的测试同样需要——否则将由这台机器真实的 `` 决定 roster 的内容。 + +随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。 + ### 默认 preset 是一项用户设置 当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值: diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 4ab3f67e50..20efc80e0c 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -25,6 +25,17 @@ import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' /** The composition file that makes a directory a preset. */ export const COMPOSITION_FILE = 'agent.cordis.yml' +/** + * Harness-home directory holding locally authored presets. + * + * This package owns the writable root the way `dsh-skill-local` owns + * `/skills`. An app must assemble the SHIPPED root, whose path only + * the installed app can resolve; where a person's own presets go is the same + * place in every deployment that does not say otherwise, so a launcher that + * forgets to configure one still finds them. + */ +export const USER_PRESET_DIR = '.agent-presets' + /** * Why `rows` cannot be an entry list, or undefined when it can. * diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 3da5e3b5c9..c146e75774 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -28,11 +28,12 @@ import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type // Type-only: resolves the `agent/created` lifecycle event this service watches. import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' -import { discoverPresets } from './discovery.ts' +import { dshHomePath } from '@deepseek-ai/dsh-paths' +import { discoverPresets, USER_PRESET_DIR } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' -import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts' +import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts' import type {} from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ @@ -49,7 +50,7 @@ export const AgentPresetSettingsSchema: z = z.object({ default: z.string(), }) -export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' +export { COMPOSITION_FILE, discoverPresets, scanRoot, USER_PRESET_DIR } from './discovery.ts' export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, } from './metadata.ts' @@ -88,8 +89,21 @@ export class AgentPresets extends Service { path: z.string().required(), trust: z.union(['system', 'user'] as const).default('user'), })).default([]), + includeUserRoot: z.boolean().default(true), }) as z + /** + * The roots discovery and authoring actually scan: every configured root in + * order, then the harness-home user root unless `includeUserRoot` is false. + * + * Derived once, because a root set that changed between `list()` and the + * `copy()` acting on its answer would author into a directory the caller + * never saw. Appending rather than prepending keeps an earlier configured + * root winning a duplicate id, so a shipped preset still shadows a + * locally authored directory that claimed its name. + */ + private readonly resolvedRoots: readonly PresetRoot[] + /** * The user layer over `config.default`, present only while a settings * provider is composed. Held rather than snapshotted so a hot-reloaded @@ -116,6 +130,9 @@ export class AgentPresets extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.selfCtx = ctx + this.resolvedRoots = config.includeUserRoot + ? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }] + : [...config.roots] // Deliberately not `installSettingsSection`: that helper exists to re-judge // what a consumer DERIVED from the source — memoized resolutions, // registration-level facts — across attach, detach, and change. Nothing @@ -147,7 +164,7 @@ export class AgentPresets extends Service { // does that today — the Web surface mounts in `setup` and children join // through `composeFrom` before publication. ctx.on('agent/created', ({ agent }) => { - if (this.config.roots.length === 0) return + if (this.resolvedRoots.length === 0) return if (this.composedPreset(agent.ctx) !== undefined) return ctx.logger.warn( `agent "${agent.id}" was published without joining an agent preset; ` @@ -180,7 +197,7 @@ export class AgentPresets extends Service { * @returns the presets, first-root-wins per id. */ async list(): Promise { - return await discoverPresets(this.config.roots) + return await discoverPresets(this.resolvedRoots) } /** @@ -322,7 +339,7 @@ export class AgentPresets extends Service { /** Whether this deployment configures a root locally authored presets go to. */ get authorable(): boolean { - return this.config.roots.some(root => root.trust === 'user') + return this.resolvedRoots.some(root => root.trust === 'user') } /** @@ -358,7 +375,7 @@ export class AgentPresets extends Service { if ((await this.list()).some(preset => preset.id === id)) { throw new PresetExistsError(id) } - await copyComposition(this.config.roots, source, id, name) + await copyComposition(this.resolvedRoots, 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. @@ -371,7 +388,7 @@ export class AgentPresets extends Service { * @throws when the preset is unknown or ships with the deployment. */ async remove(id: string): Promise { - await deleteComposition(this.config.roots, await this.resolve(id)) + await deleteComposition(this.resolvedRoots, await this.resolve(id)) // Sessions on the deleted preset keep their standing mount; only new // sessions see the roster without it. this.standing.delete(id) diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts index b2b48ea6ea..554348cdd6 100644 --- a/packages/preset/agent-presets/src/preset.ts +++ b/packages/preset/agent-presets/src/preset.ts @@ -54,6 +54,11 @@ export interface Config { default: string /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ roots: PresetRoot[] + /** + * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every + * configured root. False mounts a roster over `roots` alone. + */ + includeUserRoot: boolean } /** diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index df69a792d5..8086996111 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -52,6 +52,10 @@ beforeEach(async () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: userRoot, trust: 'user' as const }, ], + // Every roster in this file pins its own roots: the derived harness-home + // root would add the developer's real presets to what these assertions + // count, and `copy` would write into it. + includeUserRoot: false, }) }) @@ -199,6 +203,7 @@ describe('a deployment with more than one user root', () => { { path: userRoot, trust: 'user' as const }, { path: second, trust: 'user' as const }, ], + includeUserRoot: false, }) // Writes go to the first user root, so a preset discovered from a later @@ -219,6 +224,7 @@ describe('a deployment with no writable root', () => { await readOnly.plugin(AgentPresets, { default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' as const }], + includeUserRoot: false, }) expect(readOnly.agentPresets.authorable).toBe(false) @@ -240,6 +246,7 @@ describe('a user root that does not exist yet', () => { { path: join(FIXTURES, 'system'), trust: 'system' as const }, { path: absent, trust: 'user' as const }, ], + includeUserRoot: false, }) await fresh.agentPresets.copy('standard', 'mine') diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 709ee5ba00..570fdb31b9 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -31,7 +31,7 @@ async function harness(): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false }) await ctx.plugin(InvariantService) await ctx.plugin(AgentPresetsInvariant) return ctx diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 92a080a930..a02b286fee 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -38,7 +38,7 @@ const ROOTS = [ * @param roster - roster config, defaulting to the fixture roots. * @returns the booted context. */ -async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise { +async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -94,7 +94,7 @@ describe('composing an agent from a preset', () => { join(presetDir, COMPOSITION_FILE), `- id: only\n name: ${plugin}\n config:\n tool: absolute\n`, ) - const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }] }) + const scoped = await harness({ default: 'absolute', roots: [{ path: root, trust: 'user' }], includeUserRoot: false }) const imported = vi.spyOn(scoped.loader.internal!, 'import') await agentOn(scoped, 'sess-absolute-plugin') @@ -347,7 +347,7 @@ describe('composing from a broken preset', () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) await mkdir(join(root, 'damaged')) await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) - return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }] }) + return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) } it('refuses the mount up front with the discovery-reported reason', async () => { @@ -380,7 +380,7 @@ describe('a roster with nothing in it', () => { it('says so instead of naming an empty list of candidates', async () => { const bare = new Context() await bare.plugin(Loader) - await bare.plugin(AgentPresets, { default: 'standard', roots: [] }) + await bare.plugin(AgentPresets, { default: 'standard', roots: [], includeUserRoot: false }) await expect(bare.agentPresets.resolve()) .rejects.toThrow(/preset "standard" not found \(available: none\)/) @@ -418,7 +418,7 @@ describe('the preset file is an input, never a persistence target', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) await scoped.agents.create({ sessionId: SessionId('sess-self-dispose'), @@ -532,7 +532,7 @@ describe('replacing a composition', () => { // Presets are optional: every surface except the Web bundle keeps its // model-facing rows in the host plane, so an agent with a chain of one is // exactly right there and the diagnostic must stay silent. - const rosterless = await harness({ default: 'standard', roots: [] }) + const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false }) const warnings: string[] = [] rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn @@ -581,7 +581,7 @@ describe('replacing a composition', () => { await scoped.plugin(ToolRegistry) await scoped.plugin(AgentRegistry) await scoped.plugin(AgentLoop, { agents: [] }) - await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }] }) + await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) const handle = await scoped.agents.create({ sessionId: SessionId('sess-restore-gone'), setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'), @@ -621,7 +621,7 @@ describe('editing a composition file', () => { 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 }] }) + const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false }) return { scoped, path } } diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index ef75eb8b78..49f1636a6c 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -49,7 +49,7 @@ async function harness( await ctx.plugin(AgentLoop, { agents: [] }) const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false }) await settingsFiber - await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots] }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: [...ROOTS, ...extraRoots], includeUserRoot: false }) return { ctx, settingsFile, settingsFiber } } diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts new file mode 100644 index 0000000000..0320d0c899 --- /dev/null +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -0,0 +1,129 @@ +/** + * The writable root is this package's own, not an assembly fact each app must + * remember: a roster configured with only a `system` root still discovers and + * authors into `/.agent-presets`, the way `dsh-skill-local` owns + * `/skills`. `includeUserRoot: false` is how a deployment — or a test + * pinning an exact roster — opts out. + * + * `$DSH_HOME` is repointed per test because the derived root is resolved in the + * constructor: the plugin must be mounted while the environment names the + * temporary home, or it would reach the developer's real one. + */ + +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import AgentPresets, { COMPOSITION_FILE, USER_PRESET_DIR, type Config } from '@deepseek-ai/dsh-agent-presets' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM_ROOT = join(FIXTURES, 'system') +const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' + +let home: string +let previousHome: string | undefined + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-')) + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = home +}) + +afterEach(() => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome +}) + +/** Boot a roster over the fixture system root, with the derived root left to the plugin. */ +async function roster(config: Partial = {}): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(AgentPresets, { + default: 'standard', + roots: [{ path: SYSTEM_ROOT, trust: 'system' as const }], + includeUserRoot: true, + ...config, + }) + return ctx +} + +/** Hand-place a preset directory under the harness home's preset root. */ +async function seedHomePreset(id: string): Promise { + await mkdir(join(home, USER_PRESET_DIR, id), { recursive: true }) + await writeFile(join(home, USER_PRESET_DIR, id, COMPOSITION_FILE), VALID) +} + +describe('the harness-home preset root', () => { + it('is what a roster gets when config names no roots at all', () => { + // The schema default is the contract an app relies on by saying nothing; + // every other case here passes the field explicitly. The cast stands for + // the untyped document the Loader hands the schema, which is where a + // composition that omits the key actually comes from. + const parsed = AgentPresets.Config({ default: 'standard' } as unknown as Config) + + expect(parsed).toMatchObject({ includeUserRoot: true, roots: [] }) + }) + + it('is discovered without any app configuring it', async () => { + await seedHomePreset('mine') + const ctx = await roster() + + const listed = await ctx.agentPresets.list() + + expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' }) + expect((await ctx.agentPresets.resolve('mine')).path) + .toBe(join(home, USER_PRESET_DIR, 'mine', COMPOSITION_FILE)) + }) + + it('makes a roster with only a system root authorable, and receives the copy', async () => { + const ctx = await roster() + + expect(ctx.agentPresets.authorable).toBe(true) + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(home, USER_PRESET_DIR, 'copied', COMPOSITION_FILE))).toBe(true) + }) + + it('sorts after every configured root, so a shipped id still shadows a home directory', async () => { + // `standard` exists in the fixture system root; claiming the name at home + // must not take it over, because `copy` refuses an id any root supplies + // and a session resolving `standard` must reach the shipped composition. + await seedHomePreset('standard') + const ctx = await roster() + + expect((await ctx.agentPresets.resolve('standard')).trust).toBe('system') + await expect(ctx.agentPresets.copy('standard', 'standard')).rejects.toThrow(/already exists/) + }) + + it('is absent under includeUserRoot: false, which leaves the roster unauthorable', async () => { + await seedHomePreset('mine') + const ctx = await roster({ includeUserRoot: false }) + + expect((await ctx.agentPresets.list()).map(preset => preset.id)).not.toContain('mine') + expect(ctx.agentPresets.authorable).toBe(false) + await expect(ctx.agentPresets.copy('standard', 'mine')) + .rejects.toThrow(/no user-writable preset root/) + }) + + it('yields to a configured user root for authoring, which writableRoot takes first', async () => { + const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-')) + const ctx = await roster({ + roots: [ + { path: SYSTEM_ROOT, trust: 'system' as const }, + { path: explicit, trust: 'user' as const }, + ], + }) + + await ctx.agentPresets.copy('standard', 'copied') + + expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true) + expect(existsSync(join(home, USER_PRESET_DIR, 'copied'))).toBe(false) + }) +}) diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index 28306f0dcc..b4c5d5736e 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -40,7 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter; ctx.loader.builtins.include = Include await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS }) + await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false }) const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')]) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ From 101e7f238206f2e6f713631aa30491b5e1be168d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 20:23:03 +0800 Subject: [PATCH 2/7] docs: regenerate the core subsystem catalog for the moved Config source line --- docs/subsystems/core.i18n.yaml | 4 ++-- docs/subsystems/core.md | 2 +- docs/subsystems/core.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index ff6ed9b466..5f5d6aec22 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: e52a7619085b2956496be6234f711441902fc259 -core.zh.md: e9cfe19129e33a1c17e87561397b13139a6d7537 +core.md: 2e89bac4c0468c094814aa7137381f4be569fc29 +core.zh.md: 2a8a35bb46adf28fd2ba07d0a319cdca0cbb4a2f diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index e52a761908..2e89bac4c0 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index e9cfe19129..2a8a35bb46 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:82`](../../packages/preset/agent-presets/src/index.ts) From 715baae6c1e471620374c72a0338a82c530e0a95 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 21:13:41 +0800 Subject: [PATCH 3/7] fix(agent-presets): let the invariant follow the roster it actually scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The service moved its "is a roster composed" reads to the derived root set; the invariant companion still read `config.roots`. In the shape this change exists for — an app configures nothing and the roster is the harness home alone — that made the advisory warning fire while the fail-loud invariant stayed silent, so an agent could address a model against an empty global layer unchecked. Both now read one source: `roots` exposes the resolved set, and the invariant asks it. That decides the behavior deliberately rather than by omission — a composition that mounts the roster now fails an unjoined agent whether its roots were configured or derived, and `includeUserRoot: false` with no configured roots is how a deployment keeps its agents on the host plane. Both shapes are pinned; the derived-only case fails against the old predicate. Three pieces of prose went stale with the first commit: the web-app bundle comment still called the writable root an assembly fact patched in by AppCLIEntry (removed in the profile-plugin-bundles refactor — `composeProfile` owns it now, and only for the shipped root), and the shipped skill and its Agent Note still called both roots "configuration". The README gains the resolved-roster reader and the discoverable-but-undeletable preset a second writable root produces. --- ...nt-validates-its-own-composition.i18n.yaml | 4 +-- ...ing-agent-validates-its-own-composition.md | 2 +- ...-agent-validates-its-own-composition.zh.md | 2 +- .../editing-cordis-compositions/SKILL.md | 4 +-- packages/bundle/web-app/cordis.patch.yml | 15 ++++++---- .../preset/agent-presets/README.i18n.yaml | 4 +-- packages/preset/agent-presets/README.md | 4 ++- packages/preset/agent-presets/README.zh.md | 4 ++- packages/preset/agent-presets/src/index.ts | 12 +++++++- .../preset/agent-presets/src/invariant.ts | 2 +- .../agent-presets/tests/invariant.spec.ts | 28 +++++++++++++++++-- 11 files changed, 60 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml index d243b435cc..72878df41d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md -2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 15efa02b703abe6b0b694dbdca7054051ce168ec -2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 3d22799fffae2003b935e9360501164730313df3 +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: eb21094f0d859a31d5f16d780cada6818a508b36 +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 02c245348a9c7e9968472044d7ff95e1ff21120c diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md index 15efa02b70..eb21094f0d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -32,7 +32,7 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora "Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service. -The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: no call reports a root, `authorable` answers only whether a writable one exists, and `list()` cannot reveal a user root that holds nothing yet. +The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" while routing the path an agent actually reads or edits through `list()` or `resolve()`. Stating the path is right for talking to a person and wrong for feeding a file tool: a deployment may configure other roots, and `list()` cannot reveal a user root that holds nothing yet. That path is now a property of the package rather than of one launcher. `AgentPresets` derives `/.agent-presets` as a `user` root unless `includeUserRoot` is false, the way [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) derives `/skills`, and `apps/cli` supplies only the SHIPPED root — the one path an installed app alone can resolve. The asymmetry it replaces cost a bug: with both roots patched in by one launcher, `dsh run` booted a roster with no roots at all and failed resolving `standard` (fixed then by teaching every launcher the patch). The derived root is appended after every configured root, so a shipped id still shadows a home directory claiming it, and `writableRoot()` still prefers an explicitly configured `user` root. It is resolved once at construction: a root set that changed between a `list()` and the `copy()` acting on its answer would author into a directory the caller never saw. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md index 3d22799fff..02c245348a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -32,7 +32,7 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂 「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。 -指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案,同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。写出该路径对人讲是对的,喂给文件工具是错的:没有任何调用会报告根目录,`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。 +指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案,同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。写出该路径对人讲是对的,喂给文件工具是错的:部署可以配置其他根目录,而 `list()` 无法揭示一个尚且为空的用户根。 该路径如今是本包的属性,而非某个启动器的属性。除非 `includeUserRoot` 为 false,`AgentPresets` 自行推导 `/.agent-presets` 作为 `user` 根,正如 [`dsh-skill-local`](../../../../packages/skill/skill-local/README.md) 推导 `/skills`;`apps/cli` 只提供**随附**根——那是唯有已安装 app 才能解析的路径。它取代的那种不对称曾付出过代价:两个根都由单一启动器补入时,`dsh run` 启动的 roster 一个根都没有,解析 `standard` 直接失败(当时的修法是让每个启动器都执行该 patch)。推导出的根追加在全部已配置根之后,因此随附 id 仍会遮蔽占用它的家目录目录,而 `writableRoot()` 仍优先选择显式配置的 `user` 根。它在构造时解析一次:若根目录集合在一次 `list()` 与依据其答案执行的 `copy()` 之间发生变化,写入的将是调用方从未见过的目录。 diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 744d2f13c1..d897cafb7a 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -25,13 +25,13 @@ Two planes, and the choice is not about how "agent-related" something feels — A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. -Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. Both roots are configuration rather than fixed locations, though, and no call reports them — `authorable` says only whether a writable one exists — so take the path you actually read or edit from `list()` or `resolve()`, which is also where `copy()` reports what it just created. +Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. -Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. The four calls this skill relies on: +Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 535386579f..de352df77a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -374,12 +374,15 @@ disabled: true # The preset roster. `config/agent-presets/` ships with the deployment and is -# read-only (its entries carry `system` trust); -# `$DSH_HOME/.agent-presets` is where a person — or an agent — authors their own, and -# carries the same trust as shell access because a preset IS a composition. -# `roots` is an assembly fact, not user config: the shipped preset directory -# ships beside this file, so AppCLIEntry resolves it and patches it in — the -# same treatment `distIndex` gets on the webserver row. +# read-only (its entries carry `system` trust); `$DSH_HOME/.agent-presets` is +# where a person — or an agent — authors their own, and carries the same trust +# as shell access because a preset IS a composition. +# +# Only the SHIPPED root is an assembly fact: it sits beside the installed app's +# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the +# same treatment `distIndex` gets on the webserver row. The writable root is +# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that +# never reaches that patch still finds a person's presets. - insert: - id: agent-presets name: '@deepseek-ai/dsh-agent-presets' diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 7e3dc0a5dd..d63dd3fe2a 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: fb140e6ba7330b88b3c4fede775e3967a3ece0eb -README.zh.md: ceb8f0284cba25110bd95e20d597942e0b919446 +README.md: 63bed95d192e6aeff6f484b63bdde711df0f1967 +README.zh.md: 505cb017a3a2439a11e0f3ed1c7a950893f5e7de diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index fb140e6ba7..63bed95d19 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -18,7 +18,8 @@ Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every cal - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` The preset one LIVE agent runs on, read from its scope chain rather than from its session — the only answer available for an agent whose durable header is still being built. - `ctx.agentPresets.recompose(agentCtx, id): Promise` 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. Refuses a broken preset like `mount()`. - `ctx.agentPresets.standingKeyFor(id?): Promise` 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. Refuses a broken preset like `mount()`. -- `ctx.agentPresets.authorable: boolean` Whether any configured root has `user` trust, and therefore whether a preset can be created at all. +- `ctx.agentPresets.roots: readonly PresetRoot[]` The roots this roster scans — every configured root in order, then the derived harness-home root. Not `config.roots`: read this to answer whether a roster is composed at all, so one derivation decides it. +- `ctx.agentPresets.authorable: boolean` Whether any of those roots has `user` trust, and therefore whether a preset can be created at all. - `ctx.agentPresets.read(id): Promise` One preset's composition text, exactly as stored. - `ctx.agentPresets.copy(from, id, name?): Promise` 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` 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. @@ -143,6 +144,7 @@ Prefix-stable for the life of an agent: a composition is installed once, before ## Known Limitations and Deferred Work +- **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the FIRST `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and then answers "it does not live under the writable preset root" for every delete. The roster carries one writable root by design; a deployment that wants only its own sets `includeUserRoot: false`. - **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 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. - **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index ceb8f0284c..505cb017a3 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -18,7 +18,8 @@ - `ctx.agentPresets.composedPreset(agentCtx): string | undefined` 某个**活着的** agent 正在运行的 preset,从其 scope 链读取而不是从其会话读取——对于持久化 header 尚在构建中的 agent,这是唯一能拿到的答案。 - `ctx.agentPresets.recompose(agentCtx, id): Promise` 把一个 agent 重链到另一个 preset 的常驻组装。仅在该 agent 尚无任何产出时合法——**由调用方负责该检查**;新挂载在链移动之前确保完成,失败时 agent 原封不动。与 `mount()` 一样拒绝损坏的 preset。 - `ctx.agentPresets.standingKeyFor(id?): Promise` 没有 agent 的宿主读取方(冷读记录)解析 preset 注册所用的常驻 scope key;确保挂载而不启动任何 agent、会话或轮次。与 `mount()` 一样拒绝损坏的 preset。 -- `ctx.agentPresets.authorable: boolean` 是否有任一配置根目录具备 `user` 信任级别,因而 preset 是否可创建。 +- `ctx.agentPresets.roots: readonly PresetRoot[]` 本 roster 实际扫描的根目录——全部已配置根目录按序在前,随后是推导出的 harness home 根目录。它不是 `config.roots`:判断「是否已组装 roster」应读它,从而由同一处推导决定。 +- `ctx.agentPresets.authorable: boolean` 上述根目录中是否有任一具备 `user` 信任级别,因而 preset 是否可创建。 - `ctx.agentPresets.read(id): Promise` 某个 preset 的组装文本,与存储内容逐字一致。 - `ctx.agentPresets.copy(from, id, name?): Promise` 通过整目录复制一个既有 preset 来创建本地创作的 preset——唯一的创作写入。组装文本不经过这道接缝,因此副本与其来源同等可加载;复制出的元数据保留来源的描述、但绝不保留其名称与 roster 排序,`name`(或回退到 id)才是区分两行的依据。 - `ctx.agentPresets.remove(id): Promise` 删除一个本地创作的 preset;已加入的会话保留其常驻挂载。若用户默认值恰好指向刚删除的 preset 则一并清除:存一个尚不存在的默认值是刻意的,但本次删除的这个再也不会有人提供,留着会让所有未显式指定的新会话无法启动。 @@ -143,6 +144,7 @@ Indirectly, through the plugins a standing composition registers, which own ever ## Known Limitations and Deferred Work +- **位于可写根目录之外的 preset 可被发现却无法删除** —— `remove()` 拒绝任何不在**第一个** `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。roster 按设计只有一个可写根;只想要自有根的部署应设置 `includeUserRoot: false`。 - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 - **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。 - **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。 diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index c146e75774..be3ae1654b 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -337,7 +337,17 @@ export class AgentPresets extends Service { return standingMountFor(agentCtx)?.presetId } - /** Whether this deployment configures a root locally authored presets go to. */ + /** + * The roots this roster scans, which is not `config.roots`: it is every + * configured root in order, then the harness-home user root unless + * `includeUserRoot` is false. Read this — not the config field — to answer + * whether a roster is composed at all, so one derivation decides it. + */ + get roots(): readonly PresetRoot[] { + return this.resolvedRoots + } + + /** Whether this deployment has a root locally authored presets go to. */ get authorable(): boolean { return this.resolvedRoots.some(root => root.trust === 'user') } diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index e9240a0b2d..81cfd834ab 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -60,7 +60,7 @@ const install: InvariantInstaller = (ctx, fail) => { ctx.on('system-prompt/assemble', (_assembly, context, next) => { const presets = ctx.get('agentPresets') const agent = context.agent - if (presets !== undefined && presets.config.roots.length > 0 + if (presets !== undefined && presets.roots.length > 0 && agent !== undefined && presets.composedPreset(agent.ctx) === undefined) { fail( `agent "${agent.id}" addressed a model without joining any agent preset while a roster is ` diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 570fdb31b9..f73f77a53b 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -11,7 +11,7 @@ import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import InvariantService from '@deepseek-ai/dsh-invariants' import { describe, expect, it } from 'vitest' -import AgentPresets, { livePresetMounts } from '@deepseek-ai/dsh-agent-presets' +import AgentPresets, { livePresetMounts, type Config } from '@deepseek-ai/dsh-agent-presets' import * as AgentPresetsInvariant from '@deepseek-ai/dsh-agent-presets/invariant' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') @@ -20,7 +20,7 @@ const ROOTS = [ { path: join(FIXTURES, 'user'), trust: 'user' as const }, ] -async function harness(): Promise { +async function harness(roster: Partial = {}): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -31,7 +31,7 @@ async function harness(): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false }) + await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster }) await ctx.plugin(InvariantService) await ctx.plugin(AgentPresetsInvariant) return ctx @@ -97,6 +97,28 @@ describe('agent-presets invariants', () => { .rejects.toThrow(/without joining any agent preset/) }) + it('rejects one just the same when the derived home root is the whole roster', async () => { + // The shape this plugin defaults to: an app configures nothing and the + // roster is the harness home alone. A roster is a roster however its roots + // were resolved, so the fail-loud half must not go quiet here — it read + // `config.roots` once, which is empty in exactly this case. + const ctx = await harness({ roots: [], includeUserRoot: true }) + const handle = await ctx.agents.create({ sessionId: SessionId('inv-derived-only') }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))) + .rejects.toThrow(/without joining any agent preset/) + }) + + it('stays silent for a composition that opted out of every root', async () => { + // `includeUserRoot: false` with no configured roots is a deployment that + // mounts the roster but keeps its agents on the host plane; there is no + // roster to join, so an unjoined agent is not a violation. + const ctx = await harness({ roots: [], includeUserRoot: false }) + const handle = await ctx.agents.create({ sessionId: SessionId('inv-no-roster') }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined() + }) + it('admits a joined agent, a scopeless read, and a standing-key read', async () => { const ctx = await harness() const handle = await ctx.agents.create({ From d647ce9f7d44fdd1481e6a78a307ccab50b3be05 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 21:18:37 +0800 Subject: [PATCH 4/7] test(agent-presets): assert the derived root through the shipped bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new default's whole claim is that a launcher configuring nothing still finds a person's presets, and nothing asserted it through a real composition — `user-root.spec.ts` plugs the service directly, and every other lane pins `includeUserRoot: false` so the machine's home cannot decide a golden. This lane boots the real base and web-app bundles with only the shipped root patched in, points `$DSH_HOME` at a temp home before boot (the derived root is resolved when the plugin is constructed), and asserts a preset placed there is listed as `user`, reported healthy, authorable, and mountable into an agent whose tool catalog it decides. --- apps/cli/tests/web-agent-presets.e2e.ts | 60 +++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index d554202b04..b83cec9704 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -634,6 +634,66 @@ describe('a delegated child', () => { }) }) +describe('a launcher that configures no writable root', () => { + // The claim this default exists for, asserted through the real shipped + // bundles rather than a hand-built context: `apps/cli` patches in only the + // system root, and a person's own presets are found anyway because the + // roster derives `/.agent-presets` itself. `$DSH_HOME` is pointed + // at a temp home BEFORE boot — the derived root is resolved when the plugin + // is constructed, and an unpinned run would read the developer's own. + let derivedCtx: Context + let previousHome: string | undefined + + beforeAll(async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-preset-derived-')) + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = home + await mkdir(join(home, '.agent-presets', 'derived-mine'), { recursive: true }) + await writeFile( + join(home, '.agent-presets', 'derived-mine', 'agent.cordis.yml'), + '- id: tool-todo\n name: \'@deepseek-ai/dsh-tool-todo\'\n config:\n allowParallelInProgress: true\n', + ) + const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml') + await writeFile(settingsFile, '{}\n') + // Only the shipped root, exactly what `composeProfile` supplies; the + // writable one is the roster's own default rather than this patch's job. + derivedCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }], + includeUserRoot: true, + }, + }]) + }, 120_000) + + afterAll(async () => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome + await derivedCtx.fiber.dispose() + }) + + it('discovers and mounts a preset the person authored under the harness home', async () => { + const listed = await derivedCtx.agentPresets.list() + + const mine = listed.find(preset => preset.id === 'derived-mine') + expect(mine).toMatchObject({ trust: 'user' }) + // Omitted rather than undefined: a healthy row carries no `broken` key. + expect(mine?.broken).toBeUndefined() + expect(derivedCtx.agentPresets.authorable).toBe(true) + + const handle = await derivedCtx.agents.create({ + sessionId: SessionId('preset-derived-root'), + setup: agentCtx => derivedCtx.agentPresets.mount(agentCtx, 'derived-mine').then(() => undefined), + }) + try { + expect(toolNames(derivedCtx, handle.agent)).toContain('todo_write') + } finally { + await handle.dispose() + } + }) +}) + describe('authoring a preset on the shipped composition', () => { let authorCtx: Context let userRoot: string From 770f28902907d9d8393c779f5565a99acf2ce75f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 21:59:15 +0800 Subject: [PATCH 5/7] test(agent-presets): name the rosterless case for what now makes it one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty `roots` stopped meaning "no roster" when the harness-home root became a default, so the case that keeps the unjoined-agent diagnostic silent is the one opting out of every root — which is what it already passes and what its name now says. --- packages/preset/agent-presets/tests/mount.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index a02b286fee..8901770434 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -528,10 +528,12 @@ describe('replacing a composition', () => { expect(warnings).toEqual([]) }) - it('says nothing when the deployment configures no roster at all', async () => { + it('says nothing when the composition opts out of every root', async () => { // Presets are optional: every surface except the Web bundle keeps its // model-facing rows in the host plane, so an agent with a chain of one is - // exactly right there and the diagnostic must stay silent. + // exactly right there and the diagnostic must stay silent. Opting out is + // what makes this rosterless — empty `roots` alone would still derive the + // harness-home root, which is a roster like any other. const rosterless = await harness({ default: 'standard', roots: [], includeUserRoot: false }) const warnings: string[] = [] rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn From 5091d31370cebc59714b6b724d65721e79b6f6fa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 22:03:50 +0800 Subject: [PATCH 6/7] test(web): wait for the transcript the subagent golden pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI captured `- text: Loading history…` where the golden holds the grandchild's first message. The only wait before that capture is the offline banner, which renders from the descriptor and says nothing about the transcript below it, and `captureStableAria` calls two identical polls stable — two of which can both be the loading placeholder on a loaded machine. Waiting for the message the golden asserts makes the precondition match the assertion. The race is not observable here (the history is already loaded when the banner appears, so the wait returns in ~1ms), so this is reasoned from the CI log and the helper's semantics rather than from a local reproduction; if the history instead never arrives on CI, the lane now times out on the missing message rather than committing a loading placeholder to the diff. --- apps/web/tests/subagent-conversation.e2e.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 6756b093d2..4b8ecb55e6 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -31,6 +31,8 @@ const ONE_SHOT_LABEL = 'event-sourcing reviewer' const NESTED_LABEL = 'example editor' const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.' const INITIAL_PROMPT = 'Explain event sourcing in one sentence.' +/** The grandchild's own first message; its arrival is what says its history finished loading. */ +const NESTED_PROMPT = 'Give one concrete event sourcing example.' const FOLLOWUP = 'Now give the same explanation to a human reader.' const POST_FORK_FOLLOWUP = 'Continue the original conversation after the fork.' @@ -176,7 +178,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = seq: 1, time: authoredAt + 1, data: { - content: [{ type: 'text', text: 'Give one concrete event sourcing example.' }], + content: [{ type: 'text', text: NESTED_PROMPT }], source: { kind: 'user' }, }, surfaceOp: 'append', @@ -404,6 +406,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ) await nestedRow.click() await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() + // The offline banner renders from the descriptor alone, so it says nothing + // about the transcript below it. The golden pins that transcript, and + // `captureStableAria` calls two identical polls stable — including two of + // "Loading history…". Wait for the message the golden asserts. + await page.getByText(NESTED_PROMPT).waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) From 2646da8d5640209b56bfb4ad2f124abd255ad3c9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 11:36:24 +0800 Subject: [PATCH 7/7] refactor(agent-presets): keep the user-root segment out of the package API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `USER_PRESET_DIR` was exported with no production consumer — only the new test read it, and a test that imports the implementation constant cannot catch that constant being wrong. It stays module-internal, and the test spells the segment it expects. --- packages/preset/agent-presets/src/discovery.ts | 4 ++++ packages/preset/agent-presets/src/index.ts | 2 +- .../preset/agent-presets/tests/user-root.spec.ts | 14 ++++++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 20efc80e0c..51e1f30c95 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -33,6 +33,10 @@ export const COMPOSITION_FILE = 'agent.cordis.yml' * the installed app can resolve; where a person's own presets go is the same * place in every deployment that does not say otherwise, so a launcher that * forgets to configure one still finds them. + * + * Package-internal on purpose: no consumer outside this package addresses the + * directory by name, and a test that imported it could not catch this value + * being wrong — the expected segment is spelled out where it is asserted. */ export const USER_PRESET_DIR = '.agent-presets' diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index be3ae1654b..a98eb92dd4 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -50,7 +50,7 @@ export const AgentPresetSettingsSchema: z = z.object({ default: z.string(), }) -export { COMPOSITION_FILE, discoverPresets, scanRoot, USER_PRESET_DIR } from './discovery.ts' +export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts' export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata, } from './metadata.ts' diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts index 0320d0c899..98c8123d1d 100644 --- a/packages/preset/agent-presets/tests/user-root.spec.ts +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -19,10 +19,12 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import AgentPresets, { COMPOSITION_FILE, USER_PRESET_DIR, type Config } from '@deepseek-ai/dsh-agent-presets' +import AgentPresets, { COMPOSITION_FILE, type Config } from '@deepseek-ai/dsh-agent-presets' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const SYSTEM_ROOT = join(FIXTURES, 'system') +/** Spelled out rather than imported: the convention is what these tests assert. */ +const USER_ROOT_SEGMENT = '.agent-presets' const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' let home: string @@ -56,8 +58,8 @@ async function roster(config: Partial = {}): Promise { /** Hand-place a preset directory under the harness home's preset root. */ async function seedHomePreset(id: string): Promise { - await mkdir(join(home, USER_PRESET_DIR, id), { recursive: true }) - await writeFile(join(home, USER_PRESET_DIR, id, COMPOSITION_FILE), VALID) + await mkdir(join(home, USER_ROOT_SEGMENT, id), { recursive: true }) + await writeFile(join(home, USER_ROOT_SEGMENT, id, COMPOSITION_FILE), VALID) } describe('the harness-home preset root', () => { @@ -79,7 +81,7 @@ describe('the harness-home preset root', () => { expect(listed.find(preset => preset.id === 'mine')).toMatchObject({ trust: 'user' }) expect((await ctx.agentPresets.resolve('mine')).path) - .toBe(join(home, USER_PRESET_DIR, 'mine', COMPOSITION_FILE)) + .toBe(join(home, USER_ROOT_SEGMENT, 'mine', COMPOSITION_FILE)) }) it('makes a roster with only a system root authorable, and receives the copy', async () => { @@ -88,7 +90,7 @@ describe('the harness-home preset root', () => { expect(ctx.agentPresets.authorable).toBe(true) await ctx.agentPresets.copy('standard', 'copied') - expect(existsSync(join(home, USER_PRESET_DIR, 'copied', COMPOSITION_FILE))).toBe(true) + expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied', COMPOSITION_FILE))).toBe(true) }) it('sorts after every configured root, so a shipped id still shadows a home directory', async () => { @@ -124,6 +126,6 @@ describe('the harness-home preset root', () => { await ctx.agentPresets.copy('standard', 'copied') expect(existsSync(join(explicit, 'copied', COMPOSITION_FILE))).toBe(true) - expect(existsSync(join(home, USER_PRESET_DIR, 'copied'))).toBe(false) + expect(existsSync(join(home, USER_ROOT_SEGMENT, 'copied'))).toBe(false) }) })