ソースを参照

Merge pull request #2146 from deepseek-harness/fix/web-slash-catalog-follows-preset

修复 preset 切换后 / 面板不跟随与切不回原 preset
Yichen Jiang 1 ヶ月 前
コミット
6a1b89f3d2
37 ファイル変更475 行追加35 行削除
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml
  2. 37 0
      .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md
  3. 37 0
      .agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml
  5. 43 0
      .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md
  6. 43 0
      .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md
  7. 94 13
      apps/web/tests/agent-preset-selection.e2e.ts
  8. 2 2
      docs/event-producer-consumer.i18n.yaml
  9. 1 0
      docs/event-producer-consumer.md
  10. 1 0
      docs/event-producer-consumer.zh.md
  11. 2 2
      packages/client/runtime/README.i18n.yaml
  12. 0 1
      packages/client/runtime/README.md
  13. 0 1
      packages/client/runtime/README.zh.md
  14. 15 0
      packages/client/runtime/src/client/index.ts
  15. 9 1
      packages/client/runtime/src/client/sessions/manager.ts
  16. 34 0
      packages/client/runtime/tests/sessions-service.spec.ts
  17. 13 1
      packages/client/runtime/tests/wire-events.spec.ts
  18. 2 2
      packages/client/ui-command/README.i18n.yaml
  19. 1 1
      packages/client/ui-command/README.md
  20. 1 1
      packages/client/ui-command/README.zh.md
  21. 5 0
      packages/client/ui-command/src/client/service.ts
  22. 24 0
      packages/client/ui-command/tests/service.spec.ts
  23. 2 2
      packages/client/ui-skill/README.i18n.yaml
  24. 1 1
      packages/client/ui-skill/README.md
  25. 1 1
      packages/client/ui-skill/README.zh.md
  26. 6 1
      packages/client/ui-skill/src/client/index.ts
  27. 15 0
      packages/client/ui-skill/tests/browser-plugin.spec.ts
  28. 2 2
      packages/host/apiproxy/README.i18n.yaml
  29. 1 1
      packages/host/apiproxy/README.md
  30. 1 1
      packages/host/apiproxy/README.zh.md
  31. 11 0
      packages/host/apiproxy/src/api-proxy.ts
  32. 1 0
      packages/host/apiproxy/src/api/events.schema.ts
  33. 12 0
      packages/host/apiproxy/src/api/events.ts
  34. 32 0
      packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
  35. 1 0
      packages/host/apiproxy/tests/rpc-schemas.spec.ts
  36. 1 0
      scripts/gen-cordis-catalog.ts
  37. 12 1
      scripts/gen-doc-graphs.ts

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md
+2026-08-10-session-row-identity-covers-the-preset.md: 7a89dcb4e4ae292a06a1743842d2e9cf6bd96282
+2026-08-10-session-row-identity-covers-the-preset.zh.md: 7ffa3423818bcc867c942651540db1975737e073

+ 37 - 0
.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.md

@@ -0,0 +1,37 @@
+# Agent Note: The session-row identity guard covers the preset
+
+Status: implemented
+
+English | [中文](2026-08-10-session-row-identity-covers-the-preset.zh.md)
+
+## Problem
+
+`SessionManager.buildListSnapshot` memoizes list rows by value: a wire refresh mints all-new summary objects, so an entry equal to the cached one is replaced by the cached instance, and every `SessionListItem` memo downstream keeps hitting. The stated contract is "reuse the cached object when every field matches"; the comparison enumerated the fields by hand and did not enumerate `agentPreset`.
+
+A confirmed preset switch moves exactly that one field. `noteAgentPreset` upserts it and `applyMutation` merges it in — the merge deliberately does not take the mutation's `updatedAt`, so a switched row differs from its cached twin in the preset and in nothing else. The guard therefore judged the row unchanged and served the stale instance, permanently: the manager's own summaries said `minimal` while every reader of the projected snapshot went on reading `standard`.
+
+The hero chip is one of those readers, and it compares the pick against that row before sending anything. Switching back to the preset the session was created under looked to it like "already on that preset", so it dropped the stage and sent no RPC at all — the chip label moved while the composition did not. A session could be switched away from its creation-time preset once and never back.
+
+## Decision
+
+The identity guard compares `agentPreset` alongside the other summary fields, which is what "every field matches" already claimed. Nothing else changes: the memoization, the merge, and the chip's no-op check all stay as they are, because each is correct once the row it reads is.
+
+## Alternatives considered
+
+**Have the chip re-read the host instead of the list row.** It would route around the stale row, but the row is also what the session header labels itself from, so the staleness would survive in the surface where it is most visible — and any future reader of `SessionSummary.agentPreset` would inherit the same trap.
+
+**Drop the entry-identity memoization and rebuild rows every snapshot.** It removes the whole class of missing-field bugs, at the cost the memo exists to avoid: a wire refresh mints new objects for every row, so each refresh would re-render the entire session list.
+
+**Compare summaries structurally rather than field by field.** A generic deep comparison cannot be added blind: the row carries `projectionValues`, whose reference identity is the deliberate signal that the projection store republished, and folding it into a value comparison would either re-render on every projection tick or mask a real one.
+
+## Consequences
+
+Every field a session row carries now participates in row identity, so a surface reading `SessionSummary.agentPreset` sees a switch as soon as the host confirms it — the header label included. The guard is still a hand-written enumeration, so a field added to `SessionSummary` later must be added here too; the `sessions-service` projection test names the failure mode for the next such field rather than only pinning this one.
+
+## Testing
+
+`sessions-service.spec.ts` feeds a blank row, notes a switch, and asserts the projected snapshot reports the new preset — it fails on the old guard because the row differs in nothing else. The `agent-preset-selection` web e2e switches down and back up, asserting the host honors the second switch and the `/` catalog returns with it; without this fix the second switch never reaches the host at all.
+
+## Related
+
+The same e2e covers [the catalog-invalidation fix](2026-08-10-slash-catalog-follows-preset-switch.md), which is what makes the menu follow either switch once the switch itself lands.

+ 37 - 0
.agents/notes/implemented/bug-fix/2026-08-10-session-row-identity-covers-the-preset.zh.md

@@ -0,0 +1,37 @@
+# Agent Note:会话行的标识判定纳入 preset
+
+Status: implemented
+
+[English](2026-08-10-session-row-identity-covers-the-preset.md) | 中文
+
+## Problem
+
+`SessionManager.buildListSnapshot` 按值对列表行做记忆化:一次 wire 刷新会铸造全新的 summary 对象,因此与缓存项相等的行会被替换为缓存实例,下游每一个 `SessionListItem` memo 才能持续命中。它声明的约定是「每个字段都相同就复用缓存对象」,而那段比较是手写枚举字段的,其中没有 `agentPreset`。
+
+一次已确认的 preset 切换恰好只移动这一个字段。`noteAgentPreset` 把它 upsert 进去,`applyMutation` 合并它——该合并有意不采用 mutation 的 `updatedAt`,因此切换后的行与它的缓存孪生只在 preset 上不同,别处一致。于是标识判定认为这一行没变,永久地提供了过期实例:manager 自己的 summaries 是 `minimal`,而所有读取投影快照的一方继续读到 `standard`。
+
+hero 上的 chip 正是其中一个读取方,而且它在发出任何请求之前会拿这次选择和那一行比较。切回会话创建时的那个 preset,在它看来就是「已经是这个 preset 了」,于是丢弃 stage、根本不发 RPC——chip 的标签变了,组成没变。一个会话可以从创建时的 preset 切走一次,然后再也切不回来。
+
+## Decision
+
+标识判定把 `agentPreset` 与其余 summary 字段一起比较,这本就是「每个字段都相同」所声称的内容。其他一概不动:记忆化、合并、chip 的 no-op 检查各自都是对的——只要它们读到的那一行是对的。
+
+## Alternatives considered
+
+**让 chip 改为直接读宿主,而不是读列表行。** 这样能绕开过期的行,但会话头部的标签同样以这一行为准,过期状态会在最显眼的界面里留下来;而且将来任何 `SessionSummary.agentPreset` 的读取方都会继承同一个陷阱。
+
+**去掉行标识记忆化,每次快照都重建行。** 这能整类消除「漏字段」缺陷,代价却正是这个 memo 存在的理由:一次 wire 刷新会为每一行铸造新对象,于是每次刷新都要重渲染整个会话列表。
+
+**改成结构化比较,而不是逐字段枚举。** 通用的深比较不能盲目加:行上带有 `projectionValues`,它的引用标识本身就是「投影 store 重新发布了」这一有意为之的信号,把它折进值比较,要么每个投影 tick 都重渲染,要么把一次真实变化掩盖掉。
+
+## Consequences
+
+会话行携带的每个字段现在都参与行标识,因此读取 `SessionSummary.agentPreset` 的界面会在宿主确认后立刻看到切换,会话头部标签也包含在内。该判定仍是手写枚举,所以将来给 `SessionSummary` 新增字段时必须同步加进来;`sessions-service` 的投影测试为下一个这样的字段点明了失效形态,而不只是钉住这一次。
+
+## Testing
+
+`sessions-service.spec.ts` 喂入一行空会话、记录一次切换,并断言投影快照报告的是新 preset——在旧判定下它会失败,因为这一行别处都没变。`agent-preset-selection` web e2e 先向下切再向上切,断言宿主认可第二次切换、`/` 目录随之回来;没有这次修复,第二次切换根本到不了宿主。
+
+## Related
+
+同一条 e2e 也覆盖[目录失效的修复](2026-08-10-slash-catalog-follows-preset-switch.md)——正是它让菜单在切换真正落地之后跟随任一方向的切换。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md
+2026-08-10-slash-catalog-follows-preset-switch.md: 85bd5b2134fd20c86fdeb13f3ce5b007449105b5
+2026-08-10-slash-catalog-follows-preset-switch.zh.md: 97c8f08a7b3dfec7c17fbb00bef626e28505c500

+ 43 - 0
.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md

