424 lines
18 KiB
TypeScript
424 lines
18 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 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 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', '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('attributing a service to a subtree', () => {
|
|
it('never writes the preset file back, however the subtree changes', async () => {
|
|
delete (globalThis as { __RECONFIGURE__?: unknown }).__RECONFIGURE__
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('sess-write'),
|
|
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
|
|
})
|
|
const file = join(FIXTURES, 'system', 'standard', 'agent.cordis.yml')
|
|
const before = await readFile(file, 'utf8')
|
|
|
|
// The inherited `write()` persists the whole tree whenever the Loader
|
|
// decides a row's config moved, so one row reconfiguring itself would
|
|
// rewrite the shipped composition — here, with the row's new tool name.
|
|
const reconfigure = (globalThis as { __RECONFIGURE__?: (tool: string) => Promise<void> }).__RECONFIGURE__
|
|
expect(reconfigure).toBeTypeOf('function')
|
|
await reconfigure!('rewritten')
|
|
|
|
expect(toolNames(ctx, handle.agent)).toContain('rewritten')
|
|
expect(await readFile(file, 'utf8')).toBe(before)
|
|
|
|
await handle.dispose()
|
|
})
|
|
|
|
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/)
|
|
})
|
|
})
|