Browse Source

fix(web): fold the preset frame into the session row for every client

The frame carried `agentPreset` for surfaces that label the session, but
nothing consumed it: `noteAgentPreset` ran only in the switching tab's RPC
callback, so a second connected client refetched its catalogs while its
session row — the header label's source, and the hero chip's no-op input —
kept the composition the session had replaced.

`SessionManager.handleHostEnvelope` now folds the frame like the other
session frames. Re-applying the switching tab's own frame is a no-op: the
merge lowers `blank` only and keeps the row's `updatedAt`.
Yichen Jiang 1 month ago
parent
commit
0be9bf312a

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.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/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md
-2026-08-10-slash-catalog-follows-preset-switch.md: 4f32347e04e9b1cde024a59a32fcfd3cca64172a
-2026-08-10-slash-catalog-follows-preset-switch.zh.md: fb30df74a93a9eb913dc43b43c3065c6255bcab9
+2026-08-10-slash-catalog-follows-preset-switch.md: 85bd5b2134fd20c86fdeb13f3ce5b007449105b5
+2026-08-10-slash-catalog-follows-preset-switch.zh.md: 97c8f08a7b3dfec7c17fbb00bef626e28505c500

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

@@ -16,7 +16,9 @@ The menu therefore kept serving the composition the session no longer ran. Switc
 
 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. Deriving it 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.
+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
 
@@ -38,4 +40,4 @@ That e2e also stopped reading its staged-pick assertion off the serialized sessi
 
 ## 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, the e2e below could only exercise the first switch — the invalidation edge here is direction-blind, but the switch it reacts to has to happen.
+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.

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

@@ -16,7 +16,9 @@ preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿
 
 这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。
 
-该帧按会话粒度,且不携带目录。从落账事件而不是 RPC 处理器的返回值派生它,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。
+该帧按会话粒度,不携带目录,只带 preset id——manager 会把它折进会话行,因为 `agentPresets.select` 的回执只会到达发起切换的那个客户端,而会话头部标签正是以这一行为准(hero chip 比较下一次选择时读的也是它)。
+
+从落账事件而不是 RPC 处理器的返回值派生该帧,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。
 
 ## Alternatives considered
 
@@ -38,4 +40,4 @@ preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿
 
 ## Related
 
-第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,下面那条 e2e 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。
+第二次切换能否到达宿主是另一个缺陷,有各自的成因与修复:[会话行的标识判定](2026-08-10-session-row-identity-covers-the-preset.md)。在它落地之前,`agent-preset-selection.e2e.ts` 只能演练第一次切换——这里的失效边对方向无感,但它所响应的那次切换必须真的发生。

+ 5 - 3
apps/web/tests/agent-preset-selection.e2e.ts

@@ -173,12 +173,14 @@ describe('web e2e: agent-preset selection', () => {
   })
 
   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` (applied above) 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.
+    // `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)]))

+ 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: b1c0c8e5b6aa93f5e79b4b75c5f8db89bd656688
-README.zh.md: b6add06324bf9fc5cf4a93d89d88072609a67c50
+README.md: 753d1de796ba8ff20217d423555710429e9b7a75
+README.zh.md: 9b5b8ba7ce42875afd4b9b83b9c2f64e95298ca5

File diff suppressed because it is too large
+ 0 - 1
packages/client/runtime/README.md


File diff suppressed because it is too large
+ 0 - 1
packages/client/runtime/README.zh.md


+ 8 - 0
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)

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

@@ -87,6 +87,23 @@ describe('list store projection', () => {
     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' }])

+ 1 - 1
packages/client/ui-command/src/client/service.ts

@@ -124,7 +124,7 @@ 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
+    // 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.

+ 3 - 2
packages/host/apiproxy/src/api/events.ts

@@ -137,8 +137,9 @@ export type HostFrame =
    * 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; the preset id
-   * rides along for surfaces that label the session.
+   * (`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 }
   /**

Some files were not shown because too many files changed in this diff