@@ -0,0 +1,43 @@
+# Agent Note: The slash catalog follows a blank session's preset switch
+
+Status: implemented
+
+English | [中文](2026-08-10-slash-catalog-follows-preset-switch.zh.md)
+
+## Problem
+
+Presets moved the rows that decide what a session's `/` menu contains. The Web composition disables host-plane `skill-local`, `tool-skill`, `plan-mode`, and `command-compact`; a preset supplies them, so which commands and skills exist is a property of the session's composition rather than of the deployment.
+
+Both browser catalogs cache per session — `CommandDirectory` in `dsh-client-ui-command`, the single-flight fetch map in `dsh-client-ui-skill` — and the composer warms both at scope birth, under whatever preset the session was created with. The hero chip then lets the user recompose the still-blank session, and neither cache had an invalidation edge for that: `commands/changed` is registry-wide and `connection/reset` needs a reconnect. `agentPresets.recompose` re-parents the agent's scope onto a standing mount that may already exist, so it registers nothing and the registry-wide signal never fires for it.
+
+The menu therefore kept serving the composition the session no longer ran. Switching down left `compact`, `plan`, and every project skill listed; switching up left the narrower catalog — the four host-plane rows and the client's own `model` contribution — with no skills at all, which is what the bug report described. The catalog only healed when an unrelated registry change or a reconnect happened to invalidate it.
+
+## Decision
+
+The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog).
+
+The frame is per session and carries no catalog, only the preset id — which the manager folds into the session row, because the `agentPresets.select` echo reaches only the client that issued the switch and the row is what the session header labels itself from (and what the hero chip compares the next pick against).
+
+Deriving the frame from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come.
+
+## Alternatives considered
+
+**Invalidate in the client's own `agentPresets.select` callback.** Smallest change, and the preset is locked after the first turn, so the hero chip is the only place a switch can originate. Rejected because the invalidation would then live in the surface that happens to issue the RPC rather than at the commit point: a second tab on the same blank session keeps a stale menu, and any future host-side recomposition has no signal at all.
+
+**Derive the client event from the existing `session/event` mux frame.** The logged event already reaches every subscribed client, so no new wire type would be needed. Rejected on face separation: narrowing `event.type` to `agent-preset/selected` requires the `SessionEventMap` augmentation, and the only ways to load it in the Client program are a project reference to `dsh-agent-presets` — which drags the host `ctx.sessions` merge into a program that publishes its own — or a cast that defeats the discriminant.
+
+**Reuse `host/commands-changed`.** It is the existing catalog-invalidation frame, but it is registry-wide, carries no session, and says nothing about skills; a client would repull every session's commands and still never refresh a skill catalog.
+
+## Consequences
+
+The wire gains one frame and the Client one typed event, and every catalog a preset decides now has one place to subscribe: a future per-session surface derived from the composition invalidates on the same signal instead of inventing another. The cost is that the frame is a second reader of a logged fact — the host stream must keep deriving it from `agent-preset/selected`, so a future switch path that recomposes without logging would go unannounced. `ui-command` stays soft (the open menu never blanks) while `ui-skill` drops its entry outright, because a skill catalog has no partial-serve mode; a menu opened inside the refetch window shows no skills for that instant rather than the wrong ones.
+
+## Testing
+
+`api-proxy-agent-preset.spec.ts` asserts the committed switch frames once with the session and its new preset; `wire-events.spec.ts` asserts the frame-to-event bridge; the `ui-command` and `ui-skill` specs assert that the event repulls the recomposed session and leaves every other session's cache serving. The `agent-preset-selection` web e2e seeds a project skill and, after the hero chip applies `minimal`, asserts the `/` menu drops `compact`, `plan`, and the skill while keeping the host-plane rows — the assembled-application evidence that the panel follows the composition.
+
+That e2e also stopped reading its staged-pick assertion off the serialized session list: the seeded session records `minimal` too, so the substring answered before the switch had landed. It now addresses the live session by id.
+
+## Related
+
+Reaching the host on a SECOND switch is a separate defect with its own cause and fix: [the session-row identity guard](2026-08-10-session-row-identity-covers-the-preset.md). Until it landed, `agent-preset-selection.e2e.ts` could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen.

+ 43 - 0
.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md

