mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
|
||||
2026-08-03-per-session-agent-presets.md: dbe4188fe16a01b51f01cddf8a2387471e5e00a9
|
||||
2026-08-03-per-session-agent-presets.zh.md: aa7792a7db3a686b74f19ace130ad8f7b6d2feb6
|
||||
2026-08-03-per-session-agent-presets.md: dbf2f7c3de1447382071ebcfb1d6b9abe9640210
|
||||
2026-08-03-per-session-agent-presets.zh.md: e34018f713144c6457c86d5ce4b533106a7e5372
|
||||
|
||||
@@ -25,8 +25,12 @@ Model routing stays out of presets. `installAgentLlmTarget` is already the per-a
|
||||
|
||||
Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance.
|
||||
|
||||
Which preset an unnamed session gets is a user setting (`agent-presets.default`) layered over the composition's own `default`, which becomes the `base`. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a `cordis.yml` they may not own.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session header enforces from the other side — the header records the id a session actually runs, so a resume rebuilds that composition rather than today's default, and the gateway rejects an attempt to adopt a live session under a different one. A snapshot would make the two disagree at exactly the moment the setting changes.
|
||||
|
||||
**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it.
|
||||
|
||||
**A preset can only name a group because the app registers one.** Sharing a realm across rows is a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. `boot()` therefore registers `cordis:group` beside `cordis:include` as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the `isolate` vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers.
|
||||
@@ -35,6 +39,8 @@ Mounting is per-session by default. Measured cost for a twelve-row composition i
|
||||
|
||||
**Failure rolls the agent back.** `setup` runs before publication, so a rejected mount fails `ctx.agents.create()` and leaves nothing behind. This is why `setup` is the one supported call site.
|
||||
|
||||
**A test that the preset file is never rewritten has to be able to fail.** The first version asserted the file was unchanged after an ordinary mount, and could not have caught anything: the Loader only reaches its write path when it decides the config changed, and nothing in that composition ever self-disposed. The regression plants a row that disposes itself — the shape a real preset hits every time an agent is torn down — and keeps the composition in a temp root rather than under `fixtures/`, because without the override the Loader rewrites the file it read: a committed fixture would be damaged by the very run that proves the bug, and every run after it would compare against the damaged file and pass.
|
||||
|
||||
**Fiber membership is object identity, not `uid`.** A `uid` is a per-registry counter, so fibers in two different roots collide on it; comparing by `uid` made one runtime's subtree answer for a service published in another. `ctx.plugin()` returns a thenable `Object.create(fiber)` wrapper that is never identical to the fiber in a parent chain, so the subtree captures its own fiber during construction.
|
||||
|
||||
**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state.
|
||||
|
||||
@@ -25,8 +25,13 @@ Status: implemented
|
||||
|
||||
挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。
|
||||
|
||||
未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。
|
||||
|
||||
## 后果
|
||||
|
||||
**有效默认值在每次解析时读取,从不快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session header 从另一侧执行的同一条——header 记录会话实际运行的 id,因此恢复重建的是那份组装而不是当下的默认值,网关也会拒绝把一个活着的会话收编到另一个 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
|
||||
|
||||
|
||||
**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。
|
||||
|
||||
**preset 能写出 group,是因为 app 注册了它。** 跨行共享 realm 就是一个 `cordis:group` 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 `@cordisjs/plugin-group`:Node 向上查找 `node_modules` 的路径从那里永远走不到 harness。因此 `boot()` 把 `cordis:group` 与 `cordis:include` 并排注册为 loader builtin,两者都经由环境模块管线加载,而不依赖被包含树自身的说明符解析。没有它,上文那套 `isolate` 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。
|
||||
@@ -35,6 +40,8 @@ Status: implemented
|
||||
|
||||
**失败会让 agent 回滚。** `setup` 在发布之前运行,因此挂载被拒绝会让 `ctx.agents.create()` 失败且不留残留。这正是 `setup` 是唯一受支持调用点的原因。
|
||||
|
||||
**「preset 文件从不被回写」这条断言,必须先有失败的可能。** 最初那版在一次普通挂载之后断言文件未变,其实什么也抓不到:Loader 只在认定 config 变了时才会走到写路径,而那份组装里没有任何一行会自行销毁。回归用例改为植入一个自行销毁的行——真实 preset 在每次 agent 被拆除时都会命中的形状——并把组装放在临时根目录而不是 `fixtures/` 下:没有那个覆写,Loader 会回写它读入的文件,于是提交进仓库的 fixture 会被**恰恰是证明该缺陷的那次运行**改坏,之后每一次运行都拿改坏后的文件作比较从而通过。
|
||||
|
||||
**fiber 归属判定用对象同一性,而非 `uid`。** `uid` 是按 registry 计数的序号,因此两个不同根下的 fiber 会在它上面撞号;按 `uid` 比较曾导致一个运行时的子树为另一个运行时中发布的服务背锅。`ctx.plugin()` 返回的是 thenable 的 `Object.create(fiber)` 包装对象,与父链中出现的 fiber 永远不同一,因此子树在构造时捕获自己的 fiber。
|
||||
|
||||
**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -98,7 +98,7 @@ async mount(agentCtx: Context, id?: string): Promise<AgentPreset>
|
||||
serviceFor<K extends string & keyof Context>(agent: { ctx: Context }, name: K): Context[K] | undefined
|
||||
```
|
||||
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:39`](../../packages/preset/agent-presets/src/index.ts)
|
||||
Source: [`packages/preset/agent-presets/src/index.ts:54`](../../packages/preset/agent-presets/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
|
||||
@@ -333,9 +333,6 @@ flowchart TD
|
||||
pkg_credentials --> pkg_invariants
|
||||
pkg_frontend_static --> pkg_host_webserver
|
||||
pkg_frontend_static --> pkg_invariants
|
||||
pkg_agent_presets --> pkg_invariants
|
||||
pkg_agent_presets --> pkg_paths
|
||||
pkg_agent_presets --> pkg_scope
|
||||
pkg_helper --> pkg_brand
|
||||
pkg_helper --> pkg_invariants
|
||||
pkg_helper --> pkg_subprocess
|
||||
@@ -414,11 +411,13 @@ flowchart TD
|
||||
pkg_credentials_local --> pkg_credentials
|
||||
pkg_credentials_local --> pkg_invariants
|
||||
pkg_credentials_local --> pkg_paths
|
||||
pkg_host_apiproxy --> pkg_agent_presets
|
||||
pkg_host_apiproxy --> pkg_invariants
|
||||
pkg_lsp --> pkg_brand
|
||||
pkg_lsp --> pkg_invariants
|
||||
pkg_lsp --> pkg_llm
|
||||
pkg_agent_presets --> pkg_invariants
|
||||
pkg_agent_presets --> pkg_paths
|
||||
pkg_agent_presets --> pkg_scope
|
||||
pkg_agent_presets --> pkg_settings
|
||||
pkg_sandbox --> pkg_invariants
|
||||
pkg_sandbox --> pkg_llm
|
||||
pkg_settings_local --> pkg_atomic_write
|
||||
@@ -460,11 +459,6 @@ flowchart TD
|
||||
pkg_app_boot --> pkg_invariants
|
||||
pkg_app_boot --> pkg_paths
|
||||
pkg_app_boot --> pkg_system_prompt
|
||||
pkg_client_test_runtime --> pkg_client_runtime
|
||||
pkg_client_test_runtime --> pkg_client_ui_slots
|
||||
pkg_client_test_runtime --> pkg_client_web_react
|
||||
pkg_client_test_runtime --> pkg_host_apiproxy
|
||||
pkg_client_test_runtime --> pkg_invariants
|
||||
pkg_client_ui_layout --> pkg_client_runtime
|
||||
pkg_client_ui_layout --> pkg_client_ui_slots
|
||||
pkg_client_ui_layout --> pkg_client_ui_theme
|
||||
@@ -478,6 +472,8 @@ flowchart TD
|
||||
pkg_code_runtime_worker --> pkg_invariants
|
||||
pkg_code_runtime_worker --> pkg_session
|
||||
pkg_code_runtime_worker --> pkg_timeout
|
||||
pkg_host_apiproxy --> pkg_agent_presets
|
||||
pkg_host_apiproxy --> pkg_invariants
|
||||
pkg_host_directory_picker_browse --> pkg_client_locale
|
||||
pkg_host_directory_picker_browse --> pkg_client_runtime
|
||||
pkg_host_directory_picker_browse --> pkg_client_ui_primitives
|
||||
@@ -575,6 +571,11 @@ flowchart TD
|
||||
pkg_headless --> pkg_host_webserver
|
||||
pkg_headless --> pkg_invariants
|
||||
pkg_headless --> pkg_session
|
||||
pkg_client_test_runtime --> pkg_client_runtime
|
||||
pkg_client_test_runtime --> pkg_client_ui_slots
|
||||
pkg_client_test_runtime --> pkg_client_web_react
|
||||
pkg_client_test_runtime --> pkg_host_apiproxy
|
||||
pkg_client_test_runtime --> pkg_invariants
|
||||
pkg_time_context --> pkg_agent
|
||||
pkg_time_context --> pkg_invariants
|
||||
pkg_time_context --> pkg_session
|
||||
@@ -1140,7 +1141,6 @@ flowchart TD
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
|
||||
| [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||
| [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
|
||||
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1162,8 +1162,8 @@ flowchart TD
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
|
||||
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`settings`](../packages/settings/settings) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
@@ -1177,10 +1177,10 @@ flowchart TD
|
||||
| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) |
|
||||
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
|
||||
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
|
||||
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
@@ -1205,6 +1205,7 @@ flowchart TD
|
||||
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
|
||||
|
||||
@@ -201,10 +201,11 @@ export interface SessionsApi {
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*
|
||||
* `agentPreset` names the composition the new session's agent is built
|
||||
* from; omitted, the deployment's default preset applies. The resolved id
|
||||
* is stored on the session header, so a later resume rebuilds the same
|
||||
* agent. An unknown id fails with `agent-preset-not-found`, and a preset
|
||||
* whose composition cannot be mounted fails with `agent-preset-invalid`.
|
||||
* from; omitted, the effective default applies — the user's stored choice
|
||||
* where one exists, else the deployment's own. The resolved id is stored on
|
||||
* the session header, so a later resume rebuilds the same agent. An unknown
|
||||
* id fails with `agent-preset-not-found`, and a preset whose composition
|
||||
* cannot be mounted fails with `agent-preset-invalid`.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
|
||||
README.md: 1592fba1163448aaedbd482c56962fa07ebc897e
|
||||
README.zh.md: ef4512ab93783674890bc9a848e590792a820e8c
|
||||
README.md: 5e785b747209d4c0cedaebbd3a90ba1b46dcd1c6
|
||||
README.zh.md: 4c40d7b7bfabb83dba2859251ad189a2646170c6
|
||||
|
||||
@@ -30,6 +30,17 @@ The agent factory's `setup(agentCtx)` hook is the one supported call site. Only
|
||||
|
||||
An absent root supplies no presets rather than failing: the user root does not exist until the first locally authored preset, and naming a default no root supplies already fails loud at resolution.
|
||||
|
||||
### The default preset is a user setting
|
||||
|
||||
When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its composition base, so the user document layers over the deployment's engineering default:
|
||||
|
||||
```yaml
|
||||
agent-presets:
|
||||
default: core-web
|
||||
```
|
||||
|
||||
The value is read per resolution rather than snapshotted, so a hot-reloaded document takes effect on the next session created and every running session stays on the preset it was composed from. Clearing the user field re-inherits the composition default. A default naming a preset no root supplies is stored without complaint and fails at the next `resolve()` — the roster is a live directory, so a name absent now may exist by the time a session asks for it.
|
||||
|
||||
## What a mount rejects
|
||||
|
||||
A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it. `mount()` therefore proves the result usable itself, and rejects three things.
|
||||
|
||||
@@ -30,6 +30,17 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
|
||||
|
||||
根目录不存在时视为不提供任何 preset,而非失败:用户根目录在写出第一个本地 preset 之前并不存在,而指定了没有任何根目录提供的默认值,在解析时本就会明确报错。
|
||||
|
||||
### 默认 preset 是一项用户设置
|
||||
|
||||
当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其组装 base,因此用户文档会层叠覆盖部署方的工程默认值:
|
||||
|
||||
```yaml
|
||||
agent-presets:
|
||||
default: core-web
|
||||
```
|
||||
|
||||
该值在每次解析时读取而非快照,因此热重载的文档对**此后创建**的会话生效,而每个运行中的会话仍停留在它当初据以组装的 preset 上。清空用户字段即重新继承组装默认值。若默认值指向没有任何根目录提供的 preset,写入时不会报错,而在下一次 `resolve()` 时失败——名单是一个活动目录,此刻不存在的名字,等到某个会话真正索取时可能已经存在。
|
||||
|
||||
## 挂载会拒绝什么
|
||||
|
||||
直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有任何启动审计能覆盖它。`mount()` 因此自行校验结果可用,并拒绝三种情况。
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -45,6 +46,8 @@
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
9
packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js
vendored
Normal file
9
packages/preset/agent-presets/tests/fixtures/plugins/self-dispose.js
vendored
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
141
packages/preset/agent-presets/tests/settings.spec.ts
Normal file
141
packages/preset/agent-presets/tests/settings.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,9 @@
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -204,6 +204,9 @@ importers:
|
||||
'@deepseek-ai/dsh-loader-smoke':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/support/loader-smoke
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/settings/settings
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/core/system-prompt
|
||||
@@ -4202,6 +4205,12 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
'@deepseek-ai/dsh-settings-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings-local
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
|
||||
Reference in New Issue
Block a user