mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-12 04:01:20 +00:00
Merge remote-tracking branch 'origin/master' into worktree/composer-plus-menu
This commit is contained in:
+2
-2
@@ -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: 14d568689a662e3e7d1fedf26c22aea6faddd56b
|
||||
2026-08-03-per-session-agent-presets.zh.md: 9b296fa7a1c2b9164d9e7a0ad676929179a64fef
|
||||
2026-08-03-per-session-agent-presets.md: 8af48979b49f08c8e3ac945f648acbb615757a98
|
||||
2026-08-03-per-session-agent-presets.zh.md: 2889487e989d093c162848c3c972d46fece3726d
|
||||
|
||||
@@ -27,11 +27,11 @@ The presets the deployment ships are the directories under `packages/preset/agen
|
||||
|
||||
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.
|
||||
The `agent-presets` user-settings namespace carries `modeSelectionEnabled` and `default`. `modeSelectionEnabled` defaults to `true`: the existing new-session picker remains present and an unnamed session resolves to the saved user `default`, or the composition's deployment `default` when none exists. The Web Settings toggle changes only that policy: disabling selection temporarily uses the deployment default, while re-enabling it restores the saved user `default`. This is a deliberate exception to the ordinary user-over-composition settings precedence established in [#1539](https://github.com/deepseek-harness/deepseek-harness/pull/1539): hiding the chooser disables the user's mode-selection policy without deleting its saved value. The Host policy governs every later session whose caller omits a preset; explicitly named presets and existing sessions remain unchanged. The composition value also keeps the package working with no settings provider, while an enabled user override changes later sessions without editing a deployment-owned `cordis.yml`.
|
||||
|
||||
## 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 log enforces from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes.
|
||||
**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. The Host setting itself applies when an unnamed session is resolved afterwards. An explicit Web Settings action additionally routes its accepted effective default through the existing blank-session selection path only when the captured session id is still current and blank; it never recomposes a running session or rewrites that session's history. The session log enforces the same invariant from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. 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.
|
||||
|
||||
@@ -61,11 +61,11 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`)
|
||||
|
||||
**A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it.
|
||||
|
||||
**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. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default.
|
||||
**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. It rides the session header beside `cwd`, and the summary carries it so the client surfaces show what a session actually runs rather than the deployment's current default.
|
||||
|
||||
**A durable header field is not durable until the provider writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and the JSONL provider omitted it; the derived query index also maps header fields explicitly, so a resumed Session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same form — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it.
|
||||
|
||||
**The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright.
|
||||
**The choice belongs to the screen where it still works.** The control lives on the new-session screen beside the workspace picker, is present under the default-on Host policy, and disappears only after `modeSelectionEnabled` is disabled. Its pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it; hiding the picker discards a stage that has not reached a session and returns the current blank session to the deployment default through the same selection path. What a running or historical session runs remains a read-only label in its header: a control there would promise a switch the host refuses outright.
|
||||
|
||||
**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `ApiSessionAgentController` discards the `AgentHandle` returned by the registry, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one.
|
||||
|
||||
|
||||
@@ -27,12 +27,11 @@ Status: implemented
|
||||
|
||||
挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。
|
||||
|
||||
未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。
|
||||
`agent-presets` 用户设置命名空间同时携带 `modeSelectionEnabled` 与 `default`。`modeSelectionEnabled` 默认为 `true`:既有的新建会话选择器保持显示;未指名会话会解析到已保存的用户 `default`,尚未保存时则使用组装中 `default` 指定的部署默认值。Web 设置开关只改变该策略:关闭选择时临时使用部署默认值,再次开启时恢复已保存的用户 `default`。这是对 [#1539](https://github.com/deepseek-harness/deepseek-harness/pull/1539) 所确立“用户值覆盖组装值”这一普通 settings 优先级的有意例外:隐藏选择器会停用用户的模式选择策略,但不会删除其保存值。该 Host 策略适用于此后所有未显式指定 preset 的会话;显式指定及既有会话不受影响。组装值还使本包在没有 settings 提供方时照常工作;选择器开启后,用户可覆盖默认值来改变后续会话,而无需编辑部署所拥有的 `cordis.yml`。
|
||||
|
||||
## 后果
|
||||
|
||||
**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
|
||||
|
||||
**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。Host 设置本身会在此后解析未指名会话时生效。Web Settings 中的明确操作还会把已接受的有效默认值送入既有的空白会话选择链路,但只在操作前捕获的会话 id 仍是当前空白会话时对齐;它绝不会重新组装运行中的会话,也不会改写该会话的历史。session 日志从另一侧执行同一条不变量——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。
|
||||
|
||||
**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。
|
||||
|
||||
@@ -62,11 +61,11 @@ Status: implemented
|
||||
|
||||
**preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。
|
||||
|
||||
**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。
|
||||
**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使客户端界面显示某个会话实际运行的 preset,而非部署当前的默认值。
|
||||
|
||||
**持久化 header 字段在 provider 写入前都算不上持久。** `agentPreset` 带着正确理由落在 `SessionHeader` 上,而 JSONL provider 遗漏了它;派生 query index 也显式映射 header 字段,于是恢复后的 Session 没有 preset,所有据以命名它的 surface 随之失声。`summarizeCold` 是同一种形式——它手工拼装 cold list row,而没有复用共享 projection。声明为持久的字段,需要一个跨越真实 store 的测试,而不只是声明它的类型。
|
||||
|
||||
**这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。
|
||||
**这个选择属于它仍然可用的那个界面。** 控件位于新建会话界面、工作区选择器旁边,在 Host 默认开启策略下直接显示,仅在 `modeSelectionEnabled` 关闭后隐藏。选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致;隐藏选择器会丢弃尚未到达会话的暂存选择,并通过同一条选择链路把当前空白会话带回部署默认值。至于运行中或历史会话在跑什么,仍由其标题旁的只读标签展示:在那里放控件,等于承诺一次宿主会断然拒绝的切换。
|
||||
|
||||
**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`ApiSessionAgentController` 会丢弃注册表返回的 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。
|
||||
|
||||
|
||||
@@ -874,6 +874,7 @@ describe('authoring a preset on the shipped composition', () => {
|
||||
*/
|
||||
describe('the default preset as a user setting', () => {
|
||||
it('composes an unnamed session from the stored default, not the composed one', async () => {
|
||||
expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true)
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
|
||||
await ctx.settings.update(SETTINGS_NAMESPACE, { default: 'minimal' })
|
||||
|
||||
@@ -75,11 +75,15 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 })
|
||||
// The intro copy also names 标准模式. Wait for the roster's own action so
|
||||
// the snapshot cannot land between the section shell and its cards.
|
||||
await dialog.getByRole('button', { name: '查看: 标准模式', exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
|
||||
const toggle = dialog.getByRole('switch', { name: '允许切换agent模式' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
// The intro states the copy path directly, and the shipped rows offer
|
||||
// view/copy but never delete or a location — their
|
||||
// install is overwritten by upgrades and is not the user's to manage.
|
||||
@@ -88,6 +92,8 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
expect(snapshot).toContain('查看: 标准模式')
|
||||
expect(snapshot).not.toContain('删除: 标准模式')
|
||||
expect(snapshot).not.toContain('打开目录')
|
||||
// The rest of this scenario exercises the existing default and Creator
|
||||
// actions with the beta picker enabled by default.
|
||||
}, 60_000)
|
||||
|
||||
it('views a shipped composition read-only instead of editing it', async () => {
|
||||
|
||||
@@ -247,15 +247,21 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await rm(presetRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('offers the chip on the new-session screen, beside the workspace picker', async () => {
|
||||
it('starts with mode selection shown on the Standard default', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero'))
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Agent presets' }).click()
|
||||
const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' })
|
||||
await dialog.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
await dialog.getByRole('button', { name: 'Close' }).last().click()
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE)
|
||||
// The chip opens on the deployment default, by the name that preset
|
||||
// publishes rather than its directory name.
|
||||
expect(snapshot).toContain('Standard mode')
|
||||
})
|
||||
|
||||
@@ -283,6 +289,8 @@ describe('web e2e: agent-preset selection', () => {
|
||||
// The chip stages; the blank session the workspace connect produced is
|
||||
// what the stage lands on. The host's own answer is what comes back.
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
|
||||
const roster = await scaffold.ctx.agentPresets.remoteExportList()
|
||||
expect(roster.presets.find(preset => preset.isDefault)?.id).toBe('standard')
|
||||
})
|
||||
|
||||
it('says why a switch was refused instead of letting the chip revert in silence', async () => {
|
||||
@@ -340,6 +348,39 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await writeComposerDraft(page, composer, '')
|
||||
}, 90_000)
|
||||
|
||||
it('aligns the current blank task and restores its saved default when re-enabled', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled'))
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
|
||||
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Agent presets' }).click()
|
||||
await dialog.getByRole('button', { name: 'Set as default: Minimal mode' }).click()
|
||||
await dialog.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
|
||||
const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' })
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
await dialog.getByRole('button', { name: 'Default: Standard mode' }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Close' }).last().click()
|
||||
|
||||
await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0)
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
|
||||
|
||||
// The switch controls availability only: re-enabling restores the saved
|
||||
// default and aligns this same still-blank task with it.
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const reopened = page.getByRole('dialog', { name: 'Settings' })
|
||||
await reopened.getByRole('button', { name: 'Agent presets' }).click()
|
||||
const reopenedToggle = reopened.getByRole('switch', { name: 'Allow switching Agent modes' })
|
||||
await reopenedToggle.click()
|
||||
await expect.poll(() => reopenedToggle.getAttribute('aria-checked')).toBe('true')
|
||||
await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
|
||||
await reopened.getByRole('button', { name: 'Close' }).last().click()
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
|
||||
await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
it('labels a resumed session with the preset it was created under', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header'))
|
||||
// The seeded session's cwd is the scaffold root rather than the connected
|
||||
|
||||
@@ -19,11 +19,14 @@
|
||||
- text: 关闭
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
- text: 允许切换agent模式 beta
|
||||
- paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
|
||||
- switch "允许切换agent模式" [checked]
|
||||
- heading "内置" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "当前使用: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- 'button "新任务默认: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"':
|
||||
- img
|
||||
|
||||
@@ -19,11 +19,14 @@
|
||||
- text: 关闭
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
- text: 允许切换agent模式 beta
|
||||
- paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
|
||||
- switch "允许切换agent模式" [checked]
|
||||
- heading "内置" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "当前使用: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- 'button "新任务默认: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"':
|
||||
- img
|
||||
|
||||
@@ -19,11 +19,14 @@
|
||||
- text: 关闭
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
- text: 允许切换agent模式 beta
|
||||
- paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
|
||||
- switch "允许切换agent模式" [checked]
|
||||
- heading "内置" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "当前使用: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- 'button "新任务默认: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"':
|
||||
- img
|
||||
|
||||
@@ -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 docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: b6fd2188e4088ae1de3bba0a17b991add39365be
|
||||
event-producer-consumer.md: 31bcff64bdbd4caa24124f196beef86233f22538
|
||||
event-producer-consumer.zh.md: 239149009d0ade0fa2869989e32d2c41fad46c83
|
||||
|
||||
@@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
|
||||
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
@@ -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 docs/subsystems/core.md
|
||||
core.md: 27ff30a9e63c86ffb54ccf57dea18ebf3fe39846
|
||||
core.zh.md: cedd113de5d5d551b8b558f9f33c7cacf6aa4953
|
||||
core.md: deb4743df146983857555016b5bdbe8d2ce29b42
|
||||
core.zh.md: f18a00eb02aac23ec07e80b4a9e3ea2234d7e576
|
||||
|
||||
@@ -498,11 +498,12 @@ async list(): Promise<AgentPreset[]>
|
||||
|
||||
/**
|
||||
* The roster off the Host: {@link list} projected to path-free rows, with
|
||||
* the default marked and this deployment's authoring capability beside it.
|
||||
* the policy-effective default marked, this deployment's authoring
|
||||
* capability, and its mode-selection policy beside it.
|
||||
*
|
||||
* Whether a client can open a preset's directory is the Host's own opener
|
||||
* capability, not a roster property — a caller needing both joins them.
|
||||
* @returns the rows and the authoring capability.
|
||||
* @returns the rows, authoring capability, and effective selection policy.
|
||||
*/
|
||||
@Remote('list') async remoteExportList(): Promise<AgentPresetRoster>
|
||||
|
||||
|
||||
@@ -508,11 +508,12 @@ async list(): Promise<AgentPreset[]>
|
||||
|
||||
/**
|
||||
* The roster off the Host: {@link list} projected to path-free rows, with
|
||||
* the default marked and this deployment's authoring capability beside it.
|
||||
* the policy-effective default marked, this deployment's authoring
|
||||
* capability, and its mode-selection policy beside it.
|
||||
*
|
||||
* Whether a client can open a preset's directory is the Host's own opener
|
||||
* capability, not a roster property — a caller needing both joins them.
|
||||
* @returns the rows and the authoring capability.
|
||||
* @returns the rows, authoring capability, and effective selection policy.
|
||||
*/
|
||||
@Remote('list') async remoteExportList(): Promise<AgentPresetRoster>
|
||||
|
||||
|
||||
@@ -2735,7 +2735,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
const presetRemotes = {
|
||||
// Both trusts appear, because a surface must present a locally authored
|
||||
// preset differently from one the deployment vetted.
|
||||
list(): RpcResult<{ presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[]; authorable: boolean }> {
|
||||
list(): RpcResult<{
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[]
|
||||
authorable: boolean
|
||||
modeSelectionEnabled: boolean
|
||||
}> {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -2745,6 +2749,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
isDefault: id === fixtureDefaultPreset,
|
||||
})),
|
||||
authorable: true,
|
||||
modeSelectionEnabled: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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/client/ui-agent-preset/README.md
|
||||
README.md: df5a46ae7d1668483b60538cffc4fc1b851163bc
|
||||
README.zh.md: c3bb7467dd4ea4df8882f2c5e514e8d6027e5700
|
||||
README.md: 476d0a351896835b4f92372c4a62854f2162a6e1
|
||||
README.zh.md: fa5a81b4af6299ae2253cf43809f07478d66885c
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Agent-preset surfaces for the Web GUI: the default-preset setting, the new-session chip, the session-header label, and the preset roster management section; for users and maintainers of agent composition."
|
||||
description: "Agent-preset surfaces for the Web GUI: picker visibility and default settings, the new-session chip, the session-header label, and preset roster management; for users and maintainers of agent composition."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition.
|
||||
Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,15 +25,15 @@ Use this package to choose the agent preset for a new Web GUI session, see the a
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount this plugin alongside the settings and conversation packages; the preset surfaces then appear where their slots render. The new-session chip opens on the deployment default and stages a pick that lands on the next blank session; the stage is spent on first use, so the following new session opens on the default again.
|
||||
Mount this plugin alongside the settings and conversation packages; the management section then shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history.
|
||||
|
||||
### Managing the roster
|
||||
|
||||
The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The default is set from any surface; deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot.
|
||||
The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The visibility switch changes only whether the saved user default is active: the Host uses the deployment default while hidden and restores the saved default when the picker is shown again. While the picker is enabled, choosing a healthy non-default card writes a new user default for later sessions; if the current new-task surface already reuses a blank session, that explicit Settings action carries the same preset to that exact blank session through the existing selection path. Started and historical sessions remain unchanged. The switch is disabled while saving, and a failed write keeps the prior preference and shows an error. Hiding the picker disables default selection and the Creator launch but leaves roster viewing, copying, location, and deletion available. Deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot.
|
||||
|
||||
### The conversational entry
|
||||
|
||||
When the roster carries the self-referential `cordis` preset, a dashed add-card stages it and starts a new session — the section closes the settings panel and the new-session chip's own applier composes the blank session the workspace flow produces.
|
||||
When the roster carries the self-referential `cordis` preset, its dashed add-card stays disabled until the picker is enabled. It then stages `cordis` and starts a new session — the section closes the settings panel and the new-session chip's own applier composes the blank session the workspace flow produces.
|
||||
|
||||
-----
|
||||
|
||||
@@ -43,7 +43,7 @@ When the roster carries the self-referential `cordis` preset, a dashed add-card
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The display options come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so no surface introspects the settings schema — and the default write, the settings section's make-default action, targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
|
||||
The settings section writes the Host's existing `agent-presets` namespace through `settings.update`. Its visibility switch sets only `modeSelectionEnabled`, and its make-default action writes `default` only while the picker is shown. After either write, the Host roster supplies the effective default, and the chip controller's `agentPresets/select` path carries it to the same still-blank session; that path is the only session-mutation API these surfaces use. Display options and Host-effective visibility come from `agentPresets/list` — the roster already marks the effective Host default and carries `modeSelectionEnabled`, so a non-loopback read-only client stays consistent without introspecting the settings schema. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip renders only while `modeSelectionEnabled` is true; hiding it drops a pending stage and local menu or refusal state, while the header label remains registered and reads each session's recorded preset. The stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the Host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the Host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -68,7 +68,7 @@ Indirectly, through the preset a later session is composed from; the preset it s
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation. Changing the default never touches a running session's prefix; a session created afterwards establishes its own prefix from its own composition.
|
||||
No direct invalidation. Changing picker visibility or the default does not alter a running session's composition or prefix, or a historical session's recorded preset; a session created afterwards establishes its own prefix from its own composition.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
@@ -79,7 +79,7 @@ These limits define the current preset surfaces. They are current package constr
|
||||
|
||||
- **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source. The resolution itself is the shared `presetDisplayText` fold from [`dsh-agent-presets/display`](../../preset/agent-presets/README.md), which the Settings plugin list inlines over this plugin’s dictionaries to show shipped presets in the active locale without translating user-authored metadata.
|
||||
- **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself.
|
||||
- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/changed`, and `connection/reset`, not on every disk edit.
|
||||
- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/document-updated`, and `connection/reset`, not on every disk edit.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Web GUI 的 agent(智能体) preset 界面:默认 preset 设置、新建会话 chip、会话标题标签与 preset 名单管理分区;供 agent 组装的用户与维护者阅读。"
|
||||
description: "Web GUI 的 agent(智能体) preset 界面:选择器可见性与默认设置、新建会话 chip、会话标题标签与 preset 名单管理分区;供 agent 组装的用户与维护者阅读。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。
|
||||
使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,15 +25,15 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
与设置与对话包一起挂载本插件;preset 界面随即出现在各自槽位渲染之处。新建会话 chip 以部署默认值打开并暂存一个选择,落到下一个空白会话上;暂存一经使用即被清空,因此再下一个新会话重新以默认值打开。
|
||||
与设置和对话包一起挂载本插件;管理分区随后显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。
|
||||
|
||||
### 管理名单
|
||||
|
||||
设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。默认值可在任一界面设置;删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为工具提示附在徽标上,另有一个视觉隐藏的 alert 将该原因传达给辅助技术,而被禁用的卡片主体无法做到这一点。
|
||||
设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。可见性开关只决定已保存的用户默认值是否生效:宿主在隐藏期间使用部署默认值,再次显示选择器时恢复已保存的默认值。选择器开启期间,选择健康且非默认的卡片会为后续会话写入新的用户默认值;如果当前新任务页已经复用一个空白会话,这次在设置中的明确选择也会通过既有选择链路把同一 preset 带到这个精确的空白会话。已开始及历史会话保持不变。保存期间开关会被禁用;写入失败时,界面保留先前的偏好并显示错误。隐藏选择器会禁用默认值选择与 Creator 启动,但名单查看、复制、位置和删除仍然可用。删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为工具提示附在徽标上,另有一个视觉隐藏的 alert 将该原因传达给辅助技术,而被禁用的卡片主体无法做到这一点。
|
||||
|
||||
### 对话式入口
|
||||
|
||||
名单携带自指的 `cordis` preset 时,一张虚线添加卡会暂存它并开启新会话——分区关闭设置面板,新建会话 chip 自己的应用器负责组装工作区流程产出的空白会话。
|
||||
名单携带自指的 `cordis` preset 时,其虚线添加卡在选择器开启前保持禁用。开启后,它会暂存 `cordis` 并启动新会话——分区关闭设置面板,新建会话 chip 自己的应用器负责组装工作区流程产出的空白会话。
|
||||
|
||||
-----
|
||||
|
||||
@@ -43,7 +43,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
展示选项来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此任何界面都无需对 settings schema 做内省——默认值的写入即设置分区的设为默认动作,目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。系统会通过 composer 列上方的瞬时横幅提示拒绝结果,因为 chip 的标签此时已经恢复原值,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有用户刚做出的选择会触发提示;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而复制、删除与设置模块拥有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,协议链路不会通知文件变动。
|
||||
设置分区通过现有的 `settings.update` 写入宿主的 `agent-presets` 命名空间。可见性开关只设置 `modeSelectionEnabled`;仅当选择器显示时,设为默认动作才会写入 `default`。两种写入之后,都由宿主名单给出当前生效的默认值,再由 chip controller 的 `agentPresets/select` 链路把它带到同一个仍为空白的会话;这些界面只使用这一条会话修改 API。展示选项与宿主的生效可见性来自 `agentPresets/list`——名单本身已标记宿主当前生效的默认值并携带 `modeSelectionEnabled`,因此非 loopback 的只读客户端无需内省 settings schema 也能保持一致。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 仅在 `modeSelectionEnabled` 为 `true` 时渲染;隐藏它会丢弃待处理的暂存选择及本地菜单或失败横幅状态,而标题标签保持注册并读取每个会话已记录的 preset。暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。系统会通过 composer 列上方的瞬时横幅提示拒绝结果,因为 chip 的标签此时已经恢复原值,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有用户刚做出的选择会触发提示;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有宿主 API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而复制、删除与设置模块拥有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,协议链路不会通知文件变动。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -68,7 +68,7 @@ kind: "package-reference"
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
没有直接的失效影响。更改默认值绝不触及运行中会话的前缀;此后创建的会话依据它自己的组装建立自己的前缀。
|
||||
没有直接的失效影响。更改选择器可见性或默认值不会改变运行中会话的组装或前缀,也不会改变历史会话已记录的 preset;此后创建的会话依据它自己的组装建立自己的前缀。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
@@ -79,7 +79,7 @@ kind: "package-reference"
|
||||
|
||||
- **没有元数据的 preset 按 id 列出**——展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。解析本身使用 [`dsh-agent-presets/display`](../../preset/agent-presets/README.zh.md) 共享的 `presetDisplayText` 解析逻辑,设置的插件列表把它内联在本插件的字典之上,按当前语言显示随附 preset 的名称,同时不翻译用户自建的元数据。
|
||||
- **展示的路径是文本,不是链接**——宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。
|
||||
- **组装编辑对页面不可见**——文件在浏览器之外编辑,协议链路不广播文件变动,因此名单只在自身操作、`settings/changed` 与 `connection/reset` 时重读,而非每次磁盘编辑。
|
||||
- **组装编辑对页面不可见**——文件在浏览器之外编辑,协议链路不广播文件变动,因此名单只在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,而非每次磁盘编辑。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -89,11 +89,22 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
|
||||
// it rather than leaving the first one silently in place.
|
||||
const toastSeq = useRef(0)
|
||||
const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
|
||||
const pickerVisible = useRef(state.showPicker)
|
||||
pickerVisible.current = state.showPicker
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// The component stays registered while hidden, so clear local disclosure
|
||||
// state explicitly; otherwise an external off/on edit can revive an old
|
||||
// menu or refusal banner.
|
||||
useEffect(() => {
|
||||
if (state.showPicker) return
|
||||
setOpen(false)
|
||||
setToast(null)
|
||||
}, [state.showPicker])
|
||||
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
|
||||
const label = chosenText?.name ?? state.current
|
||||
@@ -121,7 +132,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (!ready) return null
|
||||
if (!state.showPicker || !ready) return null
|
||||
|
||||
// One wrapper span: the chip is a flex row with a gap, so loose character
|
||||
// spans would each pick up the gap between them.
|
||||
@@ -174,7 +185,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
|
||||
// Announced only for a pick a person just made: `apply()` also runs
|
||||
// when a session becomes current, and a banner over that would
|
||||
// report a refusal nobody asked for.
|
||||
if (refusal === undefined) return
|
||||
if (refusal === undefined || !pickerVisible.current) return
|
||||
toastSeq.current += 1
|
||||
setToast({ seq: toastSeq.current, text: t('switchRefused', { name, reason: refusal }) })
|
||||
})
|
||||
|
||||
@@ -18,6 +18,43 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.pickerPreference {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 14px 16px;
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.pickerPreferenceCopy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pickerPreferenceTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pickerPreferenceTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pickerPreferenceDescription {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Cards, not rows: a preset is a thing you pick, and the description is the
|
||||
part that tells them apart — a row would bury it beside the actions. */
|
||||
.group {
|
||||
@@ -63,7 +100,7 @@
|
||||
}
|
||||
|
||||
|
||||
.card:hover:not(.cardActive) {
|
||||
.card:hover:not(.cardActive):not(.cardSelectionDisabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@@ -75,6 +112,16 @@
|
||||
border-color: var(--dsw-static-neutral-bluish-400);
|
||||
}
|
||||
|
||||
/* Mode selection is off, not the preset itself: mute only the selectable
|
||||
body while the independent view/copy/location actions stay fully usable. */
|
||||
.cardSelectionDisabled {
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.cardSelectionDisabled .cardMain {
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
/* A broken preset reads as damaged before anything else: the card cannot be
|
||||
picked, so its border carries the warning the disabled body cannot. */
|
||||
.cardBroken {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tag, Tooltip,
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16,
|
||||
IconPlusOutline16, IconTrashOutline16, Modal, Switch, Tag, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -57,6 +58,8 @@ export interface AgentPresetSectionInjected {
|
||||
remove: () => Promise<void>
|
||||
/** Make one preset the default for sessions created later. */
|
||||
makeDefault: (id: string) => Promise<void>
|
||||
/** Show or hide preset selection on new-session surfaces. */
|
||||
setPickerVisible: (showPicker: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
@@ -214,8 +217,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
className={css.creatorButton}
|
||||
disabled={!state.authorable}
|
||||
title={state.authorable ? undefined : t('duplicateUnavailable')}
|
||||
disabled={!state.authorable || !state.showPicker || state.policySaving}
|
||||
title={!state.showPicker
|
||||
? t('enablePickerToCreate')
|
||||
: state.authorable ? undefined : t('duplicateUnavailable')}
|
||||
onClick={() => {
|
||||
props.startCreatorDraft?.()
|
||||
props.close()
|
||||
@@ -231,11 +236,34 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<div className={css.section}>
|
||||
<h2 className={css.title}>{t('nav')}</h2>
|
||||
<p className={css.intro}>{t('sectionIntro')}</p>
|
||||
<div className={css.pickerPreference}>
|
||||
<div className={css.pickerPreferenceCopy}>
|
||||
<span className={css.pickerPreferenceTitleRow}>
|
||||
<span className={css.pickerPreferenceTitle}>{t('showPicker')}</span>
|
||||
<Tag>{t('showPickerBeta')}</Tag>
|
||||
</span>
|
||||
<p className={css.pickerPreferenceDescription}>{t('showPickerDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={state.showPicker}
|
||||
label={t('showPicker')}
|
||||
disabled={state.status !== 'ready' || state.policySaving}
|
||||
onChange={(next) => { void props.setPickerVisible(next) }}
|
||||
/>
|
||||
</div>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
|
||||
{([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => {
|
||||
const group = state.rows
|
||||
.filter(row => row.trust === trust)
|
||||
.map(row => ({ row, text: presetDisplayText(row, t) }))
|
||||
.map(row => ({
|
||||
row,
|
||||
text: presetDisplayText(row, t),
|
||||
selectionAction: row.broken !== undefined
|
||||
? t('brokenBadge')
|
||||
: row.isDefault
|
||||
? t(state.showPicker ? 'inUse' : 'selectionOffDefault')
|
||||
: t(state.showPicker ? 'setDefault' : 'enablePickerToSetDefault'),
|
||||
}))
|
||||
// The custom group is where a preset of one's own will appear, so it
|
||||
// stays on screen even while empty: heading plus the creator entry.
|
||||
const tail = trust === 'user' ? creatorButton : null
|
||||
@@ -245,12 +273,17 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<h3 className={css.groupHead}>{heading}</h3>
|
||||
{group.length === 0 ? null : (
|
||||
<ul className={css.cards}>
|
||||
{group.map(({ row, text }) => (
|
||||
{group.map(({ row, text, selectionAction }) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
? `${css.card} ${css.cardBroken}`
|
||||
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
|
||||
className={[
|
||||
css.card,
|
||||
row.broken !== undefined ? css.cardBroken : undefined,
|
||||
row.isDefault ? css.cardActive : undefined,
|
||||
!state.showPicker && row.broken === undefined && !row.isDefault
|
||||
? css.cardSelectionDisabled
|
||||
: undefined,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{/* The card body IS the control: picking a preset is the
|
||||
common act, so it should not hide behind a small button.
|
||||
@@ -268,15 +301,16 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
// `disabled`, which would take the card out of the tab
|
||||
// order. With the reason moved onto the badge, that is
|
||||
// the only way anyone without a pointer reaches it.
|
||||
disabled={row.isDefault}
|
||||
disabled={row.isDefault
|
||||
|| (row.broken === undefined && (!state.showPicker || state.policySaving))}
|
||||
aria-disabled={row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
|
||||
aria-label={`${selectionAction}: ${text.name}`}
|
||||
// The reason rides the badge, not the whole card: two
|
||||
// tooltips over one target would race, and the card's
|
||||
// own label answers what clicking it would do.
|
||||
title={row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}
|
||||
title={selectionAction}
|
||||
onClick={() => {
|
||||
if (row.broken !== undefined) return
|
||||
void props.makeDefault(row.id)
|
||||
@@ -299,7 +333,13 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<Tag>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
</Tag>
|
||||
{row.isDefault ? <Tag tone="solid" className={css.inUse}>{t('inUse')}</Tag> : null}
|
||||
{row.isDefault
|
||||
? (
|
||||
<Tag tone="solid" className={css.inUse}>
|
||||
{state.showPicker ? t('inUse') : t('selectionOffDefault')}
|
||||
</Tag>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
<CardDescription text={text.description ?? t('noDescription')} />
|
||||
{/* Visually hidden, deliberately: the pointer path is the
|
||||
|
||||
@@ -88,7 +88,10 @@ export function apply(ctx: ClientContext): void {
|
||||
if (ns !== AGENT_PRESET_SETTINGS_NS) return
|
||||
refresh()
|
||||
}),
|
||||
ctx.on('connection/reset', () => { refresh() }),
|
||||
ctx.on('connection/reset', () => {
|
||||
refresh()
|
||||
for (const read of rosterReaders) read()
|
||||
}),
|
||||
]
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-agent-preset: settings refresh')
|
||||
@@ -99,6 +102,7 @@ export function apply(ctx: ClientContext): void {
|
||||
// unbound with it, so the section's face reads the current binding per
|
||||
// render and simply hides the button while no flow exists.
|
||||
let creatorDraft: (() => void) | undefined
|
||||
let activeSeat: AgentPresetSeatController | undefined
|
||||
|
||||
// The new-session chip and the header label: one controller, because the
|
||||
// staged choice belongs to the flow rather than to any one session.
|
||||
@@ -107,7 +111,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const state = scope.sessions.list.getSnapshot()
|
||||
return state.current === undefined ? undefined : state.byId[state.current]
|
||||
})
|
||||
|
||||
activeSeat = seat
|
||||
const seatInjected = (): AgentPresetSeatInjected => ({
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
load: () => seat.load(),
|
||||
@@ -145,6 +149,7 @@ export function apply(ctx: ClientContext): void {
|
||||
// on: the chip's list-change applier composes the blank session the
|
||||
// workspace connect produces or reuses.
|
||||
creatorDraft = () => {
|
||||
if (!section.store.getSnapshot().showPicker) return
|
||||
// The introduce cue makes the chip announce the pick the user never
|
||||
// made on this screen — the stage happened back in settings.
|
||||
seat.stage('cordis', true)
|
||||
@@ -168,12 +173,23 @@ export function apply(ctx: ClientContext): void {
|
||||
settingsMoved()
|
||||
rosterReaders.delete(readRoster)
|
||||
creatorDraft = undefined
|
||||
activeSeat = undefined
|
||||
chip()
|
||||
label()
|
||||
}
|
||||
}, 'ui-agent-preset: new-session chip and header label')
|
||||
})
|
||||
|
||||
/** Capture the exact blank Session one Settings action may update. */
|
||||
const captureBlankSessionSync = (): ((id: string) => Promise<string | undefined>) => {
|
||||
const seat = activeSeat
|
||||
const sessionId = seat?.blankSessionId()
|
||||
return async (id: string) => {
|
||||
if (seat === undefined || sessionId === undefined || activeSeat !== seat) return undefined
|
||||
return await seat.syncBlankSession(sessionId, id)
|
||||
}
|
||||
}
|
||||
|
||||
const sectionInjected = (): AgentPresetSectionInjected => ({
|
||||
hooks: { agentPresetSection: section.store },
|
||||
load: () => section.load(),
|
||||
@@ -188,7 +204,8 @@ export function apply(ctx: ClientContext): void {
|
||||
...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft },
|
||||
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
|
||||
remove: () => section.remove(),
|
||||
makeDefault: (id: string) => section.makeDefault(id),
|
||||
makeDefault: (id: string) => section.makeDefault(id, captureBlankSessionSync()),
|
||||
setPickerVisible: (showPicker: boolean) => section.setPickerVisible(showPicker, captureBlankSessionSync()),
|
||||
})
|
||||
|
||||
// Ordered after Models: choosing a model is routine, and composing an
|
||||
|
||||
@@ -10,13 +10,15 @@ export type AgentPresetSettingsKey =
|
||||
| 'presetCordisName' | 'presetCordisDescription'
|
||||
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder'
|
||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||
| 'inUse' | 'selectionOffDefault' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||
| 'brokenBadge' | 'brokenNoCopy' | 'switchRefused'
|
||||
| 'composition' | 'cancel' | 'close' | 'retry'
|
||||
| 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft'
|
||||
| 'openLocation' | 'showLocation' | 'revealedPathLabel'
|
||||
| 'idRequired' | 'idInvalid' | 'idTaken'
|
||||
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
|
||||
| 'showPicker' | 'showPickerBeta' | 'showPickerDescription'
|
||||
| 'enablePickerToSetDefault' | 'enablePickerToCreate'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
@@ -50,7 +52,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: 'Name',
|
||||
displayNamePlaceholder: 'Shown in the picker; defaults to the identifier',
|
||||
inUse: 'In use',
|
||||
inUse: 'New task default',
|
||||
selectionOffDefault: 'Default',
|
||||
builtInGroup: 'Built-in',
|
||||
customGroup: 'Custom',
|
||||
noDescription: 'No description.',
|
||||
@@ -80,6 +83,12 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.',
|
||||
deleteConfirm: 'Delete',
|
||||
deleting: 'Deleting…',
|
||||
showPicker: 'Allow switching Agent modes',
|
||||
showPickerBeta: 'Beta',
|
||||
showPickerDescription:
|
||||
'When enabled, new tasks can choose Standard, PTC, Creator, Minimal, and custom modes. When disabled, all new tasks use the default mode (Standard by default; configurable). Only affects new tasks.',
|
||||
enablePickerToSetDefault: 'Turn on Agent mode selection to choose a default',
|
||||
enablePickerToCreate: 'Turn on Agent mode selection to start Creator mode',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
@@ -108,7 +117,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: '名称',
|
||||
displayNamePlaceholder: '选择器中显示的名字,缺省用标识符',
|
||||
inUse: '当前使用',
|
||||
inUse: '新任务默认',
|
||||
selectionOffDefault: '默认',
|
||||
builtInGroup: '内置',
|
||||
customGroup: '自定义',
|
||||
noDescription: '暂无描述。',
|
||||
@@ -135,6 +145,11 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
|
||||
deleteConfirm: '删除',
|
||||
deleting: '正在删除…',
|
||||
showPicker: '允许切换agent模式',
|
||||
showPickerBeta: 'beta',
|
||||
showPickerDescription: '开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。',
|
||||
enablePickerToSetDefault: '请先开启 Agent 模式选择,再设置默认模式',
|
||||
enablePickerToCreate: '请先开启 Agent 模式选择,再启动创造模式',
|
||||
}
|
||||
|
||||
// The resolution itself is the shared fold in `dsh-agent-presets/display`,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* whether the workspace connect created it or reused an existing blank one,
|
||||
* which is why staging cannot simply ride along on `sessions.create`.
|
||||
*
|
||||
* The stage is forgotten once applied: the next new session starts from the
|
||||
* deployment default again, matching the workspace picker beside it.
|
||||
* The stage is forgotten once applied. The next new session starts from the
|
||||
* Host-effective default again.
|
||||
*/
|
||||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
@@ -21,6 +21,8 @@ import type { AgentPresetOption } from './settings-store.ts'
|
||||
|
||||
/** Hero-chip snapshot. */
|
||||
export interface AgentPresetSeatState {
|
||||
/** Whether the new-session surface exposes preset selection. */
|
||||
showPicker: boolean
|
||||
/** Presets the deployment supplies; empty means the chip renders nothing. */
|
||||
options: readonly AgentPresetOption[]
|
||||
/** The staged choice, empty until the roster loads. */
|
||||
@@ -37,7 +39,7 @@ export interface AgentPresetSeatState {
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSeatState = {
|
||||
options: [], current: '', error: null, busy: false, introduce: false,
|
||||
showPicker: false, options: [], current: '', error: null, busy: false, introduce: false,
|
||||
}
|
||||
|
||||
/** Stages the next session's preset and applies it when one appears. */
|
||||
@@ -46,7 +48,7 @@ export class AgentPresetSeatController {
|
||||
readonly store: SnapshotStore<AgentPresetSeatState> = createSnapshotStore(INITIAL)
|
||||
|
||||
/**
|
||||
* The deployment default, so a consumed stage can fall back to it without
|
||||
* The Host-effective default, so a consumed stage can fall back to it without
|
||||
* re-reading the roster.
|
||||
*/
|
||||
private fallback = ''
|
||||
@@ -54,6 +56,9 @@ export class AgentPresetSeatController {
|
||||
/** Set while a pick is waiting for a session; cleared once applied. */
|
||||
private staged: string | undefined
|
||||
|
||||
/** Only the newest roster read may publish after overlapping refreshes. */
|
||||
private loadGeneration = 0
|
||||
|
||||
constructor(
|
||||
private readonly ctx: ClientContext,
|
||||
/** The session the hero is about to hand over to, when there is one. */
|
||||
@@ -68,28 +73,33 @@ export class AgentPresetSeatController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the roster and open the chip on the deployment default.
|
||||
* Read the roster and open the chip on the Host-effective default.
|
||||
* @returns once the snapshot reflects the host.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const generation = ++this.loadGeneration
|
||||
const roster = await readRoster(this.ctx)
|
||||
if (generation !== this.loadGeneration) return
|
||||
if (!roster.ok) {
|
||||
this.set({ error: roster.error })
|
||||
return
|
||||
}
|
||||
const { presets } = roster.value
|
||||
const { presets, modeSelectionEnabled } = roster.value
|
||||
if (!modeSelectionEnabled) this.staged = undefined
|
||||
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
|
||||
const session = this.currentSession()
|
||||
this.set({
|
||||
showPicker: modeSelectionEnabled,
|
||||
options: presetOptions(presets),
|
||||
// Staged pick first, then the composition the current session
|
||||
// already carries, then the deployment default. The middle term is
|
||||
// already carries, then the Host-effective default. The middle term is
|
||||
// what keeps a late-landing load from regressing the display after
|
||||
// an applied stage was consumed — the chip mounts (and loads) only
|
||||
// once the flow's session is current, so the reply can arrive after
|
||||
// apply() already composed it.
|
||||
current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
|
||||
error: null,
|
||||
...modeSelectionEnabled ? {} : { introduce: false },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,6 +138,33 @@ export class AgentPresetSeatController {
|
||||
this.set({ current: id, error: null, introduce })
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the exact blank Session a Settings action may bring along.
|
||||
* @returns its id, or undefined outside a blank Session.
|
||||
*/
|
||||
blankSessionId(): SessionSummary['id'] | undefined {
|
||||
const session = this.currentSession()
|
||||
return session?.blank === true ? session.id : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a Settings choice only if its captured Session is still current and
|
||||
* blank. The selection uses the existing stage/apply path.
|
||||
* @param expectedSessionId - blank Session captured before the Settings write.
|
||||
* @param id - the effective default that the write persisted.
|
||||
* @returns the Host refusal text, or undefined when applied or no longer relevant.
|
||||
*/
|
||||
async syncBlankSession(
|
||||
expectedSessionId: SessionSummary['id'],
|
||||
id: string,
|
||||
): Promise<string | undefined> {
|
||||
const session = this.currentSession()
|
||||
if (session === undefined || !session.blank || session.id !== expectedSessionId) return undefined
|
||||
this.stage(id)
|
||||
await this.apply()
|
||||
return this.store.getSnapshot().error ?? undefined
|
||||
}
|
||||
|
||||
/** Acknowledge the introduction cue once the chip has played it. */
|
||||
introduced(): void {
|
||||
if (!this.store.getSnapshot().introduce) return
|
||||
@@ -175,7 +212,7 @@ export class AgentPresetSeatController {
|
||||
})
|
||||
return
|
||||
}
|
||||
// Consumed: the next new session opens on the deployment default again.
|
||||
// Consumed: the next new session opens on the Host-effective default again.
|
||||
this.set({ busy: false, current: result.value })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
// Type-only: pulls the ctx.remote merge into this program.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { beginRosterRead, writeDefaultPreset } from './settings-store.ts'
|
||||
import { beginRosterRead, writeDefaultPreset, writeModeSelectionEnabled } from './settings-store.ts'
|
||||
|
||||
/** Ids a preset directory may be named, mirroring the host's own rule. */
|
||||
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
|
||||
|
||||
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error)
|
||||
|
||||
/** One preset row the page renders. */
|
||||
export interface PresetRow {
|
||||
/** Preset id and directory name; the display name falls back to it. */
|
||||
@@ -79,6 +81,10 @@ export interface AgentPresetSectionState {
|
||||
authorable: boolean
|
||||
/** Whether the host can open a preset directory on a native desktop. */
|
||||
hasDocument: boolean
|
||||
/** Whether new-session surfaces expose preset selection. */
|
||||
showPicker: boolean
|
||||
/** Whether a mode-selection policy write is in flight. */
|
||||
policySaving: boolean
|
||||
/** Every preset the deployment currently supplies. */
|
||||
rows: readonly PresetRow[]
|
||||
/** The open copy dialog, or null. */
|
||||
@@ -101,6 +107,8 @@ const INITIAL: AgentPresetSectionState = {
|
||||
error: null,
|
||||
authorable: false,
|
||||
hasDocument: false,
|
||||
showPicker: false,
|
||||
policySaving: false,
|
||||
rows: [],
|
||||
copy: null,
|
||||
view: null,
|
||||
@@ -134,6 +142,10 @@ export class AgentPresetSectionController {
|
||||
/** Page snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
|
||||
|
||||
/** The one roster load whose completion current callers await. */
|
||||
private loadFlight: Promise<void> | undefined
|
||||
private reloadRequested = false
|
||||
|
||||
constructor(
|
||||
private readonly ctx: ClientContext,
|
||||
/**
|
||||
@@ -151,6 +163,49 @@ export class AgentPresetSectionController {
|
||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||
}
|
||||
|
||||
/** Read back and reflect the Host-effective default after a policy write. */
|
||||
private async confirmEffectiveDefault(showPicker: boolean): Promise<string | undefined> {
|
||||
await this.load()
|
||||
if (this.store.getSnapshot().status === 'error') await this.load()
|
||||
const state = this.store.getSnapshot()
|
||||
if (state.status !== 'ready' || state.showPicker !== showPicker) return undefined
|
||||
return state.rows.find(row => row.isDefault)?.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide new-session preset selection without changing the saved
|
||||
* default. The Host roster resolves that saved default while selection is
|
||||
* shown and the deployment default while it is hidden.
|
||||
* @param showPicker - whether the new-session picker should be exposed.
|
||||
* @param syncBlankSession - optional current-blank-task sync kept inside the saving state.
|
||||
* @returns once the Host state and optional blank-task sync settle.
|
||||
*/
|
||||
async setPickerVisible(
|
||||
showPicker: boolean,
|
||||
syncBlankSession?: (id: string) => Promise<string | undefined>,
|
||||
): Promise<void> {
|
||||
const state = this.store.getSnapshot()
|
||||
if (state.status !== 'ready' || state.policySaving || state.showPicker === showPicker) return
|
||||
this.set({ policySaving: true, error: null })
|
||||
try {
|
||||
const failure = await writeModeSelectionEnabled(this.ctx, showPicker)
|
||||
if (failure !== undefined) {
|
||||
await this.load()
|
||||
this.set({ error: failure })
|
||||
return
|
||||
}
|
||||
const effectiveDefault = await this.confirmEffectiveDefault(showPicker)
|
||||
if (effectiveDefault === undefined) return
|
||||
const syncFailure = await syncBlankSession?.(effectiveDefault)
|
||||
if (syncFailure !== undefined) this.set({ error: syncFailure })
|
||||
} catch (error: unknown) {
|
||||
await this.load()
|
||||
this.set({ error: errorMessage(error) })
|
||||
} finally {
|
||||
this.set({ policySaving: false })
|
||||
}
|
||||
}
|
||||
|
||||
private patchCopy(patch: Partial<CopyDraft>): void {
|
||||
const { copy } = this.store.getSnapshot()
|
||||
if (copy === null) return
|
||||
@@ -164,21 +219,40 @@ export class AgentPresetSectionController {
|
||||
* @returns once the snapshot reflects the host.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
this.reloadRequested = true
|
||||
this.loadFlight ??= this.drainLoads()
|
||||
await this.loadFlight
|
||||
}
|
||||
|
||||
/** Coalesce invalidations without losing changes received during a read. */
|
||||
private async drainLoads(): Promise<void> {
|
||||
try {
|
||||
do {
|
||||
await this.loadOnce()
|
||||
} while (this.reloadRequested)
|
||||
} finally {
|
||||
this.loadFlight = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Perform the section's one owned roster read. */
|
||||
private async loadOnce(): Promise<void> {
|
||||
this.reloadRequested = false
|
||||
// Whether a preset's directory can be opened is the Host's opener
|
||||
// capability rather than a roster property, so the page joins the two.
|
||||
// Issued together: one round trip decides the page, and a load that waited
|
||||
// for them in turn would hold the section in `loading` twice as long,
|
||||
// where a concurrent reload silently returns instead of refreshing.
|
||||
// Both reads start together; one missing capability does not hide the roster.
|
||||
const opener = this.ctx.remote.settings.canOpenAgentPresetDirectory()
|
||||
const roster = await beginRosterRead(this.ctx, this.store)
|
||||
// A refused describe leaves the reveal-the-path path, which needs no opener.
|
||||
const described = await opener
|
||||
if (roster === undefined) return
|
||||
const { presets, authorable } = roster
|
||||
const { presets, authorable, modeSelectionEnabled: showPicker } = roster
|
||||
const hasDocument = described.ok && described.value
|
||||
if (presets.length === 0) {
|
||||
// Nothing to manage leaves nothing to keep a dialog open over.
|
||||
this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null })
|
||||
this.set({
|
||||
status: 'unavailable', rows: [], authorable, hasDocument, showPicker, copy: null, view: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
// A reveal outlives a reload but not its preset: a path for a row the
|
||||
@@ -191,6 +265,7 @@ export class AgentPresetSectionController {
|
||||
error: null,
|
||||
authorable,
|
||||
hasDocument,
|
||||
showPicker,
|
||||
rows: presets.map(preset => ({ ...preset })),
|
||||
revealedPaths: kept,
|
||||
})
|
||||
@@ -331,14 +406,32 @@ export class AgentPresetSectionController {
|
||||
* Make one preset the default for sessions created later. Running sessions
|
||||
* keep the composition they began with, so this never disturbs work.
|
||||
* @param id - the preset to make default.
|
||||
* @returns once the write settled and the roster was re-read.
|
||||
* @param syncBlankSession - optional current-blank-task sync kept inside the policy lock.
|
||||
* @returns once the write and optional blank-task sync settle.
|
||||
*/
|
||||
async makeDefault(id: string): Promise<void> {
|
||||
const failure = await writeDefaultPreset(this.ctx, id)
|
||||
if (failure !== undefined) {
|
||||
this.set({ error: failure })
|
||||
return
|
||||
async makeDefault(
|
||||
id: string,
|
||||
syncBlankSession?: (id: string) => Promise<string | undefined>,
|
||||
): Promise<void> {
|
||||
const state = this.store.getSnapshot()
|
||||
if (!state.showPicker || state.policySaving) return
|
||||
this.set({ policySaving: true, error: null })
|
||||
try {
|
||||
const failure = await writeDefaultPreset(this.ctx, id)
|
||||
if (failure !== undefined) {
|
||||
this.set({ error: failure })
|
||||
return
|
||||
}
|
||||
const effectiveDefault = await this.confirmEffectiveDefault(true)
|
||||
if (effectiveDefault === undefined) return
|
||||
const syncFailure = await syncBlankSession?.(effectiveDefault)
|
||||
if (syncFailure !== undefined) this.set({ error: syncFailure })
|
||||
} catch (error: unknown) {
|
||||
this.set({
|
||||
error: errorMessage(error),
|
||||
})
|
||||
} finally {
|
||||
this.set({ policySaving: false })
|
||||
}
|
||||
await this.load()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Agent-preset roster store shared by the display surfaces.
|
||||
*
|
||||
* Options come from one `agentPresets.list` call. Writes target the settings
|
||||
* namespace's `default` field, which is what the host resolves at creation;
|
||||
* the management section is the surface that writes it.
|
||||
* namespace fields the host resolves at creation; the management section is
|
||||
* the surface that writes them.
|
||||
*/
|
||||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
@@ -15,6 +15,15 @@ import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types'
|
||||
/** The agent-preset settings namespace on the host wire. */
|
||||
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
|
||||
|
||||
/** Write only the named agent-preset settings fields. */
|
||||
async function writeAgentPresetSettings(
|
||||
ctx: ClientContext,
|
||||
patch: { default?: string; modeSelectionEnabled?: boolean },
|
||||
): Promise<string | undefined> {
|
||||
const response = await ctx.remote.settings.update(AGENT_PRESET_SETTINGS_NS, patch, undefined)
|
||||
return response.ok ? undefined : response.error.message
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one preset as the default for sessions created later.
|
||||
*
|
||||
@@ -25,16 +34,24 @@ export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
|
||||
* @param id - the preset to make default.
|
||||
* @returns the failure message, or undefined once the write landed.
|
||||
*/
|
||||
export async function writeDefaultPreset(
|
||||
export function writeDefaultPreset(
|
||||
ctx: ClientContext,
|
||||
id: string,
|
||||
): Promise<string | undefined> {
|
||||
const response = await ctx.remote.settings.update(
|
||||
AGENT_PRESET_SETTINGS_NS,
|
||||
{ default: id },
|
||||
undefined,
|
||||
)
|
||||
return response.ok ? undefined : response.error.message
|
||||
return writeAgentPresetSettings(ctx, { default: id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist whether new-session surfaces expose preset selection.
|
||||
* @param ctx - the browser plugin context carrying the Remote namespaces.
|
||||
* @param enabled - whether the picker should be exposed.
|
||||
* @returns the failure message, or undefined once the write landed.
|
||||
*/
|
||||
export function writeModeSelectionEnabled(
|
||||
ctx: ClientContext,
|
||||
enabled: boolean,
|
||||
): Promise<string | undefined> {
|
||||
return writeAgentPresetSettings(ctx, { modeSelectionEnabled: enabled })
|
||||
}
|
||||
|
||||
/** One selectable preset. */
|
||||
@@ -55,7 +72,7 @@ export type RosterPreset = AgentPresetRoster['presets'][number]
|
||||
/** The roster, or the message to show in its place. */
|
||||
export type RosterRead = { ok: true; value: AgentPresetRoster } | { ok: false; error: string }
|
||||
|
||||
const EMPTY_ROSTER: AgentPresetRoster = { presets: [], authorable: false }
|
||||
const EMPTY_ROSTER: AgentPresetRoster = { presets: [], authorable: false, modeSelectionEnabled: false }
|
||||
|
||||
/**
|
||||
* Read the roster, turning a refusal into the message every surface shows.
|
||||
|
||||
@@ -32,6 +32,7 @@ const ROSTER_ONE = {
|
||||
value: {
|
||||
presets: [{ id: 'standard', trust: 'system', isDefault: true }],
|
||||
authorable: true,
|
||||
modeSelectionEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ const ROSTER_AUTHORED = {
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
authorable: true,
|
||||
modeSelectionEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -56,20 +58,35 @@ const ROSTER_MOVED = {
|
||||
{ id: 'minimal', trust: 'system', isDefault: true },
|
||||
],
|
||||
authorable: true,
|
||||
modeSelectionEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
async function bench() {
|
||||
/** The deployment after reconnect, with mode selection disabled. */
|
||||
const ROSTER_HIDDEN = {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [{ id: 'standard', trust: 'system', isDefault: true }],
|
||||
authorable: true,
|
||||
modeSelectionEnabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
async function bench(options: {
|
||||
failSettingsUpdate?: boolean
|
||||
} = {}) {
|
||||
const ctx = new Context()
|
||||
// The host's answer, mutable so a spec can move the default the way the
|
||||
// settings surface does and watch who re-reads it.
|
||||
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE
|
||||
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED | typeof ROSTER_HIDDEN = ROSTER_ONE
|
||||
const moveDefault = (): void => { ROSTER = ROSTER_MOVED }
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
locale.setLocale('zh')
|
||||
ctx.provide('locale', locale)
|
||||
const calls: string[] = []
|
||||
let savedDefault = 'standard'
|
||||
let selectionEnabled = true
|
||||
// The row reads `describe` to learn whether this browser may write at all,
|
||||
// and its default write is the one op this spec records.
|
||||
const settings = {
|
||||
@@ -78,8 +95,23 @@ async function bench() {
|
||||
ok: true as const,
|
||||
value: { writable: true, hasDocument: true, namespaces: [] },
|
||||
}),
|
||||
update: (_ns: string, patch: unknown) => {
|
||||
update: (_ns: string, patch: { default?: unknown; modeSelectionEnabled?: unknown }) => {
|
||||
calls.push(`settings:${JSON.stringify(patch)}`)
|
||||
if (options.failSettingsUpdate === true) {
|
||||
return Promise.resolve({
|
||||
ok: false as const,
|
||||
error: new RemoteError('gateway/internal', 'settings write disconnected', {}),
|
||||
})
|
||||
}
|
||||
if (typeof patch.default === 'string') {
|
||||
savedDefault = patch.default
|
||||
}
|
||||
if (typeof patch.modeSelectionEnabled === 'boolean') {
|
||||
selectionEnabled = patch.modeSelectionEnabled
|
||||
}
|
||||
ROSTER = !selectionEnabled
|
||||
? ROSTER_HIDDEN
|
||||
: savedDefault === 'minimal' ? ROSTER_MOVED : ROSTER_ONE
|
||||
return Promise.resolve({ ok: true as const, value: {} })
|
||||
},
|
||||
openAgentPresetDirectory: (agentPreset: string) => {
|
||||
@@ -215,6 +247,7 @@ describe('ui-agent-preset apply', () => {
|
||||
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
|
||||
await section.load()
|
||||
await section.makeDefault('standard')
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().rows)
|
||||
.toEqual([{ id: 'standard', trust: 'system', isDefault: true }])
|
||||
@@ -269,6 +302,10 @@ describe('ui-agent-preset apply', () => {
|
||||
it('re-reads both surfaces when the connection comes back', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
await section.load()
|
||||
@@ -277,7 +314,8 @@ describe('ui-agent-preset apply', () => {
|
||||
ctx.emit('connection/reset')
|
||||
|
||||
// A reconnect can land on a host whose roster changed under the browser.
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
|
||||
await vi.waitFor(() => { expect(calls.length).toBe(before + 3) })
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('leaves the section alone until it has been opened once', async () => {
|
||||
@@ -348,6 +386,73 @@ describe('ui-agent-preset apply', () => {
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('aligns Settings defaults with the current blank Session, never a running one', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const sessionState = {
|
||||
current: 's1',
|
||||
byId: {
|
||||
s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } },
|
||||
},
|
||||
}
|
||||
const sessions = sessionsDouble(sessionState)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!
|
||||
.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
await Promise.all([section.load(), seat.load()])
|
||||
|
||||
await section.makeDefault('minimal')
|
||||
expect(calls.filter(call => call.startsWith('select:'))).toEqual(['select:minimal'])
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
|
||||
sessionState.byId.s1.projectionValues.agentPreset = 'minimal'
|
||||
|
||||
await section.setPickerVisible(false)
|
||||
expect(calls.filter(call => call.startsWith('select:'))).toEqual([
|
||||
'select:minimal', 'select:standard',
|
||||
])
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().showPicker).toBe(false)
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
sessionState.byId.s1.projectionValues.agentPreset = 'standard'
|
||||
|
||||
await section.setPickerVisible(true)
|
||||
expect(calls.filter(call => call.startsWith('select:'))).toEqual([
|
||||
'select:minimal', 'select:standard', 'select:minimal',
|
||||
])
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().showPicker).toBe(true)
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
|
||||
sessionState.byId.s1.projectionValues.agentPreset = 'minimal'
|
||||
|
||||
sessionState.byId.s1.blank = false
|
||||
await section.makeDefault('standard')
|
||||
expect(calls.filter(call => call.startsWith('select:'))).toEqual([
|
||||
'select:minimal', 'select:standard', 'select:minimal',
|
||||
])
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('reloads Host truth after a picker-policy save failure', async () => {
|
||||
const { ctx, slots, calls } = await bench({ failSettingsUpdate: true })
|
||||
declareRoot(slots)
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!
|
||||
.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
await section.load()
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().showPicker).toBe(true)
|
||||
|
||||
await section.setPickerVisible(false)
|
||||
|
||||
expect(calls.filter(call => call === 'list')).toHaveLength(2)
|
||||
expect(section.hooks.agentPresetSection.getSnapshot()).toMatchObject({
|
||||
showPicker: true, policySaving: false, error: 'settings write disconnected',
|
||||
})
|
||||
})
|
||||
|
||||
it('offers a just-authored preset on the new-session chip', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
@@ -495,6 +600,11 @@ describe('ui-agent-preset apply', () => {
|
||||
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await section.load()
|
||||
await section.setPickerVisible(false)
|
||||
section.startCreatorDraft?.()
|
||||
expect(uiWorkspace.starts).toHaveLength(0)
|
||||
await section.setPickerVisible(true)
|
||||
section.startCreatorDraft?.()
|
||||
|
||||
// The pick is staged on the chip's own controller — the session the
|
||||
@@ -536,6 +646,7 @@ describe('ui-agent-preset apply', () => {
|
||||
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await section.load()
|
||||
section.startCreatorDraft?.()
|
||||
state.current = 's1'
|
||||
state.byId['s1'] = { id: 's1', blank: true }
|
||||
@@ -568,6 +679,25 @@ describe('ui-agent-preset apply', () => {
|
||||
})
|
||||
|
||||
describe('AgentPresetSeatController reconciliation', () => {
|
||||
it('does not retarget a different blank Session after a Settings write', async () => {
|
||||
const select = vi.fn(() => Promise.resolve({ ok: true as const, value: 'minimal' }))
|
||||
let current = {
|
||||
id: SessionId('first'), blank: true, projectionValues: { agentPreset: 'standard' },
|
||||
}
|
||||
const controller = new AgentPresetSeatController({
|
||||
remote: { agentPresets: { select } },
|
||||
} as never, () => current)
|
||||
const captured = controller.blankSessionId()
|
||||
if (captured === undefined) throw new Error('expected a blank Session')
|
||||
current = {
|
||||
id: SessionId('second'), blank: true, projectionValues: { agentPreset: 'standard' },
|
||||
}
|
||||
|
||||
await controller.syncBlankSession(captured, 'minimal')
|
||||
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the deployment default without a Session and clears it for an uncomposed Session', async () => {
|
||||
const state: { current?: { id: SessionId; blank: boolean } } = {}
|
||||
const controller = new AgentPresetSeatController({
|
||||
|
||||
@@ -27,6 +27,7 @@ const ROSTER_READY: AgentPresetSettingsState = {
|
||||
}
|
||||
|
||||
const SEAT_READY: AgentPresetSeatState = {
|
||||
showPicker: true,
|
||||
current: 'standard',
|
||||
options: [
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
@@ -80,6 +81,12 @@ function renderLabel(
|
||||
}
|
||||
|
||||
describe('the new-session chip', () => {
|
||||
it('renders nothing while the picker is disabled', () => {
|
||||
renderSeat({ showPicker: false })
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the roster once and shows the staged preset by name', async () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ interface FakeOptions {
|
||||
calls?: Recorded[]
|
||||
/** Reject `list` with this message. */
|
||||
failList?: string
|
||||
/** Reject only this numbered `list` call. */
|
||||
failListAt?: number
|
||||
/** Reject `read` with this message. */
|
||||
failRead?: string
|
||||
/** Reject `copy` with this message. */
|
||||
@@ -38,6 +40,14 @@ interface FakeOptions {
|
||||
failCapability?: string
|
||||
/** Hold `remove` until this resolves, to observe the in-flight state. */
|
||||
holdRemove?: Promise<void>
|
||||
/** Initial new-session picker visibility. */
|
||||
showPicker?: boolean
|
||||
/** Mutable Host policy used when a Settings write is reflected by the roster. */
|
||||
pickerPolicy?: { enabled: boolean }
|
||||
/** Simulate a concurrent Host write winning after this client's policy write. */
|
||||
ignorePickerWrite?: boolean
|
||||
/** Throw from the Settings transport instead of returning a Remote failure. */
|
||||
throwSettings?: unknown
|
||||
}
|
||||
|
||||
const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value })
|
||||
@@ -58,18 +68,26 @@ function fakeCtx(
|
||||
options: FakeOptions = {},
|
||||
): ClientContext {
|
||||
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
|
||||
let listCount = 0
|
||||
return {
|
||||
remote: {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
record('list', {})
|
||||
if (options.failList !== undefined) return remoteFail(options.failList)
|
||||
listCount += 1
|
||||
if (options.failList !== undefined
|
||||
&& (options.failListAt === undefined || options.failListAt === listCount)) {
|
||||
return remoteFail(options.failList)
|
||||
}
|
||||
const selectionEnabled = options.pickerPolicy?.enabled ?? options.showPicker ?? true
|
||||
const effectiveDefault = selectionEnabled ? defaultId.id : 'standard'
|
||||
return remoteOk({
|
||||
presets: [...presets].map(([id, preset]) => ({
|
||||
id, trust: preset.trust, isDefault: id === defaultId.id,
|
||||
id, trust: preset.trust, isDefault: id === effectiveDefault,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
})),
|
||||
authorable: options.authorable ?? true,
|
||||
modeSelectionEnabled: selectionEnabled,
|
||||
})
|
||||
},
|
||||
read: (agentPreset: string) => {
|
||||
@@ -114,19 +132,23 @@ function fakeCtx(
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
update: (ns: string, patch: { default?: unknown; modeSelectionEnabled?: unknown }) => {
|
||||
record('settings.update', { ns, patch })
|
||||
if (options.throwSettings !== undefined) throw options.throwSettings
|
||||
if (options.failSettings !== undefined) return remoteFail(options.failSettings)
|
||||
if (typeof patch.default === 'string') defaultId.id = patch.default
|
||||
if (typeof patch.modeSelectionEnabled === 'boolean' && !options.ignorePickerWrite) {
|
||||
const policy = options.pickerPolicy ?? { enabled: options.showPicker ?? true }
|
||||
policy.enabled = patch.modeSelectionEnabled
|
||||
}
|
||||
return remoteOk({})
|
||||
},
|
||||
canOpenAgentPresetDirectory: () => {
|
||||
record('canOpenAgentPresetDirectory', {})
|
||||
return options.failCapability === undefined
|
||||
? remoteOk(options.hasDocument ?? true)
|
||||
: remoteFail(options.failCapability)
|
||||
},
|
||||
update: (ns: string, patch: { default?: string }) => {
|
||||
record('settings.update', { ns, patch })
|
||||
if (options.failSettings !== undefined) return remoteFail(options.failSettings)
|
||||
/* v8 ignore next -- the controller only ever sets `default` */
|
||||
defaultId.id = patch.default ?? defaultId.id
|
||||
return remoteOk({})
|
||||
},
|
||||
openAgentPresetDirectory: (agentPreset: string) => {
|
||||
record('openAgentPresetDirectory', { agentPreset })
|
||||
if (options.failOpen !== undefined) return remoteFail(options.failOpen)
|
||||
@@ -151,12 +173,16 @@ function harness(options: FakeOptions = {}) {
|
||||
const defaultId = { id: 'standard' }
|
||||
const calls: Recorded[] = []
|
||||
let rosterChanges = 0
|
||||
const wired = { ...options, calls: options.calls ?? calls }
|
||||
const pickerPolicy = options.pickerPolicy ?? { enabled: options.showPicker ?? true }
|
||||
const wired = { ...options, pickerPolicy, calls: options.calls ?? calls }
|
||||
const controller = new AgentPresetSectionController(
|
||||
fakeCtx(presets, defaultId, wired),
|
||||
() => { rosterChanges += 1 },
|
||||
)
|
||||
return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
|
||||
return {
|
||||
controller, presets, defaultId, pickerPolicy, calls,
|
||||
rosterChanges: () => rosterChanges,
|
||||
}
|
||||
}
|
||||
|
||||
function copyOf(controller: AgentPresetSectionController): CopyDraft {
|
||||
@@ -201,12 +227,12 @@ describe('loading the roster', () => {
|
||||
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('keeps one load in flight rather than stacking reads', async () => {
|
||||
it('coalesces concurrent refreshes into one follow-up read', async () => {
|
||||
const { controller, calls } = harness()
|
||||
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
await Promise.all([controller.load(), controller.load(), controller.load()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('surfaces a refusal as the page error', async () => {
|
||||
@@ -528,14 +554,26 @@ describe('a controller with no roster listener', () => {
|
||||
})
|
||||
|
||||
describe('the default preset', () => {
|
||||
it('writes the setting and re-reads the roster', async () => {
|
||||
it('syncs the Host value that wins before the confirming read', async () => {
|
||||
const { controller, defaultId } = harness()
|
||||
const synced: string[] = []
|
||||
const sync = (id: string): Promise<undefined> => {
|
||||
synced.push(id)
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
const makingDefault = controller.makeDefault('mine', sync)
|
||||
defaultId.id = 'standard'
|
||||
await makingDefault
|
||||
|
||||
expect(defaultId.id).toBe('mine')
|
||||
expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true)
|
||||
expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard')
|
||||
expect(synced).toEqual(['standard'])
|
||||
|
||||
const missingDefault = controller.makeDefault('mine', sync)
|
||||
defaultId.id = 'missing'
|
||||
await missingDefault
|
||||
expect(synced).toEqual(['standard'])
|
||||
})
|
||||
|
||||
it('surfaces a settings refusal as the page error', async () => {
|
||||
@@ -546,4 +584,138 @@ describe('the default preset', () => {
|
||||
|
||||
expect(controller.store.getSnapshot().error).toContain('read-only settings')
|
||||
})
|
||||
|
||||
it('keeps a composition sync failure on the page', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault(
|
||||
'mine',
|
||||
() => Promise.resolve('blank session rejected the preset'),
|
||||
)
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
error: 'blank session rejected the preset', policySaving: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the policy lock after a thrown default write', async () => {
|
||||
const { controller } = harness({ throwSettings: 'settings transport unavailable' })
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
error: 'settings transport unavailable', policySaving: false,
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('the new-session picker preference', () => {
|
||||
it('ignores policy writes until the section is ready', async () => {
|
||||
const { controller, calls } = harness({ showPicker: true })
|
||||
|
||||
await controller.setPickerVisible(false)
|
||||
|
||||
expect(calls.some(call => call.method === 'settings.update')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses Standard while disabled and restores the saved default when re-enabled', async () => {
|
||||
const { controller, calls, defaultId } = harness({
|
||||
showPicker: true, failList: 'connection moved', failListAt: 2,
|
||||
})
|
||||
const synced: string[] = []
|
||||
const sync = (id: string): Promise<undefined> => {
|
||||
synced.push(id)
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
defaultId.id = 'mine'
|
||||
await controller.load()
|
||||
expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
|
||||
|
||||
await controller.setPickerVisible(false, sync)
|
||||
expect(calls.filter(call => call.method === 'settings.update')[0]?.payload)
|
||||
.toEqual({ ns: 'agent-presets', patch: { modeSelectionEnabled: false } })
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
showPicker: false, policySaving: false,
|
||||
})
|
||||
expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard')
|
||||
|
||||
await controller.setPickerVisible(true, sync)
|
||||
expect(calls.filter(call => call.method === 'settings.update')[1]?.payload)
|
||||
.toEqual({ ns: 'agent-presets', patch: { modeSelectionEnabled: true } })
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
showPicker: true, policySaving: false,
|
||||
})
|
||||
expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
|
||||
expect(synced).toEqual(['standard', 'mine'])
|
||||
})
|
||||
|
||||
it('reloads Host truth and reports a refused visibility write', async () => {
|
||||
const { controller } = harness({ showPicker: true, failSettings: 'read-only settings' })
|
||||
await controller.load()
|
||||
|
||||
await controller.setPickerVisible(false)
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
showPicker: true, error: 'read-only settings', policySaving: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads Host truth when another policy value wins the write', async () => {
|
||||
const { controller } = harness({ showPicker: true, ignorePickerWrite: true })
|
||||
await controller.load()
|
||||
|
||||
await controller.setPickerVisible(false)
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ showPicker: true, policySaving: false })
|
||||
})
|
||||
|
||||
it('reloads a roster that cannot mark an effective default', async () => {
|
||||
const { controller, defaultId } = harness({ showPicker: false })
|
||||
defaultId.id = 'missing'
|
||||
await controller.load()
|
||||
|
||||
await controller.setPickerVisible(true)
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ showPicker: true, policySaving: false })
|
||||
expect(controller.store.getSnapshot().rows.every(row => !row.isDefault)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a blank-task sync failure on the page', async () => {
|
||||
const { controller } = harness({ showPicker: true })
|
||||
await controller.load()
|
||||
|
||||
await controller.setPickerVisible(
|
||||
false,
|
||||
() => Promise.resolve('blank session rejected the policy'),
|
||||
)
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
error: 'blank session rejected the policy', policySaving: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads Host truth after a thrown visibility write', async () => {
|
||||
const { controller } = harness({ showPicker: true, throwSettings: new Error('connection lost') })
|
||||
await controller.load()
|
||||
|
||||
await controller.setPickerVisible(false)
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
showPicker: true, error: 'connection lost', policySaving: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores default writes while mode selection is disabled', async () => {
|
||||
const { controller, calls } = harness({ showPicker: false })
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
|
||||
expect(calls.some(call => call.method === 'settings.update')).toBe(false)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ const READY: AgentPresetSectionState = {
|
||||
error: null,
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
showPicker: true,
|
||||
policySaving: false,
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
@@ -59,6 +61,7 @@ function renderSection(
|
||||
confirmDelete: vi.fn(),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
makeDefault: vi.fn(() => Promise.resolve()),
|
||||
setPickerVisible: vi.fn(() => Promise.resolve()),
|
||||
}
|
||||
const props = {
|
||||
...actions,
|
||||
@@ -107,6 +110,41 @@ describe('the preset list', () => {
|
||||
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('explains and reverses a Host-disabled picker without offering preset changes', () => {
|
||||
const actions = renderSection({
|
||||
showPicker: false,
|
||||
error: 'settings write disconnected',
|
||||
rows: [
|
||||
...READY.rows,
|
||||
{ id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' },
|
||||
],
|
||||
})
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: en.showPicker })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
fireEvent.click(toggle)
|
||||
expect(actions.setPickerVisible).toHaveBeenCalledWith(true)
|
||||
expect(screen.getByRole('alert').textContent).toBe('settings write disconnected')
|
||||
|
||||
const creator = screen.getByRole('button', { name: en.creatorDraft })
|
||||
expect(creator).toHaveProperty('disabled', true)
|
||||
expect(creator.getAttribute('title')).toBe(en.enablePickerToCreate)
|
||||
|
||||
const standard = within(rowFor('standard')).getByRole('button', {
|
||||
name: `${en.selectionOffDefault}: ${en.presetStandardName}`,
|
||||
})
|
||||
expect(standard.getAttribute('title')).toBe(en.selectionOffDefault)
|
||||
expect(within(rowFor('standard')).getByText(en.selectionOffDefault)).toBeTruthy()
|
||||
|
||||
const mine = within(rowFor('mine')).getByRole('button', {
|
||||
name: `${en.enablePickerToSetDefault}: mine`,
|
||||
})
|
||||
expect(mine).toHaveProperty('disabled', true)
|
||||
expect(mine.getAttribute('title')).toBe(en.enablePickerToSetDefault)
|
||||
fireEvent.click(mine)
|
||||
expect(actions.makeDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('separates built-in presets from custom ones', () => {
|
||||
renderSection()
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* The agent-preset roster store: it derives the display options from one
|
||||
* roster call and treats an empty roster as "this deployment composes no
|
||||
* presets" rather than as a failure. The default is written by the
|
||||
* management section through `writeDefaultPreset`, which targets only the
|
||||
* `default` field of the agent-presets namespace.
|
||||
* presets" rather than as a failure. The management section writes each
|
||||
* preference through a narrow field writer in the same settings namespace.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -13,7 +12,8 @@ import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, writeDefaultPreset,
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
|
||||
writeDefaultPreset, writeModeSelectionEnabled,
|
||||
} from '../src/client/settings-store.ts'
|
||||
|
||||
/** The roster store over a scripted context. */
|
||||
@@ -29,7 +29,12 @@ interface Recorded { ns: string; ops: unknown }
|
||||
/** A roster Remote answering a fixed set of rows, or refusing. */
|
||||
function fakeRoster(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
options: { failList?: string; failListCode?: RemoteErrorCode; settings?: object } = {},
|
||||
options: {
|
||||
failList?: string
|
||||
failListCode?: RemoteErrorCode
|
||||
settings?: object
|
||||
showPicker?: boolean
|
||||
} = {},
|
||||
): ClientContext {
|
||||
return {
|
||||
remote: {
|
||||
@@ -37,7 +42,12 @@ function fakeRoster(
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
return Promise.resolve(options.failList === undefined
|
||||
? { ok: true as const, value: { presets, authorable: true } }
|
||||
? {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets, authorable: true, modeSelectionEnabled: options.showPicker ?? true,
|
||||
},
|
||||
}
|
||||
: {
|
||||
ok: false as const,
|
||||
error: new RemoteError(options.failListCode ?? 'gateway/internal', options.failList, {}),
|
||||
@@ -58,14 +68,13 @@ function fakeApi(
|
||||
} = {},
|
||||
): ClientContext {
|
||||
const settings = {
|
||||
update: (ns: string, patch: { default?: unknown }) => {
|
||||
update: (ns: string, patch: { default?: unknown; modeSelectionEnabled?: unknown }) => {
|
||||
options.writes?.push({ ns, ops: patch })
|
||||
if (options.failWrite !== undefined) {
|
||||
return Promise.resolve({ ok: false as const, error: new RemoteError('gateway/internal', options.failWrite, {}) })
|
||||
}
|
||||
// A committed write moves the roster's default.
|
||||
for (const preset of presets) {
|
||||
preset.isDefault = preset.id === patch.default
|
||||
if (patch.default !== undefined) {
|
||||
for (const preset of presets) preset.isDefault = preset.id === patch.default
|
||||
}
|
||||
return Promise.resolve({ ok: true as const, value: {} })
|
||||
},
|
||||
@@ -166,6 +175,16 @@ describe('the agent-preset roster store', () => {
|
||||
expect(await writeDefaultPreset(ctx, 'minimal')).toBe('read-only settings')
|
||||
})
|
||||
|
||||
it('writeModeSelectionEnabled writes only the picker policy field', async () => {
|
||||
const writes: Recorded[] = []
|
||||
|
||||
expect(await writeModeSelectionEnabled(fakeApi([], { writes }), false)).toBeUndefined()
|
||||
expect(writes).toEqual([{
|
||||
ns: AGENT_PRESET_SETTINGS_NS,
|
||||
ops: { modeSelectionEnabled: false },
|
||||
}])
|
||||
})
|
||||
|
||||
it('surfaces a roster failure without claiming the deployment has no presets', async () => {
|
||||
const controller = derivedController(fakeApi([], { failList: 'host down' }))
|
||||
|
||||
@@ -198,19 +217,26 @@ describe('the new-session chip controller', () => {
|
||||
failSelect?: string
|
||||
failList?: string
|
||||
failListCode?: RemoteErrorCode
|
||||
showPicker?: boolean
|
||||
list?: () => Promise<ReturnType<typeof remoteRoster>>
|
||||
} = {},
|
||||
): AgentPresetSeatController {
|
||||
const ctx = {
|
||||
remote: {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
list: options.list ?? (() => {
|
||||
return Promise.resolve(options.failList === undefined
|
||||
? { ok: true as const, value: { presets, authorable: true } }
|
||||
? {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets, authorable: true, modeSelectionEnabled: options.showPicker ?? true,
|
||||
},
|
||||
}
|
||||
: {
|
||||
ok: false as const,
|
||||
error: new RemoteError(options.failListCode ?? 'gateway/internal', options.failList, {}),
|
||||
})
|
||||
},
|
||||
}),
|
||||
select: (agentId: SessionId, agentPreset: string) => {
|
||||
options.writes?.push({ ns: 'select', ops: agentPreset })
|
||||
return Promise.resolve(options.failSelect === undefined
|
||||
@@ -250,6 +276,24 @@ describe('the new-session chip controller', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('takes picker visibility from the newest Host roster truth', async () => {
|
||||
const first = Promise.withResolvers<ReturnType<typeof remoteRoster>>()
|
||||
const second = Promise.withResolvers<ReturnType<typeof remoteRoster>>()
|
||||
const replies = [first.promise, second.promise]
|
||||
const controller = chip([], undefined, { list: () => replies.shift()! })
|
||||
|
||||
const older = controller.load()
|
||||
const newer = controller.load()
|
||||
second.resolve(remoteRoster(false))
|
||||
await newer
|
||||
first.resolve(remoteRoster(true))
|
||||
await older
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
showPicker: false, current: 'standard', error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
const controller = chip([{ id: 'minimal', trust: 'system', isDefault: false }], undefined)
|
||||
|
||||
@@ -433,6 +477,26 @@ describe('the new-session chip controller', () => {
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('clears an unconsumed stage when the Host hides the picker', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, {
|
||||
id: 's1' as SessionId,
|
||||
blank: false,
|
||||
projectionValues: { agentPreset: 'standard' },
|
||||
}, { writes, showPicker: false })
|
||||
controller.stage('minimal', true)
|
||||
|
||||
await controller.load()
|
||||
await controller.apply()
|
||||
|
||||
expect(writes).toEqual([])
|
||||
expect(controller.store.getSnapshot()).toMatchObject({
|
||||
showPicker: false,
|
||||
current: 'standard',
|
||||
introduce: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a refused roster read without emptying the chip', async () => {
|
||||
const controller = chip(ROSTER, undefined, { failList: 'host down' })
|
||||
|
||||
@@ -442,3 +506,14 @@ describe('the new-session chip controller', () => {
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
function remoteRoster(modeSelectionEnabled: boolean) {
|
||||
return {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [{ id: 'standard', trust: 'system' as const, isDefault: true }],
|
||||
authorable: true,
|
||||
modeSelectionEnabled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: '@Remote(\'list\') async remoteExportList(): Promise<AgentPresetRoster>',
|
||||
description: 'The roster off the Host: list projected to path-free rows, with the default marked and this deployment\'s authoring capability beside it.\n\nWhether a client can open a preset\'s directory is the Host\'s own opener capability, not a roster property — a caller needing both joins them.',
|
||||
description: 'The roster off the Host: list projected to path-free rows, with the policy-effective default marked, this deployment\'s authoring capability, and its mode-selection policy beside it.\n\nWhether a client can open a preset\'s directory is the Host\'s own opener capability, not a roster property — a caller needing both joins them.',
|
||||
parameters: [],
|
||||
returns: 'the rows and the authoring capability.',
|
||||
returns: 'the rows, authoring capability, and effective selection policy.',
|
||||
},
|
||||
{
|
||||
signature: 'async compositionInventory(): Promise<AgentPresetComposition[]>',
|
||||
@@ -3616,7 +3616,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'AgentPresetRoster',
|
||||
declaration: 'export interface AgentPresetRoster {\n readonly presets: readonly AgentPresetRow[];\n readonly authorable: boolean;\n}',
|
||||
declaration: 'export interface AgentPresetRoster {\n readonly presets: readonly AgentPresetRow[];\n readonly authorable: boolean;\n readonly modeSelectionEnabled: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AgentPresetRow',
|
||||
|
||||
@@ -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: e82282b1c7d800b76d07ca8a658a6bfc12e9d588
|
||||
README.zh.md: fe770aff90f31b547753d897e13140da507eb7a2
|
||||
README.md: 3f9764206d86ee24b7daad898f08d7b0f8538c8d
|
||||
README.zh.md: 11423e94e8dc71e6eac52653d952b2203083574b
|
||||
|
||||
@@ -50,7 +50,7 @@ The plugin needs a `default` preset id and scans `roots` for presets:
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `default` | required | Preset id composed when a session names none |
|
||||
| `default` | required | Deployment fallback preset id, used while mode selection is disabled or no user default overrides it |
|
||||
| `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) |
|
||||
| `includeShippedRoot` | `true` | Prepend the package's bundled presets as a `system` root before every configured root |
|
||||
| `includeUserRoot` | `true` | Append `<dshHome>/.agent-presets` as a `user` root, after every configured root |
|
||||
@@ -59,16 +59,17 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
The shipped root is prepended before every configured root, so the built-in set remains available and wins duplicate ids even when a patch replaces the roster configuration. `includeShippedRoot: false` drops that built-in set for deployments that supply all presets themselves. `includeUserRoot: false` drops the derived writable root; tests that pin an exact roster disable both derived roots.
|
||||
|
||||
### Choosing the default preset
|
||||
### Showing the picker and choosing its default
|
||||
|
||||
The `default` config sets the deployment default. When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its base, so a user document layers a per-user default over the deployment's:
|
||||
The required `default` config sets the deployment default. When a settings provider is composed, this plugin registers the `agent-presets` namespace with `{ default: config.default, modeSelectionEnabled: true }` as its base, so the existing new-session picker remains visible unless a user turns it off. The Host reads both fields on every default resolution: while `modeSelectionEnabled` is false, an omitted preset resolves to `config.default` even if the user document retains another `default`; while it is true, a user default may override the deployment value:
|
||||
|
||||
```yaml
|
||||
agent-presets:
|
||||
modeSelectionEnabled: true
|
||||
default: minimal
|
||||
```
|
||||
|
||||
The value is read when a session is created, so a changed default affects only sessions created afterwards; running sessions stay on the preset they were composed from. Clearing the user field re-inherits the composition default.
|
||||
A client shows or hides selection by writing only `modeSelectionEnabled`; the [Web GUI settings switch](../../client/ui-agent-preset/README.md) does exactly that. The deployment default governs while selection is hidden; re-enabling it restores the saved user `default`, or keeps the deployment default when none has been saved. While mode selection stays enabled, choosing a default writes a user override for sessions created later. Because the Host owns the policy, it applies to every subsequently created session whose caller omits a preset, including Web, CLI, SDK, and headless callers; an explicitly named preset and every existing session remain unchanged.
|
||||
|
||||
### Authoring presets
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ kind: "package-reference"
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `default` | 必填 | 会话未指定时组装的 preset id |
|
||||
| `default` | 必填 | 部署 fallback preset id;模式选择关闭或没有用户默认值覆盖时使用 |
|
||||
| `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) |
|
||||
| `includeShippedRoot` | `true` | 在全部已配置根目录之前,前置本包随附的 preset 作为 `system` 根目录 |
|
||||
| `includeUserRoot` | `true` | 在全部已配置根目录之后追加 `<dshHome>/.agent-presets` 作为 `user` 根目录 |
|
||||
@@ -59,16 +59,17 @@ kind: "package-reference"
|
||||
|
||||
随附根目录前置在全部已配置根目录之前,因此即使补丁替换 roster 配置,内置集合仍然可用并赢得重复 id。`includeShippedRoot: false` 会为完全自行提供 preset 的部署移除内置集合。`includeUserRoot: false` 会移除推导出的可写根目录;钉住确切 roster 的测试会同时关闭两个推导根目录。
|
||||
|
||||
### 选择默认 preset
|
||||
### 显示选择器并选择默认 preset
|
||||
|
||||
`default` 配置设定部署级默认值。当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其 base,因此用户文档会在部署默认值之上层叠一份按用户设置的默认值:
|
||||
必填的 `default` 配置设定部署默认值。当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `{ default: config.default, modeSelectionEnabled: true }` 作为 base,因此既有的新建会话选择器会保持显示,除非用户主动关闭。Host 每次解析默认值都会读取这两个字段:`modeSelectionEnabled` 为 `false` 时,未显式指定 preset 的会话解析为 `config.default`,即使用户文档还保留其他 `default` 也会忽略它;该字段为 `true` 时,用户默认值才可覆盖部署值:
|
||||
|
||||
```yaml
|
||||
agent-presets:
|
||||
modeSelectionEnabled: true
|
||||
default: minimal
|
||||
```
|
||||
|
||||
该值在会话创建时读取,因此更改默认值只影响此后创建的会话;运行中的会话仍停留在它们当初据以组装的 preset 上。清空用户字段即重新继承组装默认值。
|
||||
客户端只需写入 `modeSelectionEnabled` 即可显示或隐藏选择,[Web GUI 设置开关](../../client/ui-agent-preset/README.zh.md)正是这样做的。选择器隐藏期间由部署默认值生效;再次开启时恢复已保存的用户 `default`,尚未保存时则继续使用部署默认值。模式选择保持开启时,选择默认模式会写入用户覆盖值,仅供此后创建的会话使用。由于该策略归 Host 所有,它适用于 Web、CLI、SDK 与 headless 调用方此后创建的全部未显式指定 preset 的会话;显式指定的 preset 与任何既有会话均不受影响。
|
||||
|
||||
### 创作 preset
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export type {
|
||||
AgentPresetComposition, AgentPresetCompositionRow, CompositionRowEnablement,
|
||||
} from './composition-inventory.ts'
|
||||
|
||||
/** Settings namespace carrying the user's chosen default preset. */
|
||||
/** Settings namespace carrying the user's preset-picker preference and chosen default. */
|
||||
export const SETTINGS_NAMESPACE = 'agent-presets'
|
||||
|
||||
/** Refuse an empty preset id before invoking a domain operation. */
|
||||
@@ -61,15 +61,18 @@ function validatePresetId(value: string, field: 'agentPreset' | 'from'): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** The user-writable slice of this plugin's config. */
|
||||
/** Resolved preset-selection settings; the registration base supplies both fields. */
|
||||
export interface AgentPresetSettings {
|
||||
/** Preset mounted when a session names none. */
|
||||
default?: string
|
||||
/** Saved default used when mode selection is enabled. */
|
||||
default: string
|
||||
/** Whether visible mode selection and the saved user default govern unnamed new sessions. */
|
||||
modeSelectionEnabled: boolean
|
||||
}
|
||||
|
||||
/** Runtime schema for the user-writable slice. */
|
||||
export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({
|
||||
default: z.string(),
|
||||
modeSelectionEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
export { COMPOSITION_FILE, discoverPresets, scanRoot, SHIPPED_PRESET_ROOT } from './discovery.ts'
|
||||
@@ -189,7 +192,7 @@ export class AgentPresets extends TypertRemoteService {
|
||||
this.settings = settingsCtx.settings.register(
|
||||
SETTINGS_NAMESPACE,
|
||||
AgentPresetSettingsSchema,
|
||||
{ base: { default: config.default } },
|
||||
{ base: { default: config.default, modeSelectionEnabled: true } },
|
||||
)
|
||||
this.settingsService = settingsCtx.settings
|
||||
settingsCtx.effect(() => () => {
|
||||
@@ -238,7 +241,21 @@ export class AgentPresets extends TypertRemoteService {
|
||||
* every running session on the preset it was composed from.
|
||||
*/
|
||||
get defaultId(): string {
|
||||
return this.settings?.get().default ?? this.config.default
|
||||
// Hiding the picker is also the product's safe-default boundary: a stale
|
||||
// user choice from an older build must not silently compose a non-standard
|
||||
// new session while there is no control that reports that choice.
|
||||
return this.selectionPolicy().defaultId
|
||||
}
|
||||
|
||||
/** Read one internally consistent snapshot of the selection policy. */
|
||||
private selectionPolicy(): { enabled: boolean; defaultId: string } {
|
||||
const settings = this.settings?.get()
|
||||
if (settings === undefined) return { enabled: true, defaultId: this.config.default }
|
||||
const enabled = settings.modeSelectionEnabled
|
||||
return {
|
||||
enabled,
|
||||
defaultId: enabled ? settings.default : this.config.default,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,25 +268,30 @@ export class AgentPresets extends TypertRemoteService {
|
||||
|
||||
/**
|
||||
* The roster off the Host: {@link list} projected to path-free rows, with
|
||||
* the default marked and this deployment's authoring capability beside it.
|
||||
* the policy-effective default marked, this deployment's authoring
|
||||
* capability, and its mode-selection policy beside it.
|
||||
*
|
||||
* Whether a client can open a preset's directory is the Host's own opener
|
||||
* capability, not a roster property — a caller needing both joins them.
|
||||
* @returns the rows and the authoring capability.
|
||||
* @returns the rows, authoring capability, and effective selection policy.
|
||||
*/
|
||||
@Remote('list')
|
||||
async remoteExportList(): Promise<AgentPresetRoster> {
|
||||
const defaultId = this.defaultId
|
||||
// Keep the visible policy and marked default from the same settings
|
||||
// snapshot even when discovery yields while settings are hot-reloaded.
|
||||
const policy = this.selectionPolicy()
|
||||
const presets = await this.list()
|
||||
return {
|
||||
presets: (await this.list()).map(preset => ({
|
||||
presets: presets.map(preset => ({
|
||||
id: preset.id,
|
||||
trust: preset.trust,
|
||||
isDefault: preset.id === defaultId,
|
||||
isDefault: preset.id === policy.defaultId,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
...preset.description === undefined ? {} : { description: preset.description },
|
||||
...preset.broken === undefined ? {} : { broken: preset.broken },
|
||||
})),
|
||||
authorable: this.authorable,
|
||||
modeSelectionEnabled: policy.enabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface AgentPresetRoster {
|
||||
readonly presets: readonly AgentPresetRow[]
|
||||
/** Whether this deployment has a root locally authored presets go to. */
|
||||
readonly authorable: boolean
|
||||
/** Whether visible mode selection is enabled for unnamed new sessions. */
|
||||
readonly modeSelectionEnabled: boolean
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
|
||||
@@ -117,6 +117,7 @@ describe('the roster a client reads', () => {
|
||||
const roster = await ctx.agentPresets.remoteExportList()
|
||||
|
||||
expect(roster.authorable).toBe(true)
|
||||
expect(roster.modeSelectionEnabled).toBe(true)
|
||||
expect(roster.presets).toEqual([
|
||||
{ id: 'minimal', trust: 'system', isDefault: true },
|
||||
{ id: 'standard', trust: 'system', isDefault: false },
|
||||
@@ -152,7 +153,7 @@ describe('the roster a client reads', () => {
|
||||
|
||||
// Composing no presets is a valid deployment: every session then shares
|
||||
// the host composition, and nothing can be written either.
|
||||
expect(roster).toEqual({ presets: [], authorable: false })
|
||||
expect(roster).toEqual({ presets: [], authorable: false, modeSelectionEnabled: true })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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.
|
||||
* The default preset is a user setting behind the preset picker. While the
|
||||
* picker is hidden, `config.default` remains the deployment's safe default;
|
||||
* once shown, the settings document overrides it and is hot-reloaded.
|
||||
*/
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -65,17 +65,27 @@ 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 () => {
|
||||
it('shows mode selection on the composition default by default', async () => {
|
||||
const { ctx } = await harness()
|
||||
|
||||
expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true)
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
|
||||
await ctx.settings.update(NS, { modeSelectionEnabled: false })
|
||||
expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(false)
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
|
||||
it('takes the user default over the composition default', async () => {
|
||||
it('temporarily ignores the saved user default while selection is off', async () => {
|
||||
const { ctx } = await harness()
|
||||
|
||||
await ctx.settings.update(NS, { default: 'minimal' })
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
|
||||
await ctx.settings.update(NS, { modeSelectionEnabled: false })
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
|
||||
await ctx.settings.update(NS, { modeSelectionEnabled: true })
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
})
|
||||
|
||||
@@ -109,6 +119,11 @@ describe('the default preset as a user setting', () => {
|
||||
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
expect(toolNames(ctx, running.agent)).toEqual(['alpha'])
|
||||
|
||||
await ctx.settings.update(NS, { modeSelectionEnabled: false })
|
||||
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
expect(toolNames(ctx, running.agent)).toEqual(['alpha'])
|
||||
} finally {
|
||||
await running.dispose()
|
||||
}
|
||||
@@ -117,10 +132,12 @@ describe('the default preset as a user setting', () => {
|
||||
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((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true)
|
||||
expect(ctx.agentPresets.defaultId).toBe('minimal')
|
||||
|
||||
await ctx.settings.replace(NS, {})
|
||||
|
||||
expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true)
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
|
||||
@@ -161,12 +178,14 @@ 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((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true)
|
||||
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((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true)
|
||||
expect(ctx.agentPresets.defaultId).toBe('standard')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user