@@ -0,0 +1,43 @@
+# Agent Note:斜杠目录跟随空会话的 preset 切换
+
+Status: implemented
+
+[English](2026-08-10-slash-catalog-follows-preset-switch.md) | 中文
+
+## Problem
+
+preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿主面的 `skill-local`、`tool-skill`、`plan-mode` 和 `command-compact`,改由 preset 提供,因此一个会话有哪些命令和技能,是它自身组成的属性,而不是部署的属性。
+
+浏览器侧两份目录都按会话缓存——`dsh-client-ui-command` 的 `CommandDirectory`,`dsh-client-ui-skill` 的 single-flight 拉取表——并且 composer 在 scope 出生时就按会话创建时的 preset 预热了它们。随后 hero 上的 chip 允许用户重组这个仍为空的会话,而两份缓存都没有对应的失效边:`commands/changed` 是注册表级的,`connection/reset` 需要重连。`agentPresets.recompose` 只是把 agent 的 scope 重新挂接到一个可能已经存在的常驻挂载上,不产生任何注册,注册表级信号因此永远不会为它触发。
+
+于是菜单继续提供会话已经不再运行的那套组成。向下切换后 `compact`、`plan` 和全部项目技能仍列在菜单里;向上切换后留在原地的是更窄的目录——四条宿主面行加客户端自己的 `model` 贡献——而且完全没有技能,这正是 bug 报告描述的现象。只有当某个无关的注册表变化或一次重连恰好使其失效时,目录才会自愈。
+
+## Decision
+
+这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。
+
+该帧按会话粒度,不携带目录,只带 preset id——manager 会把它折进会话行,因为 `agentPresets.select` 的回执只会到达发起切换的那个客户端,而会话头部标签正是以这一行为准(hero chip 比较下一次选择时读的也是它)。
+
+从落账事件而不是 RPC 处理器的返回值派生该帧,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。
+
+## Alternatives considered
+
+**在客户端自己的 `agentPresets.select` 回调里就地失效。** 改动最小,而且第一轮之后 preset 就锁定,hero 上的 chip 是切换唯一可能的发起处。否决理由是失效逻辑会落在恰好发起 RPC 的那个界面上,而不是提交点:同一个空会话在第二个标签页里仍是过期菜单,将来任何宿主侧的重组也完全没有信号。
+
+**从既有的 `session/event` mux 帧派生客户端事件。** 落账事件本来就会送达每个已订阅的客户端,不需要新增协议类型。因面(face)分离而否决:把 `event.type` 收窄到 `agent-preset/selected` 需要 `SessionEventMap` 增补,而在 Client 程序里加载它只有两条路——引用 `dsh-agent-presets` 工程,那会把宿主的 `ctx.sessions` 合并拖进一个自己也发布同名服务的程序;或者用一次类型断言绕过判别式。
+
+**复用 `host/commands-changed`。** 它是既有的目录失效帧,但它是注册表级的、不带会话、也与技能无关;客户端会把每个会话的命令都重拉一遍,却依然永远刷不新技能目录。
+
+## Consequences
+
+协议多了一个帧,Client 多了一个类型化事件,而每一份由 preset 决定的目录从此有了统一的订阅点:将来任何从组成派生的按会话界面,都在同一个信号上失效,而不必再发明一个。代价是该帧成为一项落账事实的第二个读者——宿主流必须持续从 `agent-preset/selected` 派生它,因此将来若出现一条不落账就重组的切换路径,它将无人宣告。`ui-command` 保持软失效(已打开的菜单不会变空),而 `ui-skill` 直接丢弃该项,因为技能目录没有「部分可服务」的状态;在重拉窗口内打开的菜单,那一瞬间显示的是没有技能,而不是错误的技能。
+
+## Testing
+
+`api-proxy-agent-preset.spec.ts` 断言已提交的切换恰好成帧一次,并带上会话与新 preset;`wire-events.spec.ts` 断言帧到事件的桥接;`ui-command` 与 `ui-skill` 的 spec 断言该事件只重拉被重组的会话,其他会话的缓存继续服务。`agent-preset-selection` web e2e 播种一个项目技能,并在 hero chip 应用 `minimal` 之后断言 `/` 菜单丢掉了 `compact`、`plan` 和该技能,同时保留宿主面的那几行——这是面板跟随组成的整装应用证据。
+
+同一条 e2e 也不再从序列化后的会话列表里读它的 staged-pick 断言:被播种的会话同样记录着 `minimal`,子串匹配在切换落地之前就会通过。现在它按 id 寻址那个活跃会话。
+
+## Related
+
+第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,`agent-preset-selection.e2e.ts` 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。

+ 94 - 13
apps/web/tests/agent-preset-selection.e2e.ts

@@ -11,6 +11,7 @@
 //
 // Zero model calls: no replay fixture mounts, so a stray stream fails loud.
 import { fileURLToPath } from 'node:url'
