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:
@@ -40,6 +40,7 @@
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
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'
|
||||
@@ -7,7 +8,8 @@ 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 type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { 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))
|
||||
@@ -19,9 +21,15 @@ const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml')
|
||||
* 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(): Promise<Context> {
|
||||
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 },
|
||||
@@ -37,6 +45,8 @@ async function bootWeb(): Promise<Context> {
|
||||
{ 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' }] },
|
||||
@@ -50,7 +60,9 @@ const toolNames = (ctx: Context, agent?: Agent): string[] =>
|
||||
|
||||
let ctx: Context
|
||||
beforeAll(async () => {
|
||||
ctx = await bootWeb()
|
||||
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', () => {
|
||||
@@ -195,6 +207,43 @@ describe('a forked session', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 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({
|
||||
|
||||
Reference in New Issue
Block a user