mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
A composition is a file, but "edit it on the filesystem" is not a browser affordance. The roster gains `read`/`write`/`remove` beside `select`, and the browser gains a settings section over them: the presets as rows, one composition open in a YAML editor at a time, and per-row default, duplicate, and delete. All four authoring methods are loopback-pinned. A composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. `agentPreset.list` deliberately stays ordinary and now reports `authorable`, so a surface knows whether creating is possible at all rather than offering a button whose save always fails. Authoring starts by duplicating: a shipped preset opens read-only because the deployment's copy is what a broken local one is compared against. Ids are contained before they become directory names, and the text is parsed with the loader's own schema, so a save cannot leave a file no session could load. Fixes a defect the real-composition test found: a preset written under the user's home could never mount, because the loader resolves a row against the composition's own directory and Node's `node_modules` walk from there never reaches the installed harness. The mount now records the host base and sends bare specifiers there, leaving relative paths resolving from the preset. Also closes the coverage the earlier surfaces in this stack shipped without — the General row, the composer seat, and the plugin halves now have tests.
394 lines
16 KiB
TypeScript
394 lines
16 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 } from '@deepseek-ai/dsh-agent-presets'
|
|
import type { Config } from '@deepseek-ai/dsh-agent-presets'
|
|
|
|
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 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 one agent\'s instance of a realm-private service', 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 — that is what
|
|
// makes it per session — so a caller holding the agent is the only way a
|
|
// request from OUTSIDE the session can read 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()
|
|
expect(theirs).toBeDefined()
|
|
// Each agent gets ITS own: the addressing is per subtree, not a lookup
|
|
// that happens to find the first match.
|
|
expect(mine).not.toBe(theirs)
|
|
})
|
|
|
|
it('answers undefined for a service the agent\'s preset does not mount', async () => {
|
|
const agent = await agentOn(ctx, 'sess-reach-none', 'standard')
|
|
|
|
expect(ctx.agentPresets.serviceFor(agent, 'fixtureIsolatedSvc')).toBeUndefined()
|
|
})
|
|
|
|
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'])
|
|
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('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('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('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('reports the switch failure even when the restore also fails', async () => {
|
|
// The previous preset's whole directory disappears between the unmount
|
|
// and the restore. The caller still needs to hear why the switch was
|
|
// refused rather than why putting the old one back did not work.
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-vanishing-'))
|
|
await mkdir(join(root, 'vanishing'), { recursive: true })
|
|
// An absolute plugin path, because a relative one resolves from the
|
|
// preset's own directory and this preset does not live beside the fixtures.
|
|
await writeFile(join(root, 'vanishing', COMPOSITION_FILE), [
|
|
'- id: alpha',
|
|
` name: ${join(FIXTURES, 'plugins', 'contribute.js')}`,
|
|
' config:',
|
|
' tool: vanishing',
|
|
'',
|
|
].join('\n'))
|
|
const local = await harness({
|
|
default: 'vanishing',
|
|
roots: [{ path: root, trust: 'user' as const }, ...ROOTS],
|
|
})
|
|
const handle = await local.agents.create({
|
|
sessionId: SessionId('sess-vanishing'),
|
|
setup: async (agentCtx: Context) => void await local.agentPresets.mount(agentCtx, 'vanishing'),
|
|
})
|
|
await rm(root, { recursive: true, force: true })
|
|
|
|
await expect(local.agentPresets.recompose(handle.agent.ctx, 'broken'))
|
|
.rejects.toThrow(/failed to mount/)
|
|
})
|
|
|
|
it('refuses an unscoped context', async () => {
|
|
await expect(ctx.agentPresets.recompose(ctx, 'minimal'))
|
|
.rejects.toThrow(/unscoped context/)
|
|
})
|
|
})
|