+import { mkdir, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
@@ -29,6 +30,30 @@ const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
 const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
 const MODE = webSnapshotMode()
 const SEED_ID = 'agent-preset-selection-web-e2e'
+/** A project skill only a preset that mounts `skill-local` can discover. */
+const SKILL_NAME = 'preset-catalog-demo'
+
+/**
+ * Seed one project skill under the connected workspace.
+ *
+ * Local skill discovery is a PRESET row, so this file is visible through
+ * `standard` and invisible through `minimal` — which makes the '/' menu's
+ * skill group a statement about the session's composition.
+ * @param workspaceCwd - the scaffold's temp project parent.
+ */
+async function seedWorkspaceSkill(workspaceCwd: string): Promise<void> {
+  const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', SKILL_NAME)
+  await mkdir(directory, { recursive: true })
+  await writeFile(join(directory, 'SKILL.md'), [
+    '---',
+    `name: ${SKILL_NAME}`,
+    'description: Prove the slash catalog follows the session composition',
+    '---',
+    '',
+    'Body.',
+    '',
+  ].join('\n'))
+}
 
 /**
  * A settled one-turn session with no model content: this lane asserts chrome
@@ -53,6 +78,35 @@ function seedLog(): string {
   ].join('\n')
 }
 
+/**
+ * The preset the host reports for the blank session the workspace connect
+ * produced. Addressed by id rather than by scanning the serialized list: the
+ * seeded session records `minimal` too, so a substring match over the whole
+ * list answers before the switch has landed.
+ * @param baseUrl - the scaffold's origin.
+ * @returns the live session's preset, or undefined before it is listed.
+ */
+async function livePreset(baseUrl: string): Promise<string | undefined> {
+  const response = await fetch(`${baseUrl}/api/session.list`, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({
+      type: 'client-request', rpcId: 'agent-preset-live', method: 'session.list', payload: {},
+    }),
+  })
+  const body = await response.json() as {
+    result: { value?: { items: { sessionId: string; agentPreset?: string }[] } }
+  }
+  return body.result.value?.items.find(item => item.sessionId !== SEED_ID)?.agentPreset
+}
+
+/** Every option label the trigger menu currently lists. */
+async function menuOptions(page: Page): Promise<string[]> {
+  const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+  await menu.waitFor({ timeout: 10_000 })
+  return await menu.getByRole('option').allTextContents()
+}
+
 describe('web e2e: agent-preset selection', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -67,6 +121,7 @@ describe('web e2e: agent-preset selection', () => {
     // records `minimal` is what makes the header label a claim about the
     // session rather than an echo of the current default.
     await seedSession(scaffold, seedLog(), SEED_ID, 'minimal')
+    await seedWorkspaceSkill(scaffold.workspaceCwd)
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
@@ -114,21 +169,47 @@ 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(async () => {
-      const response = await fetch(`${scaffold.baseUrl}/api/session.list`, {
-        method: 'POST',
-        headers: { 'content-type': 'application/json' },
-        body: JSON.stringify({
-          type: 'client-request', rpcId: 'agent-preset-stage', method: 'session.list', payload: {},
-        }),
-      })
-      const body = await response.json() as {
-        result: { value?: { sessions: { blank: boolean; agentPreset?: string }[] } }
-      }
-      return JSON.stringify(body.result.value?.sessions ?? body.result)
-    }, { timeout: 15_000 }).toContain('minimal')
+    await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('minimal')
   })
 
+  it('re-reads the slash catalog through the composition the switch installed', async () => {
+    // Continues the previous case: the chip has already applied `minimal` to
+    // the blank session, and this one reads the menu that switch left behind.
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-slash-catalog'))
+    const composer = page.locator('textarea:enabled').last()
+
+    // `minimal` mounts neither the compaction group nor plan mode nor local
+    // skill discovery, so the catalog the composer warmed under the
+    // deployment default must not survive the switch.
+    await composer.fill('/')
+    await expect.poll(() => menuOptions(page), { timeout: 15_000 })
+      .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
+    const onMinimal = await menuOptions(page)
+    expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false)
+    expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false)
+    // The host-plane commands and the client's own contribution are the
+    // floor: they belong to no preset and never move.
+    expect(onMinimal.some(option => option.startsWith('goal'))).toBe(true)
+    expect(onMinimal.some(option => option.startsWith('model'))).toBe(true)
+    await composer.fill('')
+
+    // Switching back up reaches the host at all — the chip compares the pick
+    // against its list row, so a row that never reprojected the first switch
+    // answers "already standard" and sends nothing — and restores the catalog
+    // instead of leaving the session reading the narrower composition.
+    await page.getByRole('button', { name: '极简模式' }).click()
+    await page.getByRole('menuitem', { name: /^标准模式/ }).first().click()
+    await expect.poll(() => livePreset(scaffold.baseUrl), { timeout: 15_000 }).toBe('standard')
+
+    await composer.fill('/')
+    await expect.poll(() => menuOptions(page), { timeout: 15_000 })
+      .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)]))
+    const onStandard = await menuOptions(page)
+    expect(onStandard.some(option => option.startsWith('compact'))).toBe(true)
+    expect(onStandard.some(option => option.startsWith('plan'))).toBe(true)
+    await composer.fill('')
+  }, 90_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

+ 2 - 2
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: 3b8a6b1dd155fd1350b164f1dd2d2bf0ec26a4a5
-event-producer-consumer.zh.md: 12de167fcd1217f00a8ae719ef3191a4873a2799
+event-producer-consumer.md: 70de749c328f1d901ff6f9bc0d97cd52a6f3bf63
+event-producer-consumer.zh.md: 6c49c33a1b1197a7da9bccfc161b7cfa6b6a548f

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

@@ -70,6 +70,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `internal/status` | - | [`agent`](../packages/core/agent) |
 | `locale/change` | `locale` (`emit`) | `locale` |
 | `models/changed` | `runtime` (`emit`) | `ui-models` |
+| `session/preset-changed` | `runtime` (`emit`) | `ui-command` |
 | `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` |
 | `slash/input-begin-command` | - | `ui-conversation` |
 | `slash/input-consume-token` | - | `ui-conversation` |

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

@@ -72,6 +72,7 @@
 | `internal/status` | - | [`agent`](../packages/core/agent) |
 | `locale/change` | `locale` (`emit`) | `locale` |
 | `models/changed` | `runtime` (`emit`) | `ui-models` |
+| `session/preset-changed` | `runtime` (`emit`) | `ui-command` |
 | `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` |
 | `slash/input-begin-command` | - | `ui-conversation` |
 | `slash/input-consume-token` | - | `ui-conversation` |

+ 2 - 2
packages/client/runtime/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/runtime/README.md
-README.md: 0a7d9975093da558af623ee9940f4be398526821
-README.zh.md: 41388e4ba564baa61cfdaaacb74f4f5ea053d41a
+README.md: 753d1de796ba8ff20217d423555710429e9b7a75
+README.zh.md: 9b5b8ba7ce42875afd4b9b83b9c2f64e95298ca5

ファイルの差分が大きいため隠しています
+ 0 - 1
packages/client/runtime/README.md


ファイルの差分が大きいため隠しています
+ 0 - 1
packages/client/runtime/README.zh.md


+ 15 - 0
packages/client/runtime/src/client/index.ts

