ソースを参照

Merge remote-tracking branch 'origin/master' into worktree/composer-plus-menu

creatixchu 1 日 前
コミット
aed83d29ba
39 ファイル変更968 行追加162 行削除
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml
  2. 4 4
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
  3. 4 5
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md
  4. 1 0
      apps/cli/tests/web-agent-presets.e2e.ts
  5. 7 1
      apps/web/tests/agent-preset-authoring.e2e.ts
  6. 45 4
      apps/web/tests/agent-preset-selection.e2e.ts
  7. 5 2
      apps/web/tests/expected/agent-preset-authoring/created.expected.md
  8. 5 2
      apps/web/tests/expected/agent-preset-authoring/damaged.expected.md
  9. 5 2
      apps/web/tests/expected/agent-preset-authoring/section.expected.md
  10. 1 1
      docs/event-producer-consumer.i18n.yaml
  11. 1 1
      docs/event-producer-consumer.md
  12. 2 2
      docs/subsystems/core.i18n.yaml
  13. 3 2
      docs/subsystems/core.md
  14. 3 2
      docs/subsystems/core.zh.md
  15. 6 1
      packages/client/connection/src/client/fixture.ts
  16. 2 2
      packages/client/ui-agent-preset/README.i18n.yaml
  17. 5 6
      packages/client/ui-agent-preset/README.md
  18. 5 6
      packages/client/ui-agent-preset/README.zh.md
  19. 13 2
      packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx
  20. 48 1
      packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css
  21. 52 12
      packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx
  22. 20 3
      packages/client/ui-agent-preset/src/client/index.ts
  23. 18 3
      packages/client/ui-agent-preset/src/client/locales.ts
  24. 45 8
      packages/client/ui-agent-preset/src/client/seat-store.ts
  25. 106 13
      packages/client/ui-agent-preset/src/client/section-store.ts
  26. 27 10
      packages/client/ui-agent-preset/src/client/settings-store.ts
  27. 134 4
      packages/client/ui-agent-preset/tests/apply.client.spec.ts
  28. 7 0
      packages/client/ui-agent-preset/tests/components.client.spec.tsx
  29. 190 18
      packages/client/ui-agent-preset/tests/section-store.client.spec.ts
  30. 38 0
      packages/client/ui-agent-preset/tests/section.client.spec.tsx
  31. 88 13
      packages/client/ui-agent-preset/tests/settings-store.client.spec.ts
  32. 3 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  33. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  34. 5 4
      packages/preset/agent-presets/README.md
  35. 5 4
      packages/preset/agent-presets/README.zh.md
  36. 33 11
      packages/preset/agent-presets/src/index.ts
  37. 2 0
      packages/preset/agent-presets/src/types.ts
  38. 2 1
      packages/preset/agent-presets/tests/remote.spec.ts
  39. 24 5
      packages/preset/agent-presets/tests/settings.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml

@@ -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

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md

@@ -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.
 

+ 4 - 5
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md

@@ -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 永远不会清空它。
 

+ 1 - 0
apps/cli/tests/web-agent-presets.e2e.ts

@@ -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' })

+ 7 - 1
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -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 () => {

+ 45 - 4
apps/web/tests/agent-preset-selection.e2e.ts

@@ -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 })
 
-    const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd)
+    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

+ 5 - 2
apps/web/tests/expected/agent-preset-authoring/created.expected.md

@@ -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

+ 5 - 2
apps/web/tests/expected/agent-preset-authoring/damaged.expected.md

@@ -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

+ 5 - 2
apps/web/tests/expected/agent-preset-authoring/section.expected.md

@@ -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

+ 1 - 1
docs/event-producer-consumer.i18n.yaml

@@ -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

+ 1 - 1
docs/event-producer-consumer.md

@@ -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 - 2
docs/subsystems/core.i18n.yaml

@@ -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

+ 3 - 2
docs/subsystems/core.md

@@ -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>
 

+ 3 - 2
docs/subsystems/core.zh.md

@@ -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>
 

+ 6 - 1
packages/client/connection/src/client/fixture.ts

@@ -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 - 2
packages/client/ui-agent-preset/README.i18n.yaml

@@ -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

ファイルの差分が大きいため隠しています
+ 5 - 6
packages/client/ui-agent-preset/README.md


ファイルの差分が大きいため隠しています
+ 5 - 6
packages/client/ui-agent-preset/README.zh.md


+ 13 - 2
packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx

@@ -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 }) })
           })

+ 48 - 1
packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css

@@ -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 {

+ 52 - 12
packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx

@@ -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

+ 20 - 3
packages/client/ui-agent-preset/src/client/index.ts

@@ -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

+ 18 - 3
packages/client/ui-agent-preset/src/client/locales.ts

@@ -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`,

+ 45 - 8
packages/client/ui-agent-preset/src/client/seat-store.ts

@@ -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 })
   }
 }

+ 106 - 13
packages/client/ui-agent-preset/src/client/section-store.ts

@@ -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()
   }
 }

+ 27 - 10
packages/client/ui-agent-preset/src/client/settings-store.ts

@@ -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.

+ 134 - 4
packages/client/ui-agent-preset/tests/apply.client.spec.ts

@@ -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({

+ 7 - 0
packages/client/ui-agent-preset/tests/components.client.spec.tsx

@@ -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()
 

+ 190 - 18
packages/client/ui-agent-preset/tests/section-store.client.spec.ts

@@ -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(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard')
+    expect(synced).toEqual(['standard'])
 
-    expect(defaultId.id).toBe('mine')
-    expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true)
+    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)
+  })
+
 })

+ 38 - 0
packages/client/ui-agent-preset/tests/section.client.spec.tsx

@@ -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()
 

+ 88 - 13
packages/client/ui-agent-preset/tests/settings-store.client.spec.ts

@@ -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,
+    },
+  }
+}

+ 3 - 3
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -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 - 2
packages/preset/agent-presets/README.i18n.yaml

@@ -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

+ 5 - 4
packages/preset/agent-presets/README.md

@@ -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
 

+ 5 - 4
packages/preset/agent-presets/README.zh.md

@@ -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
 

+ 33 - 11
packages/preset/agent-presets/src/index.ts

@@ -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,
     }
   }
 

+ 2 - 0
packages/preset/agent-presets/src/types.ts

@@ -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' {

+ 2 - 1
packages/preset/agent-presets/tests/remote.spec.ts

@@ -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 })
   })
 })
 

+ 24 - 5
packages/preset/agent-presets/tests/settings.spec.ts

@@ -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')
   })
 })

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません