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:
@@ -12,10 +12,25 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
|
||||
import { discoverPresets } from './discovery.ts'
|
||||
import { mountPreset, serviceForAgent } from './mount.ts'
|
||||
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
export const SETTINGS_NAMESPACE = 'agent-presets'
|
||||
|
||||
/** The user-writable slice of this plugin's config. */
|
||||
export interface AgentPresetSettings {
|
||||
/** Preset mounted when a session names none. */
|
||||
default?: string
|
||||
}
|
||||
|
||||
/** Runtime schema for the user-writable slice. */
|
||||
export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({
|
||||
default: z.string(),
|
||||
})
|
||||
|
||||
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
|
||||
export {
|
||||
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, type PresetMount,
|
||||
@@ -48,13 +63,39 @@ export class AgentPresets extends Service {
|
||||
})).default([]),
|
||||
}) as z<Config>
|
||||
|
||||
/**
|
||||
* The user layer over `config.default`, present only while a settings
|
||||
* provider is composed. Held rather than snapshotted so a hot-reloaded
|
||||
* document takes effect without a restart.
|
||||
*/
|
||||
private settings: SettingsScope<AgentPresetSettings> | undefined
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentPresets')
|
||||
// Deliberately not `installSettingsSection`: that helper exists to re-judge
|
||||
// what a consumer DERIVED from the source — memoized resolutions,
|
||||
// registration-level facts — across attach, detach, and change. Nothing
|
||||
// here is derived. `defaultId` reads through on every call, so both of its
|
||||
// hooks would be no-ops and the source thunk would restate this field.
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
this.settings = settingsCtx.settings.register(
|
||||
settingsNamespace(SETTINGS_NAMESPACE),
|
||||
AgentPresetSettingsSchema,
|
||||
{ base: { default: config.default } },
|
||||
)
|
||||
settingsCtx.effect(() => () => { this.settings = undefined }, 'agentPresets.settings()')
|
||||
})
|
||||
}
|
||||
|
||||
/** The preset id mounted when a caller names none. */
|
||||
/**
|
||||
* The preset id mounted when a caller names none.
|
||||
*
|
||||
* Read per call rather than cached: the settings document is hot-reloaded, so
|
||||
* changing the default takes effect on the next session created and leaves
|
||||
* every running session on the preset it was composed from.
|
||||
*/
|
||||
get defaultId(): string {
|
||||
return this.config.default
|
||||
return this.settings?.get().default ?? this.config.default
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +113,7 @@ export class AgentPresets extends Service {
|
||||
* @throws when no configured root supplies that id.
|
||||
*/
|
||||
async resolve(id?: string): Promise<AgentPreset> {
|
||||
const wanted = id ?? this.config.default
|
||||
const wanted = id ?? this.defaultId
|
||||
const presets = await this.list()
|
||||
const found = presets.find(preset => preset.id === wanted)
|
||||
if (found === undefined) {
|
||||
|
||||
Reference in New Issue
Block a user