The session-row identity guard covers the preset
会话行的标识判定纳入 preset
`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 swi
English
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, which is what makes the menu follow either switch once the switch itself lands.
中文
问题
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 切走一次,然后再也切不回来。
决策
标识判定把 agentPreset 与其余 summary 字段一起比较,这本就是「每个字段都相同」所声称的内容。其他一概不动:记忆化、合并、chip 的 no-op 检查各自都是对的——只要它们读到的那一行是对的。
考虑过的替代方案
让 chip 改为直接读宿主,而不是读列表行。 这样能绕开过期的行,但会话头部的标签同样以这一行为准,过期状态会在最显眼的界面里留下来;而且将来任何 SessionSummary.agentPreset 的读取方都会继承同一个陷阱。
去掉行标识记忆化,每次快照都重建行。 这能整类消除「漏字段」缺陷,代价却正是这个 memo 存在的理由:一次 wire 刷新会为每一行铸造新对象,于是每次刷新都要重渲染整个会话列表。
改成结构化比较,而不是逐字段枚举。 通用的深比较不能盲目加:行上带有 projectionValues,它的引用标识本身就是「投影 store 重新发布了」这一有意为之的信号,把它折进值比较,要么每个投影 tick 都重渲染,要么把一次真实变化掩盖掉。
后果
会话行携带的每个字段现在都参与行标识,因此读取 SessionSummary.agentPreset 的界面会在宿主确认后立刻看到切换,会话头部标签也包含在内。该判定仍是手写枚举,所以将来给 SessionSummary 新增字段时必须同步加进来;sessions-service 的投影测试为下一个这样的字段点明了失效形态,而不只是钉住这一次。
测试
sessions-service.spec.ts 传入一个空白行、记录一次切换,并断言投影快照报告的是新 preset——在旧判定下它会失败,因为这一行别处都没变。agent-preset-selection web e2e 先向下切再向上切,断言宿主认可第二次切换、/ 目录随之回来;没有这次修复,第二次切换根本到不了宿主。
相关内容
同一条 e2e 也覆盖目录失效的修复——正是它让菜单在切换真正落地之后跟随任一方向的切换。