@@ -181,6 +181,18 @@ declare module 'cordis' {
      * @mode emit
      */
     'models/changed'(): void
+    /**
+     * One session's agent preset changed (host/session-preset-changed
+     * passthrough), so everything its composition decides — the command
+     * catalog, the skill catalog — is stale for that session and no other.
+     * Every connected client observes it, not only the one that issued the
+     * switch. Subscribers refetch their own session-keyed caches; the frame
+     * carries no catalog.
+     * @mode emit
+     * @param sessionId - the session whose composition changed.
+     * @param agentPreset - the preset it now runs.
+     */
+    'session/preset-changed'(sessionId: SessionId, agentPreset: string): void
     /**
      * A connection generation was (re-)established. Wire-derived caches must
      * treat their state as stale and repull (commands directory; the queue
@@ -244,6 +256,9 @@ export function apply(ctx: Context): void {
       // and model surfaces) subscribe on ctx.
       const frame = envelope.payload
       if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
+      else if (frame.type === 'host/session-preset-changed') {
+        ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset)
+      }
       else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns)
       else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref)
       else if (frame.type === 'host/models-changed') ctx.emit('models/changed')

+ 9 - 1
packages/client/runtime/src/client/sessions/manager.ts

@@ -780,6 +780,14 @@ export class SessionManager {
         }
         return
       }
+      case 'host/session-preset-changed': {
+        // Every connected client observes the switch here; only the tab that
+        // issued it also gets the RPC echo. The merge keeps the row's own
+        // updatedAt and lowers `blank` only, so re-applying the switching
+        // tab's own frame is a no-op.
+        this.noteAgentPreset(frame.sessionId, frame.agentPreset)
+        return
+      }
       case 'host/session-removed': {
         const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
         const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
@@ -1005,7 +1013,7 @@ export class SessionManager {
       const prev = this.entryCache.get(entry.sessionId)
       if (
         prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
-        && prev.blank === entry.blank
+        && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset
         && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
         && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
         && prev.pendingInteraction === entry.pendingInteraction

+ 34 - 0
packages/client/runtime/tests/sessions-service.spec.ts

@@ -35,6 +35,7 @@ type FeedRow = {
   origin?: 'subagent'
   running?: boolean
   blank?: boolean
+  agentPreset?: string
 }
 
 async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
@@ -44,6 +45,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
       ...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
       ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
       ...(r.origin !== undefined ? { origin: r.origin } : {}),
+      ...(r.agentPreset !== undefined ? { agentPreset: r.agentPreset } : {}),
     })),
   }) as never)
   await b.svc.refresh()
@@ -70,6 +72,38 @@ describe('list store projection', () => {
     expect(state.byId[sid('s2')]?.title).toBeUndefined()
   })
 
+  it('reprojects a blank session whose composition switched and nothing else moved', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
+    expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('standard')
+
+    // A confirmed switch moves the preset alone: the row keeps its updatedAt,
+    // title, running, and blank bits, so an identity guard blind to the preset
+    // would serve the old row forever — and every reader (the hero chip's own
+    // no-op check, the header label) would keep the composition it replaced.
+    b.svc.noteAgentPreset(sid('s1'), 'minimal')
+    await Promise.resolve()
+
+    expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
+  })
+
+  it('learns a preset switch from the host frame, not only from the tab that issued it', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }])
+
+    // Every connected client gets this frame; only the switching tab gets the
+    // RPC echo. A client that ignored the payload would keep labelling the
+    // session with the composition it replaced.
+    b.svc.handleHostEnvelope({
+      rpcId: 'r1' as never,
+      payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never,
+    })
+    await Promise.resolve()
+
+    expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal')
+    expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true)
+  })
+
   it('reflects live increments (host stream via manager) into the store', async () => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])

+ 13 - 1
packages/client/runtime/tests/wire-events.spec.ts

@@ -1,6 +1,7 @@
 /**
  * Wire-to-typed-event bridge: host/commands-changed
- * → ctx 'commands/changed'; each established connection generation →
+ * → ctx 'commands/changed'; host/session-preset-changed →
+ * ctx 'session/preset-changed'; each established connection generation →
  * ctx 'connection/reset' (the forced cache-invalidation broadcast).
  */
 import { Context } from 'cordis'
@@ -67,6 +68,17 @@ describe('wire event bridge', () => {
     ])
   })
 
+  it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => {
+    const bench = await mount()
+    const seen: Array<[string, string]> = []
+    bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) })
+    bench.sinks?.onHostEnvelope?.({
+      rpcId: 'r1' as never,
+      payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' },
+    })
+    expect(seen).toEqual([['s1', 'minimal']])
+  })
+
   it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => {
     const bench = await mount()
     let resets = 0

+ 2 - 2
packages/client/ui-command/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-command/README.md
-README.md: bc7386c8fca3b5c623473328bee6322fa7295277
-README.zh.md: 54190ac9144b1bfc12ba84a47474311d5a5391ea
+README.md: db785e769cb40235a77d05b4b66d096896a35d8a
+README.zh.md: f0f23319a8919a0dee715e9da03ab064b6e3298a

+ 1 - 1
packages/client/ui-command/README.md

@@ -6,7 +6,7 @@ Client command surface (`ctx.command`): the session-keyed command-directory cach
 
 `src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
 
-`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
+`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
 
 Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md).
 

+ 1 - 1
packages/client/ui-command/README.zh.md

@@ -6,7 +6,7 @@
 
 `src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
 
-`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
+`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
 
 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。
 

+ 5 - 0
packages/client/ui-command/src/client/service.ts

@@ -124,6 +124,11 @@ export class CommandService extends Service implements CommandServiceContract {
       warm: (session) => { this.directory.warm(session.sessionId) },
     }), 'command: slash source')
     ctx.on('commands/changed', () => { this.directory.invalidateAll() })
