feat(agent-presets): make the default preset a user setting

`config.default` becomes the composition base of an `agent-presets` settings
namespace, so the user document layers over the deployment's engineering
default and a person can change which preset new sessions get without a
restart.

The value is read per resolution rather than snapshotted: a hot-reloaded
document takes effect on the next session created, and every running session
stays on the preset it was composed from — which is the same rule the
session-header guard enforces from the other side.

`resolve()` read `config.default` directly, which would have made the whole
setting inert; it now goes through `defaultId` like every other caller.

The write-protection test is rewritten against a temp profile root. It was
passing vacuously: the un-overridden Loader REWRITES the composition it read —
stamping `disabled: true` onto the self-disposing row — so the committed
fixture had been mutated by the very run that proved the bug, and every later
run compared against the damaged file and passed. Building the preset in a
temp directory makes the assertion immune to its own failure mode, and it now
fails with a visible `+ disabled: true` when the override is removed.

Review follow-ups on this layer. The exported schema is
`AgentPresetSettingsSchema`, symmetric with the `AgentPresetSettings`
interface it resolves and self-describing at an import site. The `session.create`
JSDoc promised "the deployment's default preset" for an omitted `agentPreset`,
which this layer makes false — it now names the effective default. The
constructor records why it does not use `installSettingsSection`: that helper
re-judges what a consumer DERIVED across attach and detach, and nothing here is
derived. The provider-unload test disposes the fiber `ctx.plugin()` handed back
instead of reaching into `ctx.reflect.store`, and the write-protection wait says
why slack is the right shape for an absence assertion.

The real composition covers the layering too. `apps/cli` boots the shipped
`cordis.yml`, stores `agent-presets.default`, and asserts an unnamed session
composes from it — the package suite proves the layering against a hand-built
context, this proves the roster and the settings provider are wired to each
other. That test also pins the settings row at a temp file: it defaulted to
`$DSH_HOME/settings.yaml`, so a developer's own stored default decided the
outcome of a file whose whole point is that only the shipped root does.

The Agent Note records the per-resolution read and its correspondence with the
session header, and the vacuous-test finding above.
This commit is contained in:
Yichen Jiang
2026-08-04 00:07:11 +08:00
parent 5ed79887fb
commit 8d06b2d576
18 changed files with 378 additions and 30 deletions

View File

@@ -0,0 +1,9 @@
// Disposes itself once active. The Loader treats a self-disposing entry as a
// config change and writes the tree back through `EntryTree.write()`, which is
// the exact path that once truncated a preset file to `[]`.
export const name = 'self-dispose'
export function apply(ctx) {
globalThis.__SELF_DISPOSED__ = new Promise((resolve) => {
setTimeout(() => { ctx.fiber.dispose(); resolve(undefined) }, 0)
})
}

View File

@@ -1,3 +1,5 @@
import { mkdir, mkdtemp, readFile, 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'
@@ -10,7 +12,7 @@ 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, { leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts } from '@deepseek-ai/dsh-agent-presets'
declare module 'cordis' {
interface Context {
@@ -232,3 +234,56 @@ describe('attributing a service to a subtree', () => {
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)
})
})

View File

@@ -0,0 +1,141 @@
/**
* The default preset is a user setting. `config.default` is the deployment's
* engineering default; the settings document overrides it and is hot-reloaded,
* so a person can change which preset new sessions get without a restart.
*/
import { mkdtemp, 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 from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { describe, expect, it } from 'vitest'
import AgentPresets, { SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }]
const NS = settingsNamespace(SETTINGS_NAMESPACE)
/**
* A composition with a real file-backed settings provider. `settingsFiber` is
* the provider's own handle, so a test can take it away the way a reload does.
*/
async function harness(): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> {
const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-'))
const settingsFile = join(home, 'settings.yaml')
await writeFile(settingsFile, '{}\n')
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: [] })
const settingsFiber = ctx.plugin(SettingsLocal, { path: settingsFile, watch: false })
await settingsFiber
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS })
return { ctx, settingsFile, settingsFiber }
}
const toolNames = (ctx: Context, agent?: unknown): string[] =>
ctx.tools.schemas(agent as never).map(schema => schema.name).sort()
describe('the default preset as a user setting', () => {
it('falls back to the composition default while the user set none', async () => {
const { ctx } = await harness()
expect(ctx.agentPresets.defaultId).toBe('standard')
})
it('takes the user default over the composition default', async () => {
const { ctx } = await harness()
await ctx.settings.update(NS, { default: 'minimal' })
expect(ctx.agentPresets.defaultId).toBe('minimal')
})
it('composes a new session from the user default', async () => {
const { ctx } = await harness()
await ctx.settings.update(NS, { default: 'minimal' })
const handle = await ctx.agents.create({
sessionId: SessionId('settings-default'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx),
})
try {
expect(toolNames(ctx, handle.agent)).toEqual(['beta'])
} finally {
await handle.dispose()
}
})
it('leaves a running session on the preset it was composed from', async () => {
const { ctx } = await harness()
const running = await ctx.agents.create({
sessionId: SessionId('settings-running'),
setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx),
})
try {
expect(toolNames(ctx, running.agent)).toEqual(['alpha'])
// Changing the default mid-flight must not reach an agent that already
// composed: its history was produced under `standard`'s tools.
await ctx.settings.update(NS, { default: 'minimal' })
expect(ctx.agentPresets.defaultId).toBe('minimal')
expect(toolNames(ctx, running.agent)).toEqual(['alpha'])
} finally {
await running.dispose()
}
})
it('re-inherits the composition default when the user setting is cleared', async () => {
const { ctx } = await harness()
await ctx.settings.update(NS, { default: 'minimal' })
expect(ctx.agentPresets.defaultId).toBe('minimal')
await ctx.settings.replace(NS, {})
expect(ctx.agentPresets.defaultId).toBe('standard')
})
it('reports an unknown user default only when a session tries to use it', async () => {
const { ctx } = await harness()
// Storing it succeeds — the roster is a live directory, so a name that is
// absent now may exist by the time a session asks for it.
await ctx.settings.update(NS, { default: 'no-such-preset' })
await expect(ctx.agentPresets.resolve())
.rejects.toThrow(/preset "no-such-preset" not found/)
})
})
describe('a settings provider that goes away', () => {
it('falls back to the composition default when the provider unloads', async () => {
const { ctx, settingsFiber } = await harness()
await ctx.settings.update(NS, { default: 'minimal' })
expect(ctx.agentPresets.defaultId).toBe('minimal')
// Unloading the provider takes the user layer with it; the roster keeps
// working on its composition default rather than holding a stale override.
await settingsFiber.dispose()
expect(ctx.agentPresets.defaultId).toBe('standard')
})
})