feat(agent-presets): own the writable preset root instead of awaiting an app
`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: `<dshHome>/.agent-presets` is the same place in every deployment, resolvable here the way `dsh-skill-local` resolves `<dshHome>/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.
This commit is contained in:
@@ -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: 28b9a31ed41e5fc41e38d6b0349c5bd9cbaeed9d
|
||||
README.zh.md: 1d8d1481c20005b9e7fed5341aa26dca63dcd815
|
||||
README.md: fb140e6ba7330b88b3c4fede775e3967a3ece0eb
|
||||
README.zh.md: ceb8f0284cba25110bd95e20d597942e0b919446
|
||||
|
||||
@@ -86,9 +86,20 @@ Every read failure degrades to no metadata — absent, malformed, wrongly typed,
|
||||
|---|---|---|
|
||||
| `default` | required | Preset id mounted when a caller names none |
|
||||
| `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) |
|
||||
| `includeUserRoot` | `true` | Append `<dshHome>/.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
|
||||
|
||||
`<dshHome>/.agent-presets` is where a person's own presets live, the way `<dshHome>/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 `<dshHome>` 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:
|
||||
|
||||
@@ -86,9 +86,20 @@ description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agen
|
||||
|---|---|---|
|
||||
| `default` | 必填 | 调用方未指定时挂载的 preset id |
|
||||
| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) |
|
||||
| `includeUserRoot` | `true` | 在全部已配置根目录之后,追加 `<dshHome>/.agent-presets` 作为 `user` 根目录 |
|
||||
|
||||
根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。
|
||||
|
||||
### 可写根目录属于本包,随附根目录属于 app
|
||||
|
||||
`<dshHome>/.agent-presets` 是个人自有 preset 的所在,正如 `<dshHome>/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 的测试同样需要——否则将由这台机器真实的 `<dshHome>` 决定 roster 的内容。
|
||||
|
||||
随附根目录仍然是装配事实:它位于已安装 app 自身配置的旁边,那个路径只有该 app 能解析。
|
||||
|
||||
### 默认 preset 是一项用户设置
|
||||
|
||||
当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值:
|
||||
|
||||
@@ -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
|
||||
* `<dshHome>/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.
|
||||
*
|
||||
|
||||
@@ -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<AgentPresetSettings> = 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<Config>
|
||||
|
||||
/**
|
||||
* 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<AgentPreset[]> {
|
||||
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<void> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -31,7 +31,7 @@ async function harness(): Promise<Context> {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
|
||||
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false })
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(AgentPresetsInvariant)
|
||||
return ctx
|
||||
|
||||
@@ -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<Context> {
|
||||
async function harness(roster: Config = { default: 'standard', roots: ROOTS, includeUserRoot: false }): Promise<Context> {
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
129
packages/preset/agent-presets/tests/user-root.spec.ts
Normal file
129
packages/preset/agent-presets/tests/user-root.spec.ts
Normal file
@@ -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 `<dshHome>/.agent-presets`, the way `dsh-skill-local` owns
|
||||
* `<dshHome>/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<Config> = {}): Promise<Context> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user