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

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

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

New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
2026-08-08 22:35:26 +08:00

537 lines
24 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { beforeEach, describe, expect, it } from 'vitest'
import AgentPresets, {
COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent,
} from '@deepseek-ai/dsh-agent-presets'
import type { Config } from '@deepseek-ai/dsh-agent-presets'
import { createScope, scopeOf, setScopeParent } from '@deepseek-ai/dsh-scope'
declare module 'cordis' {
interface Context {
/** Published by the `isolated` fixture preset behind an entry-local realm. */
fixtureIsolatedSvc: { label: string }
}
}
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const ROOTS = [
{ path: join(FIXTURES, 'system'), trust: 'system' as const },
{ path: join(FIXTURES, 'user'), trust: 'user' as const },
]
/**
* A composition carrying the registries a preset contributes to, plus the
* preset roster.
* @param roster - roster config, defaulting to the fixture roots.
* @returns the booted context.
*/
async function harness(roster: Config = { default: 'standard', roots: ROOTS }): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, roster)
return ctx
}
/** Create one agent composed from `presetId`, exactly as a factory `setup` would. */
async function agentOn(ctx: Context, id: string, presetId?: string): Promise<Agent> {
const handle = await ctx.agents.create({
sessionId: SessionId(id),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId),
})
return handle.agent
}
const toolNames = (ctx: Context, agent?: Agent): string[] =>
ctx.tools.schemas(agent).map(schema => schema.name).sort()
/** Every service registration in the runtime, regardless of which realm holds it. */
function providedServiceNames(ctx: Context): string[] {
const store = ctx.reflect.store
return Object.getOwnPropertySymbols(store)
.map(key => store[key]?.name)
.filter((name): name is string => name !== undefined)
}
/** Whether the root realm maps `name` to a live registration. */
function rootResolves(ctx: Context, name: string): boolean {
const key = ctx.root[Context.isolate][name]
return key !== undefined && ctx.reflect.store[key] !== undefined
}
let ctx: Context
beforeEach(async () => {
ctx = await harness()
})
describe('composing an agent from a preset', () => {
it('gives each session only its own preset\'s tools', async () => {
const alpha = await agentOn(ctx, 'sess-alpha', 'standard')
const beta = await agentOn(ctx, 'sess-beta', 'minimal')
expect(toolNames(ctx, alpha)).toEqual(['alpha'])
expect(toolNames(ctx, beta)).toEqual(['beta'])
expect(toolNames(ctx)).toEqual([])
})
it('scopes prompt sections and assembled schemas to the same session', async () => {
const alpha = await agentOn(ctx, 'sess-alpha', 'standard')
const beta = await agentOn(ctx, 'sess-beta', 'minimal')
const alphaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(alpha))
const betaPrompt = await ctx.systemPrompt.assemble(assembleContextFor(beta))
expect(alphaPrompt.sections.map(section => section.name)).toContain('preset:alpha')
expect(alphaPrompt.sections.map(section => section.name)).not.toContain('preset:beta')
expect(betaPrompt.sections.map(section => section.name)).toContain('preset:beta')
expect(alphaPrompt.tools.map(schema => schema.name)).toEqual(['alpha'])
})
it('mounts the default preset when the caller names none', async () => {
const agent = await agentOn(ctx, 'sess-default')
expect(toolNames(ctx, agent)).toEqual(['alpha'])
})
it('lets two sessions share one preset without colliding', async () => {
const first = await agentOn(ctx, 'sess-first', 'standard')
const second = await agentOn(ctx, 'sess-second', 'standard')
expect(toolNames(ctx, first)).toEqual(['alpha'])
expect(toolNames(ctx, second)).toEqual(['alpha'])
})
it('unwinds one session\'s composition without touching another\'s', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('sess-gone'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
const survivor = await agentOn(ctx, 'sess-stays', 'minimal')
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
await handle.dispose()
expect(ctx.agents.get(SessionId('sess-gone'))).toBeUndefined()
expect(toolNames(ctx, survivor)).toEqual(['beta'])
expect(toolNames(ctx)).toEqual([])
})
})
describe('rejecting a composition that cannot be used', () => {
it('refuses to mount into a context that carries no agent scope', async () => {
await expect(ctx.agentPresets.mount(ctx, 'standard'))
.rejects.toThrow(/unscoped context/)
})
it('rolls the whole agent back when a row fails to load', async () => {
await expect(agentOn(ctx, 'sess-broken', 'broken')).rejects.toThrow(/failed to mount/)
expect(ctx.agents.get(SessionId('sess-broken'))).toBeUndefined()
expect(toolNames(ctx)).toEqual([])
})
it('names every failed row, not just the count', async () => {
// The Loader folds several failed rows into one AggregateError whose own
// message names none of them; unflattened, the operator is told only that
// "loader entries failed to apply" and has nothing to act on.
await expect(agentOn(ctx, 'sess-two-broken', 'two-broken'))
.rejects.toThrow(/first-missing[\s\S]*second-missing/)
})
it('names the unresolved service when a row never activates', async () => {
await expect(agentOn(ctx, 'sess-pending', 'pending'))
.rejects.toThrow(/waiting for serviceThatDoesNotExist/)
})
it('rejects a row that publishes a process-global service', async () => {
await expect(agentOn(ctx, 'sess-leaky', 'leaky'))
.rejects.toThrow(/process-global service\(s\) \[aaaFixtureLeakedSvc, zzzFixtureLeakedSvc\]/)
// The rejected subtree is fully unwound, so its registrations are gone from
// the store rather than merely unreachable.
expect(providedServiceNames(ctx)).not.toContain('aaaFixtureLeakedSvc')
expect(providedServiceNames(ctx)).not.toContain('zzzFixtureLeakedSvc')
})
it('accepts the same provider behind an isolate realm', async () => {
const agent = await agentOn(ctx, 'sess-isolated', 'isolated')
expect(agent.id).toBe(SessionId('sess-isolated'))
// The provider ran, but under a realm-private symbol the root cannot reach.
expect(providedServiceNames(ctx)).toContain('fixtureIsolatedSvc')
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
})
it('addresses the standing instance of a realm-private service through either agent', async () => {
const first = await agentOn(ctx, 'sess-reach-a', 'isolated')
const second = await agentOn(ctx, 'sess-reach-b', 'isolated')
// The realm keeps the service out of every host context, so a caller
// holding the agent is how a request from OUTSIDE the session reads the
// instance it is about.
expect(rootResolves(ctx, 'fixtureIsolatedSvc')).toBe(false)
const mine = ctx.agentPresets.serviceFor(first, 'fixtureIsolatedSvc')
const theirs = ctx.agentPresets.serviceFor(second, 'fixtureIsolatedSvc')
expect(mine).toBeDefined()
// ONE composition per preset: both agents joined the same standing mount,
// so they address the same instance — sessions stay apart inside it by
// the plugin's own Session/Agent keying, not by instance count.
expect(theirs).toBe(mine)
})
it('answers undefined for a service the agent\'s preset does not mount', async () => {
// The isolated preset's standing instance exists in the same runtime, so
// the lookup finds the NAME and must still refuse it: the instance lives
// under another mount's fiber, not this agent's composition.
await agentOn(ctx, 'sess-reach-other', 'isolated')
const agent = await agentOn(ctx, 'sess-reach-none', 'standard')
expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined()
})
it('answers undefined for an agent outside the scope machinery', async () => {
// Unscoped, scoped-but-unparented, and parented to a key no live mount
// owns are the three ways a context can fail to name a standing mount;
// each is an answer, not a throw, because the caller asked a question.
expect(serviceForAgent(ctx, { ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
const loner = createScope(ctx, { test: 'loner' })
expect(serviceForAgent(ctx, { ctx: loner.ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
const orphan = createScope(ctx, { test: 'orphan' })
setScopeParent(scopeOf(orphan.ctx)!, { agentPreset: 'never-mounted' })
expect(serviceForAgent(ctx, { ctx: orphan.ctx }, 'fixtureIsolatedSvc')).toBeUndefined()
})
it('refuses to mount a preset directly into an unscoped context', async () => {
// The service's own mount() guards this before delegating; the exported
// function is callable on its own, so the boundary holds there too.
const preset = await ctx.agentPresets.resolve('standard')
await expect(mountPreset(ctx, preset)).rejects.toThrow(/unscoped context/)
})
it('reports the known ids when a preset is unknown', async () => {
await expect(ctx.agentPresets.resolve('nope'))
.rejects.toThrow(/preset "nope" not found \(available: .*standard/)
})
})
describe('the preset roster', () => {
it('lists every root\'s presets with the earlier root winning', async () => {
const listed = await ctx.agentPresets.list()
expect(listed.map(preset => preset.id).sort())
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'pending', 'standard', 'two-broken'])
expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system')
})
it('exposes the configured default id', () => {
expect(ctx.agentPresets.defaultId).toBe('standard')
})
})
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 expect(bare.agentPresets.resolve())
.rejects.toThrow(/preset "standard" not found \(available: none\)/)
})
})
describe('the preset file is an input, never a persistence target', () => {
it('survives a row that disposes itself, which makes the Loader persist a tree', async () => {
// The preset lives in a temp root, not under `fixtures/`: without the
// `write()` override the Loader REWRITES the composition it read, so a
// committed fixture would be mutated by the very run that proves the bug
// and every later run would compare against the damaged file and pass.
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-'))
const dir = join(root, 'self-disposing')
await mkdir(dir)
const path = join(dir, COMPOSITION_FILE)
const composition = [
'- id: tool-kept',
` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`,
' config:',
' tool: kept',
'- id: goes-away',
` name: ${join(FIXTURES, 'plugins', 'self-dispose.js')}`,
'',
].join('\n')
await writeFile(path, composition)
const scoped = new Context()
scoped.baseUrl = pathToFileURL(FIXTURES).href + '/'
await scoped.plugin(Loader)
scoped.loader.builtins.include = Include
await scoped.plugin(LlmService)
await scoped.plugin(SessionStore)
await scoped.plugin(SystemPrompt, { persona: '' })
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.agents.create({
sessionId: SessionId('sess-self-dispose'),
setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx),
})
await (globalThis as { __SELF_DISPOSED__?: Promise<unknown> }).__SELF_DISPOSED__
// Slack past the deterministic signal above, not a race the number has to
// win. The write rides the Loader's fiber-unload listener, which stamps
// `disabled: true` and calls `write()` in the same synchronous step; once
// the self-dispose has settled, a regression has already written. Polling
// would not help — the assertion is an ABSENCE, and no amount of waiting
// proves one — so the wait only has to clear settlement.
await new Promise(resolve => setTimeout(resolve, 50))
// Inherited, `EntryTree.write()` persists the dying tree — stamping
// `disabled: true` onto the row and, in the shipped case, truncating the
// composition every session shares.
expect(await readFile(path, 'utf8')).toBe(composition)
})
})
describe('attributing a service to a subtree', () => {
it('attributes nothing to a subtree that is already torn down', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('sess-torn'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
const [mount] = livePresetMounts().filter(entry => entry.presetId === 'standard')
expect(mount).toBeDefined()
await handle.dispose()
// A disposed subtree owns nothing, so it can never be blamed for a service
// some other subtree published under the same name afterwards.
expect(leakedServices(ctx, mount!.fiber)).toEqual([])
})
})
describe('replacing a composition', () => {
it('swaps the agent\'s tools without touching another session', async () => {
const keeper = await agentOn(ctx, 'sess-keeper', 'standard')
const handle = await ctx.agents.create({
sessionId: SessionId('sess-swap'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
expect(toolNames(ctx, handle.agent)).toEqual(['beta'])
expect(toolNames(ctx, keeper)).toEqual(['alpha'])
expect(toolNames(ctx)).toEqual([])
})
it('leaves the agent on its previous composition when the new one is unknown', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('sess-unknown'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'nope'))
.rejects.toThrow(/not found/)
// Resolution happens before any teardown, so an unknown id is a no-op.
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
})
it('restores the previous composition when the new one fails to mount', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('sess-restore'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
})
await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken'))
.rejects.toThrow(/failed to mount/)
// The swap is unmount-then-mount, so a failure must put the old one back
// rather than leave the agent with no tools at all.
expect(toolNames(ctx, handle.agent)).toEqual(['alpha'])
})
it('composes an agent that had nothing installed', async () => {
// An agent created without a preset has no subtree to discard, so the
// swap is a plain mount rather than a restore-on-failure path.
const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare') })
await ctx.agentPresets.recompose(handle.agent.ctx, 'minimal')
expect(toolNames(ctx, handle.agent)).toEqual(['beta'])
})
it('refuses a bare agent\'s broken composition without restoring anything', async () => {
const handle = await ctx.agents.create({ sessionId: SessionId('sess-bare-broken') })
await expect(ctx.agentPresets.recompose(handle.agent.ctx, 'broken'))
.rejects.toThrow(/failed to mount/)
// Nothing was installed, so there is nothing to put back.
expect(toolNames(ctx, handle.agent)).toEqual([])
})
it('keeps the agent on its standing composition when a switch fails, even with the source deleted', async () => {
// A preset root this test owns, so removing the composition mid-flight
// cannot disturb the shipped fixtures.
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-'))
const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', '- id: nope\n name: ./does-not-exist.js\n']]
for (const [id, body] of seeded) {
await mkdir(join(root, id))
await writeFile(join(root, id, COMPOSITION_FILE), body)
}
const scoped = new Context()
scoped.baseUrl = pathToFileURL(FIXTURES).href + '/'
await scoped.plugin(Loader)
scoped.loader.builtins.include = Include
await scoped.plugin(LlmService)
await scoped.plugin(SessionStore)
await scoped.plugin(SystemPrompt, { persona: '' })
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 }] })
const handle = await scoped.agents.create({
sessionId: SessionId('sess-restore-gone'),
setup: async (agentCtx: Context) => void await scoped.agentPresets.mount(agentCtx, 'first'),
})
// The roster is a live directory: the composition the agent came from can
// be gone from DISK by the time a switch fails. The standing mount is not
// the file — it outlives deletion, so there is nothing to "restore".
await rm(join(root, 'first'), { recursive: true })
await expect(scoped.agentPresets.recompose(handle.agent.ctx, 'broken'))
.rejects.toThrow(/failed to mount/)
// The failed switch left the agent EXACTLY as it was: the new standing
// mount is ensured before the parent link moves, so a rejection never
// strips the old composition.
expect(toolNames(scoped, handle.agent)).toEqual(['only'])
})
it('refuses an unscoped context', async () => {
await expect(ctx.agentPresets.recompose(ctx, 'minimal'))
.rejects.toThrow(/unscoped context/)
})
})
describe('editing a composition file', () => {
/** One-row composition whose single tool is named `tool`. */
const rowFor = (tool: string): string =>
`- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: ${tool}\n`
/**
* A context over a temp root holding one editable preset. The id is
* per-test because `livePresetMounts()` is a process-global registry.
*/
async function editable(id: string): Promise<{ scoped: Context; path: string }> {
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-'))
await mkdir(join(root, id))
const path = join(root, id, COMPOSITION_FILE)
await writeFile(path, rowFor('before'))
const scoped = await harness({ default: id, roots: [{ path: root, trust: 'user' as const }] })
return { scoped, path }
}
it('starts a new generation for later sessions while joined ones keep theirs', async () => {
const { scoped, path } = await editable('edited')
const first = await agentOn(scoped, 'sess-gen-first', 'edited')
expect(toolNames(scoped, first)).toEqual(['before'])
// Files are the only composition editor now (authoring is copy/delete),
// so the standing mount notices the file's stamp changing on its own.
await writeFile(path, rowFor('afterwards'))
const second = await agentOn(scoped, 'sess-gen-second', 'edited')
expect(toolNames(scoped, second)).toEqual(['afterwards'])
// The joined session keeps the generation it runs on.
expect(toolNames(scoped, first)).toEqual(['before'])
})
it('gives two sessions racing the refreshed file one shared new generation', async () => {
const { scoped, path } = await editable('raced')
await agentOn(scoped, 'sess-race-seed', 'raced')
await writeFile(path, rowFor('afterwards'))
// Whichever racer swaps the pointer first, the other must join it rather
// than fork a third generation off the same edit.
const [left, right] = await Promise.all([
agentOn(scoped, 'sess-race-left', 'raced'),
agentOn(scoped, 'sess-race-right', 'raced'),
])
expect(toolNames(scoped, left)).toEqual(['afterwards'])
expect(toolNames(scoped, right)).toEqual(['afterwards'])
expect(livePresetMounts().filter(mount => mount.presetId === 'raced')).toHaveLength(2)
})
it('hands a host reader the standing key without starting an agent', async () => {
const { scoped } = await editable('cold-read')
const key = await scoped.agentPresets.standingKeyFor('cold-read')
// The mount exists for the reader; no agent, session, or turn started.
expect(key).toEqual({ agentPreset: 'cold-read' })
expect(livePresetMounts().filter(mount => mount.presetId === 'cold-read')).toHaveLength(1)
expect(scoped.agents.get(SessionId('cold-read'))).toBeUndefined()
// A second reader resolves the same generation, not a new mount.
expect(await scoped.agentPresets.standingKeyFor('cold-read')).toBe(key)
})
it('refuses to mount a generation it cannot stamp', async () => {
const { scoped, path } = await editable('unstampable')
await rm(path)
// Discovery would refuse the preset too; a caller that resolved just
// before the deletion must get a mount failure, not an unstamped
// generation that no later edit could ever refresh.
const racer = scoped.agentPresets as unknown as {
ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise<unknown>
}
await expect(racer.ensureStanding({ id: 'unstampable', trust: 'user', path }))
.rejects.toThrow(PresetMountError)
expect(livePresetMounts().filter(mount => mount.presetId === 'unstampable')).toHaveLength(0)
})
it('keeps serving the mounted generation when the file cannot be statted', async () => {
const { scoped, path } = await editable('stale')
await agentOn(scoped, 'sess-stale-served', 'stale')
expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1)
await rm(path)
// Discovery refuses a preset whose composition cannot be statted, so the
// public route cannot reach this state — but a caller that resolved just
// before the deletion still can, and it must be served the standing
// generation rather than failed over a stat.
const racer = scoped.agentPresets as unknown as {
ensureStanding(preset: { id: string; trust: 'user'; path: string }): Promise<unknown>
}
await racer.ensureStanding({ id: 'stale', trust: 'user', path })
expect(livePresetMounts().filter(mount => mount.presetId === 'stale')).toHaveLength(1)
})
})