A third built-in preset: the standard coding agent plus the self-referential Cordis toolset, a persona that explains the two-plane split, and a skill teaching composition authoring. It exists so a person can ask an agent to author another agent. The skill ships INSIDE the preset directory rather than in the user's skill root, and the root is derived from the preset's own `baseUrl` — the loader evaluates `!!js` with `with (ctx)`, so a composition can locate itself. A preset is the unit that gets copied and edited, so its documentation should travel with it. The skill leads with the rule that actually bites: a row publishing a service may not sit loose in a preset, whether a row publishes one is not visible from its name (`tool-bash` provides `bashEnv`), and a consumer left outside its provider's isolate group resolves the host registry and then contributes nothing — the quietest failure this design has. Writing the test surfaced a consequence worth stating: an entry-local realm makes the service invisible to the agent's own scope too, not just to the host. Only rows inside that group resolve it, which is precisely what makes `tool-skill` this agent's own rather than a shared one. The test asserts what is actually observable from outside instead of reaching for the isolated service. TRUST: `cordis_mount` evaluates model-written JavaScript against the live runtime, and a composition this agent writes becomes a preset other sessions mount. Both the preset header and the toolset's own documentation say to treat this as shell access. The tools stay opt-in per session — a test pins that they are absent from every other preset.
346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { join } from 'node:path'
|
|
import { Context } from 'cordis'
|
|
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import type { PatchOptions } from '@cordisjs/plugin-include'
|
|
import { beforeAll, describe, expect, it } from 'vitest'
|
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
|
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
|
|
const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url))
|
|
const BASE_CONFIG = join(CONFIG_DIR, 'base.cordis.yml')
|
|
const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml')
|
|
|
|
/**
|
|
* Boot the shipped Web composition, minus the rows that would bind a port,
|
|
* touch the network, or write outside the test. Everything that decides an
|
|
* agent's capabilities is the real thing, including both shipped presets.
|
|
*/
|
|
async function bootWeb(settingsFile: string): Promise<Context> {
|
|
const patches: PatchOptions[] = [
|
|
...loadOverlayPatches('dsh-test', WEB_OVERLAY),
|
|
// The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
|
|
// reads the developer's own document — and since the default preset is a
|
|
// setting, a stored `agent-presets.default` would decide this file's
|
|
// outcome. Point it at a temp file for the same reason the roster below
|
|
// names only the shipped root.
|
|
{ id: 'settings', config: { path: settingsFile, watch: false } },
|
|
// Host rows with side effects outside this process: a bound port, a
|
|
// served asset tree, a telemetry exporter.
|
|
{ id: 'webserver', disabled: true },
|
|
{ id: 'telemetry-otel', disabled: true },
|
|
{ id: 'modules', disabled: true },
|
|
{ id: 'connection', disabled: true },
|
|
// NOT a side-effect row: the api-proxy cannot mount in THIS layer at all,
|
|
// because it injects `subagents` and the subagent registry moved into the
|
|
// presets here. That is the breakage a later layer returns to the host
|
|
// plane; when it does, this line comes out and the boot audit covers the
|
|
// whole host-plane injection graph again.
|
|
{ id: 'api-gateway', disabled: true },
|
|
{ id: 'directory-picker', disabled: true },
|
|
// The roster AppCLIEntry would patch in; only the shipped root, so a
|
|
// developer's own `~/.dsh/.preset` cannot change this test's outcome.
|
|
// `default` here is the COMPOSITION default — the base layer the settings
|
|
// document overrides.
|
|
{
|
|
id: 'agent-presets',
|
|
config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] },
|
|
},
|
|
]
|
|
return await boot('dsh-test', BASE_CONFIG, patches)
|
|
}
|
|
|
|
const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
|
ctx.tools.schemas(agent).map(schema => schema.name).sort()
|
|
|
|
let ctx: Context
|
|
beforeAll(async () => {
|
|
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
|
|
await writeFile(settingsFile, '{}\n')
|
|
ctx = await bootWeb(settingsFile)
|
|
}, 120_000)
|
|
|
|
describe('the shipped Web composition', () => {
|
|
it('leaves only the host UI tool in the global layer', () => {
|
|
// `ask_user_question` is the host's own interaction surface, not an agent
|
|
// capability, so it stays global. Every other tool now belongs to a
|
|
// preset; a regression here means an agent-plane row came back to base.
|
|
expect(toolNames(ctx)).toEqual(['ask_user_question'])
|
|
})
|
|
|
|
it('supplies both shipped presets, and only those, from the system root', async () => {
|
|
const listed = await ctx.agentPresets.list()
|
|
|
|
expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'core-web', 'standard'])
|
|
expect(listed.every(preset => preset.trust === 'system')).toBe(true)
|
|
expect(ctx.agentPresets.defaultId).toBe('standard')
|
|
})
|
|
|
|
it('composes the full agent from `standard`', async () => {
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('preset-standard'),
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
})
|
|
try {
|
|
// The EXACT catalog, not a spot-check: an omission is this design's
|
|
// quietest failure mode, because a row that registers into the wrong
|
|
// layer mounts cleanly and simply contributes nothing. `glob`/`grep` are
|
|
// excluded for the reason the TUI composition e2e excludes them — they
|
|
// depend on ripgrep being present on the machine.
|
|
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
|
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
|
'get_goal', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
|
|
'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill',
|
|
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
|
|
'workflow', 'write',
|
|
])
|
|
} finally {
|
|
await handle.dispose()
|
|
}
|
|
})
|
|
|
|
it('composes exactly two tools from `core-web`', async () => {
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('preset-core-web'),
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
|
})
|
|
try {
|
|
expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor'])
|
|
} finally {
|
|
await handle.dispose()
|
|
}
|
|
})
|
|
|
|
it('keeps two differently composed sessions independent', async () => {
|
|
const full = await ctx.agents.create({
|
|
sessionId: SessionId('preset-both-full'),
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
})
|
|
const minimal = await ctx.agents.create({
|
|
sessionId: SessionId('preset-both-minimal'),
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
|
})
|
|
try {
|
|
expect(toolNames(ctx, minimal.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor'])
|
|
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
|
|
|
await minimal.dispose()
|
|
|
|
// Tearing the minimal session down leaves the full one whole.
|
|
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
|
|
expect(toolNames(ctx)).toEqual(['ask_user_question'])
|
|
} finally {
|
|
await full.dispose()
|
|
}
|
|
})
|
|
|
|
it('composes the cordis agent with its own toolset', async () => {
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('preset-cordis'),
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined),
|
|
})
|
|
try {
|
|
const tools = toolNames(ctx, handle.agent)
|
|
// The self-referential toolset is what distinguishes this preset.
|
|
expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount']))
|
|
// And it keeps the standard agent's own tools rather than replacing them.
|
|
expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill']))
|
|
|
|
// The skill registry sits in this preset's entry-local realm, so it is
|
|
// invisible to the host AND to the agent's own scope — only the rows
|
|
// inside that group resolve it, which is what makes `tool-skill` the
|
|
// agent's own rather than a shared one.
|
|
expect(ctx.get('skills')).toBeUndefined()
|
|
} finally {
|
|
await handle.dispose()
|
|
}
|
|
})
|
|
|
|
it('keeps the self-referential toolset out of every other preset', async () => {
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('preset-no-cordis'),
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
})
|
|
try {
|
|
// Editing the live runtime is opt-in per session, not ambient.
|
|
expect(toolNames(ctx, handle.agent)).not.toContain('cordis_mount')
|
|
} finally {
|
|
await handle.dispose()
|
|
}
|
|
})
|
|
|
|
it('ships the composition-authoring skill inside the preset directory', async () => {
|
|
// The preset's skill root is derived from its own `baseUrl`, so the skill
|
|
// travels with the directory wherever the preset is installed.
|
|
const skill = join(
|
|
CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md',
|
|
)
|
|
|
|
expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true)
|
|
})
|
|
|
|
it('never rewrites the preset file it composed from', async () => {
|
|
// The Loader persists a tree whose plugin self-disposed, and tearing an
|
|
// agent down disposes its whole subtree. Inherited, that rewrote the
|
|
// shipped composition — truncating it to `[]` the first time a session
|
|
// ended — so `PresetTree` refuses to write at all.
|
|
const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml')
|
|
const before = await readFile(path, 'utf8')
|
|
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('preset-readonly'),
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
})
|
|
await handle.dispose()
|
|
// Slack, not a race the number has to win. The write is driven by the
|
|
// Loader's fiber-unload listener, which fires as the subtree's fibers
|
|
// settle rather than when `dispose()` resolves, and the Loader exposes no
|
|
// flush to await. A regression writes synchronously inside that listener,
|
|
// so any wait past settlement fails; a longer one only slows the test.
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
expect(await readFile(path, 'utf8')).toBe(before)
|
|
})
|
|
|
|
it('gives each session its own persona', async () => {
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('preset-persona'),
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
|
})
|
|
try {
|
|
const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent })
|
|
expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text)
|
|
.toContain('You are a coding agent powered by')
|
|
} finally {
|
|
await handle.dispose()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('a switch survives the session', () => {
|
|
it('records the choice so the log states what the agent runs', async () => {
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('preset-switch-logged'),
|
|
meta: { agentPreset: 'standard' },
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
|
|
})
|
|
try {
|
|
// The api-proxy's select does exactly this pair while the session is blank.
|
|
await ctx.agentPresets.recompose(handle.agent.ctx, 'core-web')
|
|
handle.agent.session.append('agent-preset/selected', { agentPreset: 'core-web' })
|
|
|
|
// The header keeps the creation fact; the log carries what it runs.
|
|
expect(handle.agent.session.header.agentPreset).toBe('standard')
|
|
expect(resolveSessionPreset(handle.agent.session)).toBe('core-web')
|
|
} finally {
|
|
await handle.dispose()
|
|
}
|
|
})
|
|
|
|
it('rebuilds a switched session from the log, not the creation header', () => {
|
|
// The exact shape a resume reads back from disk: the header says standard,
|
|
// the log records the switch the user made while the session was blank.
|
|
const rebuilt = resolveSessionPreset({
|
|
header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' },
|
|
events: [
|
|
{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'core-web' } },
|
|
{ type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
] as never,
|
|
})
|
|
|
|
// Reading the header alone would compose the creation-time preset over a
|
|
// history another one produced — the replay the blank-only lock prevents.
|
|
expect(rebuilt).toBe('core-web')
|
|
})
|
|
})
|
|
|
|
describe('a forked session', () => {
|
|
it('inherits the composition its seeded history was produced under', async () => {
|
|
const parent = await ctx.agents.create({
|
|
sessionId: SessionId('preset-fork-parent'),
|
|
meta: { agentPreset: 'core-web' },
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
|
})
|
|
const inherited = resolveSessionPreset(parent.agent.session)
|
|
const child = await ctx.agents.create({
|
|
sessionId: SessionId('preset-fork-child'),
|
|
meta: {
|
|
parentSession: SessionId('preset-fork-parent'),
|
|
seedLength: 0,
|
|
...inherited === undefined ? {} : { agentPreset: inherited },
|
|
},
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, inherited).then(() => undefined),
|
|
})
|
|
try {
|
|
// Composing nothing would leave the child empty: this layer moved every
|
|
// model-facing row out of the host plane, so there is nothing to inherit
|
|
// for free any more.
|
|
expect(toolNames(ctx, child.agent)).toEqual(toolNames(ctx, parent.agent))
|
|
expect(toolNames(ctx, child.agent).length).toBeGreaterThan(0)
|
|
} finally {
|
|
await child.dispose()
|
|
await parent.dispose()
|
|
}
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Which preset an unnamed session gets is a user setting layered over the
|
|
* composition's own default. The package suite proves the layering against a
|
|
* hand-built context; this proves it through the shipped `cordis.yml` — that
|
|
* the roster and the settings provider are actually wired to each other, and
|
|
* that the id the setting names is the one a session composes from.
|
|
*/
|
|
describe('the default preset as a user setting', () => {
|
|
it('composes an unnamed session from the stored default, not the composed one', async () => {
|
|
expect(ctx.agentPresets.defaultId).toBe('standard')
|
|
|
|
await ctx.settings.update(settingsNamespace(SETTINGS_NAMESPACE), { default: 'core-web' })
|
|
try {
|
|
expect(ctx.agentPresets.defaultId).toBe('core-web')
|
|
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('preset-user-default'),
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
|
})
|
|
try {
|
|
// `mount()` with no id resolves the effective default. Two tools, not
|
|
// `standard`'s catalog: the setting decided the composition.
|
|
expect(toolNames(ctx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor'])
|
|
} finally {
|
|
await handle.dispose()
|
|
}
|
|
} finally {
|
|
// The context is shared with the rest of the file. `replace({})` drops
|
|
// the user section wholesale so the field re-inherits the composition
|
|
// base; `update` merges, and would leave the override standing.
|
|
await ctx.settings.replace(settingsNamespace(SETTINGS_NAMESPACE), {})
|
|
}
|
|
|
|
expect(ctx.agentPresets.defaultId).toBe('standard')
|
|
})
|
|
})
|
|
|
|
describe('a session keeps the preset it was created with', () => {
|
|
it('records the preset the gateway guard reads', async () => {
|
|
const handle = await ctx.agents.create({
|
|
sessionId: SessionId('preset-locked'),
|
|
meta: { agentPreset: 'core-web' },
|
|
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'core-web').then(() => undefined),
|
|
})
|
|
try {
|
|
// The api-proxy guard reads exactly this: the header records what the
|
|
// session runs, so naming anything else is a caller error rather than a
|
|
// switch. Its history was produced under `core-web`'s two tools.
|
|
expect(handle.agent.session.header.agentPreset).toBe('core-web')
|
|
} finally {
|
|
await handle.dispose()
|
|
}
|
|
})
|
|
})
|