+    // A preset switch changes which commands one session's agent resolves and
+    // registers nothing globally, so the registry-wide signal above never
+    // fires for it: repull that key alone, soft, so the old snapshot serves
+    // the menu until the new one lands.
+    ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) })
     ctx.on('connection/reset', () => { this.directory.resetConnected() })
   }
 

+ 24 - 0
packages/client/ui-command/tests/service.spec.ts

@@ -617,6 +617,30 @@ describe('directory invalidation events', () => {
     expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
   })
 
+  it('session/preset-changed repulls the recomposed session and leaves the others served', async () => {
+    const rounds = new Map<SessionId, number>()
+    const { ctx, source, warm } = await bench({
+      commands: (payload) => {
+        const round = (rounds.get(payload.sessionId) ?? 0) + 1
+        rounds.set(payload.sessionId, round)
+        return Promise.resolve({
+          commands: round === 1
+            ? S1_CMDS
+            : [{ name: 'fresh', description: '', input: { hint: 'h' } }],
+        })
+      },
+    })
+    await warm(proj('s1'))
+    await warm(proj('s2'))
+    // A preset switch changes which commands one session's agent resolves;
+    // every other session keeps the catalog its own composition serves.
+    ctx.emit('session/preset-changed', sid('s1'), 'minimal')
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined()
+    expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined()
+    expect(source.matchSpace!(proj('s2'), '/goal')).not.toBeUndefined()
+  })
+
   it('connection/reset hard-drops every session key until its rewarm lands', async () => {
     let block = false
     let release!: (value: { commands: CommandDescriptor[] }) => void

+ 2 - 2
packages/client/ui-skill/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-skill/README.md
-README.md: d8e88cb7b0215b06cd55a4ee9a7932ef180f572f
-README.zh.md: 073a41cac95aeb96b658b075011d8212684f1c65
+README.md: 36b4cf4181d74ca1ea05fd8ed2db5e42fa36c7f2
+README.zh.md: 336f43117e7bc4de41a31e636ee0966e5d1a2cd6

+ 1 - 1
packages/client/ui-skill/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`.
+Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, `session/preset-changed` drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`.
 
 A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `<skill_content>` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan.
 

+ 1 - 1
packages/client/ui-skill/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
+skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`session/preset-changed` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。
 
 pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每个入口注入渲染后的 `<skill_content>`,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。
 

+ 6 - 1
packages/client/ui-skill/src/client/index.ts

@@ -17,7 +17,9 @@
  * Catalog fetches are cached per session (the small twin of the ui-command
  * directory): the per-keystroke candidates re-poll filters a settled
  * snapshot locally, so one session costs one RPC. The scope-birth warm hook
- * prewarms the session's key; connection/reset clears everything — the host
+ * prewarms the session's key; a preset switch drops that one key (the
+ * catalog is the preset's, and a blank session may switch after the warm);
+ * connection/reset clears everything — the host
  * catalog may differ across generations. A shared in-flight fetch
  * deliberately outlives any single menu interaction: closing the menu must
  * not kill the prewarm other consumers will hit, so it carries its own
@@ -174,6 +176,9 @@ export function apply(ctx: ClientContext): void {
     },
   }
   const slash = ctx.get('slash') as SlashServiceContract
+  // A preset decides which skill providers an agent reads, so a switched
+  // session's cached catalog belongs to the composition it no longer runs.
+  ctx.on('session/preset-changed', invalidate)
   ctx.on('connection/reset', clearAll)
   ctx.effect(() => {
     const unregister = slash.registerSource(source)

+ 15 - 0
packages/client/ui-skill/tests/browser-plugin.spec.ts

@@ -263,6 +263,21 @@ describe('catalog cache', () => {
     expect(payloads).toHaveLength(2)
   })
 
+  it('session/preset-changed clears only the recomposed session', async () => {
+    const { list, payloads } = countingList()
+    const { ctx, source } = await bench(list)
+    await source.candidates(proj('s1'), req(''))
+    await source.candidates(proj('s2'), req(''))
+    expect(payloads).toHaveLength(2)
+    // The catalog a preset supplies is the preset's; the other session's
+    // composition did not change, so its cached catalog still holds.
+    ctx.emit('session/preset-changed', sid('s1'), 'minimal')
+    await source.candidates(proj('s1'), req(''))
+    await source.candidates(proj('s2'), req(''))
+    expect(payloads).toHaveLength(3)
+    expect(payloads[2]).toEqual({ sessionId: 's1' })
+  })
+
   it('connection/reset clears every cached session', async () => {
     const { list, payloads } = countingList()
     const { ctx, source } = await bench(list)

+ 2 - 2
packages/host/apiproxy/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/host/apiproxy/README.md
-README.md: c29f30b85c5579f278ac9b40a0422347502eeb8f
-README.zh.md: 92b866bafd71902c55bf0bad14c6b9e761421cf8
+README.md: 0e98520149297732da8a4d06608f9b04204a444f
+README.zh.md: 27dc0ad668bd2da3d340fd081d698a7d090a94d4

ファイルの差分が大きいため隠しています
+ 1 - 1
packages/host/apiproxy/README.md


ファイルの差分が大きいため隠しています
+ 1 - 1
packages/host/apiproxy/README.zh.md


+ 11 - 0
packages/host/apiproxy/src/api-proxy.ts

@@ -3164,6 +3164,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           ctx.on('commands/change', () => {
             queue.push(frame({ type: 'host/commands-changed' }))
           }),
+          // The recompose itself registers nothing (it re-parents the agent's
+          // scope onto a standing mount that may already exist), so the
+          // logged selection is the only commit point a client can follow.
+          ctx.on('session/event', (session: Session, event: SessionEvent) => {
+            if (event.type !== 'agent-preset/selected') return
+            queue.push(frame({
+              type: 'host/session-preset-changed',
+              sessionId: session.id,
+              agentPreset: event.data.agentPreset,
+            }))
+          }),
           ctx.on('settings/document-updated', (ns) => {
             // The RAW-section event, not the resolved one: a field going from
             // inherited to overridden leaves the resolved value equal, and a

+ 1 - 0
packages/host/apiproxy/src/api/events.schema.ts

@@ -82,6 +82,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
   z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
   z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }),
   z.object({ type: z.literal('host/commands-changed') }),
+  z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }),
   z.object({ type: z.literal('host/settings-changed'), ns: z.string() }),
   z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }),
   z.object({ type: z.literal('host/models-changed') }),

+ 12 - 0
packages/host/apiproxy/src/api/events.ts

@@ -130,6 +130,18 @@ export type HostFrame =
    * background rather than diffing.
    */
   | { type: 'host/commands-changed' }
+  /**
+   * One blank session was recomposed onto another agent preset (the logged
+   * `agent-preset/selected` commit point, read off the session stream). The
+   * registry-wide `host/commands-changed` cannot stand in for it: recomposing
+   * re-parents that agent's scope without registering anything, so a
+   * preset already mounted for another session produces no registry change
+   * at all. Clients refetch the catalogs this session's composition decides
+   * (`command.list`, `skill.list`) for this sessionId alone, and fold the
+   * preset id into their session row — the RPC echo reaches only the client
+   * that issued the switch, so the row is where every other one learns it.
+   */
+  | { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string }
   /**
    * One settings namespace's resolved value changed (`settings/updated`
    * passthrough) — an RPC write, an external `settings.yaml` edit, or a

+ 32 - 0
packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts

@@ -14,6 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
 import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
 import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
 import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
+import type { HostFrame } from '../src/api/events.ts'
 import {
   InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
 } from '@deepseek-ai/dsh-agent-presets'
@@ -350,6 +351,37 @@ describe('agentPreset.select', () => {
       .toBe('core-web')
   })
 
+  it('frames the committed switch so clients can drop that session\'s catalogs', async () => {
+    const { api, ctx } = await harness(['standard', 'minimal'])
+    await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' }))
+    // The host-stream opener reads the committed-workspace baseline; this
+    // spec owns preset identity, so the stub suffices (api-proxy-commands
+    // precedent).
+    ctx.provide('workspace', { list: () => [] } as never)
+    const abort = new AbortController()
+    const frames: HostFrame[] = []
+    const stream = api.events.host(request({}), abort.signal)
+    const consume = (async () => {
+      for await (const frame of stream) {
+        if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload)
+      }
+    })()
+
+    await api.agentPresets.select(
+      request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' }))
+    // The queue push rides the synchronous append, so one turn of the loop is
+    // enough to deliver it; closing the stream bounds the read either way.
+    await new Promise(resolve => setTimeout(resolve, 0))
+    abort.abort()
+    await consume
+
+    // Recomposing registers nothing, so this frame — not the registry-wide
+    // commands one — is what tells a client its cached catalogs are stale.
+    expect(frames).toEqual([
+      { type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' },
+    ])
+  })
+
   it('serializes two concurrent selects on one session', async () => {
     const { api, ctx } = await harness(['standard', 'core-web'])
     await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))

+ 1 - 0
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -493,6 +493,7 @@ describe('events frame schemas', () => {
       } },
       { type: 'host/workspace-removed', workspaceId: 'w' },
       { type: 'host/commands-changed' },
+      { type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' },
       { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
     ]
     for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type })

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -180,6 +180,7 @@ export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
   'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
   'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface',
   'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
+  'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the surface',
   'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
   'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
   'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',

+ 12 - 1
scripts/gen-doc-graphs.ts

@@ -734,7 +734,18 @@ type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExp
  */
 const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch'])
 
-/** Collect event dispatch/listener relations from real cross-file receiver types. */
+/**
+ * Collect event dispatch/listener relations from real cross-file receiver types.
+ *
+ * TODO: the program is seeded from the host aggregate alone (ts-project.ts
+ * documents why: one program cannot hold both faces' Context merges), so a
+ * Client package enters only when a host file imports it. Client-face
+ * listeners on client-face events are therefore under-reported —
+ * `connection/reset` omits `ui-skill`/`ui-agent-preset`, `models/changed`
+ * omits `ui-model`, `session/preset-changed` omits `ui-skill`. Closing it
+ * needs a second Client program whose relations merge into these, not a
+ * wider seed.
+ */
 export class EventRelationCollector {
   private readonly relations = new Map<string, EventRelation>()
   private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>()

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