735 lines
32 KiB
TypeScript
735 lines
32 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, stat, 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 '@deepseek-ai/cordis'
|
|
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
|
import Include from '@deepseek-ai/cordis-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, vi } 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 type {} from '@deepseek-ai/dsh-agent-presets/types'
|
|
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
|
|
|
declare module '@deepseek-ai/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('hands an absolute plugin path to Node as a file URL', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-'))
|
|
const presetDir = join(root, 'absolute')
|
|
const plugin = join(FIXTURES, 'plugins', 'contribute.js')
|
|
await mkdir(presetDir)
|
|
await writeFile(
|
|
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 imported = vi.spyOn(scoped.loader.internal!, 'import')
|
|
|
|
await agentOn(scoped, 'sess-absolute-plugin')
|
|
|
|
expect(imported).toHaveBeenCalledWith(pathToFileURL(plugin).href, expect.any(String), {})
|
|
})
|
|
|
|
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('composing a child agent from its parent', () => {
|
|
/** Create one agent joined to `parent`'s composition, as a child creation window does. */
|
|
async function childOf(ctx: Context, id: string, parent: Agent): Promise<Agent> {
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId(id),
|
|
setup: (childCtx: Context) => void ctx.agentPresets.composeFrom(childCtx, parent.ctx),
|
|
})
|
|
return handle.agent
|
|
}
|
|
|
|
it('gives the child its parent\'s tools and prompt sections', async () => {
|
|
const parent = await agentOn(ctx, 'sess-parent', 'standard')
|
|
|
|
const child = await childOf(ctx, 'sess-child', parent)
|
|
|
|
expect(toolNames(ctx, child)).toEqual(['alpha'])
|
|
const prompt = await ctx.systemPrompt.assemble(assembleContextFor(child))
|
|
expect(prompt.sections.map(section => section.name)).toContain('preset:alpha')
|
|
})
|
|
|
|
it('joins the parent\'s own generation rather than remounting its preset', async () => {
|
|
const parent = await agentOn(ctx, 'sess-shared', 'standard')
|
|
const before = livePresetMounts().length
|
|
|
|
await childOf(ctx, 'sess-shared-child', parent)
|
|
|
|
// A remount would compose a second copy of every row in the preset; the
|
|
// child must run on the plugin instances its parent already runs on.
|
|
expect(livePresetMounts()).toHaveLength(before)
|
|
})
|
|
|
|
it('keeps the child composed after its parent is disposed', async () => {
|
|
const parentHandle = await ctx.agents.create({
|
|
sessionId: SessionId('sess-dying-parent'),
|
|
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'),
|
|
})
|
|
const child = await childOf(ctx, 'sess-orphan', parentHandle.agent)
|
|
|
|
await parentHandle.dispose()
|
|
|
|
// Standing mounts outlive the agents that joined them, so a child outliving
|
|
// its parent — a background subagent — keeps the composition it started on.
|
|
expect(toolNames(ctx, child)).toEqual(['alpha'])
|
|
})
|
|
|
|
it('reports the preset id the child joined, for the durable header', async () => {
|
|
const parent = await agentOn(ctx, 'sess-named', 'minimal')
|
|
|
|
const child = await childOf(ctx, 'sess-named-child', parent)
|
|
|
|
expect(ctx.agentPresets.composedPreset(parent.ctx)).toBe('minimal')
|
|
expect(ctx.agentPresets.composedPreset(child.ctx)).toBe('minimal')
|
|
})
|
|
|
|
it('composes nothing when the parent joined no preset', async () => {
|
|
// The rosterless deployment: model-facing rows sit in the host composition
|
|
// and the child already resolves them through the registry's global layer.
|
|
const bare = (await ctx.agents.create({ sessionId: SessionId('sess-bare-parent') })).agent
|
|
|
|
const child = await childOf(ctx, 'sess-bare-child', bare)
|
|
|
|
expect(ctx.agentPresets.composedPreset(bare.ctx)).toBeUndefined()
|
|
expect(ctx.agentPresets.composeFrom(child.ctx, bare.ctx)).toBeUndefined()
|
|
expect(toolNames(ctx, child)).toEqual([])
|
|
})
|
|
|
|
it('refuses to compose an unscoped context', async () => {
|
|
const parent = await agentOn(ctx, 'sess-unscoped-parent', 'standard')
|
|
|
|
expect(() => ctx.agentPresets.composeFrom(ctx, parent.ctx)).toThrow(/unscoped context/)
|
|
})
|
|
})
|
|
|
|
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' })
|
|
bindScopeParent(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()
|
|
|
|
// `not-a-preset` is the fixture ghost: no composition file, listed broken.
|
|
expect(listed.map(preset => preset.id).sort())
|
|
.toEqual(['broken', 'isolated', 'late', 'leaky', 'minimal', 'not-a-preset', 'pending', 'standard', 'two-broken'])
|
|
expect(listed.find(preset => preset.id === 'standard')?.trust).toBe('system')
|
|
expect(listed.find(preset => preset.id === 'not-a-preset')?.broken).toMatch(/is missing/)
|
|
})
|
|
|
|
it('exposes the configured default id', () => {
|
|
expect(ctx.agentPresets.defaultId).toBe('standard')
|
|
})
|
|
})
|
|
|
|
describe('composing from a broken preset', () => {
|
|
/** A roster whose only user preset carries `composition`. */
|
|
async function rosterWith(composition: string): Promise<Context> {
|
|
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 }] })
|
|
}
|
|
|
|
it('refuses the mount up front with the discovery-reported reason', async () => {
|
|
const scoped = await rosterWith('- id: x\n name: [unclosed\n')
|
|
|
|
// The refusal happens before the loader ever sees the file, so every
|
|
// unloadable shape gets the same early PresetMountError — and a rejected
|
|
// setup rolls the whole agent creation back.
|
|
await expect(agentOn(scoped, 'sess-broken', 'damaged')).rejects.toThrow(PresetMountError)
|
|
await expect(agentOn(scoped, 'sess-broken-2', 'damaged')).rejects.toThrow(/not valid YAML/)
|
|
expect(livePresetMounts().filter(mount => mount.presetId === 'damaged')).toHaveLength(0)
|
|
})
|
|
|
|
it('refuses the standing key a cold reader would mount by', async () => {
|
|
const scoped = await rosterWith('rows: not-a-list\n')
|
|
|
|
await expect(scoped.agentPresets.standingKeyFor('damaged'))
|
|
.rejects.toThrow(/top-level list of plugin rows/)
|
|
})
|
|
|
|
it('still resolves the broken row for the surfaces that manage it', async () => {
|
|
const scoped = await rosterWith('- id: x\n name: [unclosed\n')
|
|
|
|
// Deleting and reporting need the row; only composing refuses it.
|
|
expect((await scoped.agentPresets.resolve('damaged')).broken).toMatch(/not valid YAML/)
|
|
})
|
|
})
|
|
|
|
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('publishes a committed preset selection for remote consumers', async () => {
|
|
const agent = await agentOn(ctx, 'sess-selected', 'standard')
|
|
const selected: Array<[SessionId, string]> = []
|
|
ctx.on('agent-preset/selected', (sessionId, agentPreset) => {
|
|
selected.push([sessionId, agentPreset])
|
|
})
|
|
|
|
agent.session.append('agent-preset/selected', { agentPreset: 'minimal' })
|
|
|
|
expect(selected).toEqual([[SessionId('sess-selected'), 'minimal']])
|
|
})
|
|
|
|
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('names an agent that was published without joining any preset', async () => {
|
|
const ctx = await harness()
|
|
const warnings: string[] = []
|
|
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
|
|
|
await ctx.agents.create({ sessionId: SessionId('sess-unjoined-warn') })
|
|
// Advisory, not fatal: a synchronous `agent/created` throw would veto
|
|
// publication, and creating an agent outside the roster stays legal.
|
|
expect(warnings.filter(line => line.includes('sess-unjoined-warn'))).toHaveLength(1)
|
|
expect(warnings.at(-1)).toMatch(/without joining an agent preset/)
|
|
|
|
warnings.length = 0
|
|
await agentOn(ctx, 'sess-joined-quiet', 'minimal')
|
|
expect(warnings).toEqual([])
|
|
})
|
|
|
|
it('says nothing when the deployment configures no roster at all', 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.
|
|
const rosterless = await harness({ default: 'standard', roots: [] })
|
|
const warnings: string[] = []
|
|
rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn
|
|
|
|
await rosterless.agents.create({ sessionId: SessionId('sess-no-roster') })
|
|
|
|
expect(warnings).toEqual([])
|
|
})
|
|
|
|
it('composes an agent that had nothing installed', async () => {
|
|
// An agent created without a preset has no binding to re-link, so the
|
|
// switch is its first bind — exactly a mount — and once bound only the
|
|
// roster's kept binding can move it again.
|
|
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('keeps a newer generation pointer when a stale refresh loses the swap race', async () => {
|
|
const { scoped, path } = await editable('guarded-refresh')
|
|
const preset = await scoped.agentPresets.resolve('guarded-refresh')
|
|
await agentOn(scoped, 'sess-guarded-refresh-seed', 'guarded-refresh')
|
|
const service = scoped.agentPresets as unknown as {
|
|
standing: Map<string, Promise<{
|
|
key: unknown
|
|
scope: unknown
|
|
stamp: { mtimeMs: number; size: number }
|
|
}>>
|
|
ensureStanding(current: typeof preset): Promise<unknown>
|
|
}
|
|
const stalePromise = service.standing.get(preset.id)!
|
|
const stale = await stalePromise
|
|
await writeFile(path, rowFor('afterwards'))
|
|
const { mtimeMs, size } = await stat(path)
|
|
const newer = { ...stale, stamp: { mtimeMs, size } }
|
|
const newerPromise = Promise.resolve(newer)
|
|
|
|
// `await pending` yields before the guarded delete, letting the winning
|
|
// refresher replace the pointer deterministically instead of by timing.
|
|
const refresh = service.ensureStanding(preset)
|
|
service.standing.set(preset.id, newerPromise)
|
|
|
|
expect(await refresh).toBe(newer)
|
|
expect(service.standing.get(preset.id)).toBe(newerPromise)
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|