news 2026/9/20 11:26:26

DeepSeek Harness 会话行身份守卫补全:agentPreset 投影如何避免预设切换被错误缓存吞掉

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DeepSeek Harness 会话行身份守卫补全:agentPreset 投影如何避免预设切换被错误缓存吞掉
  • 人工智能
  • AI Agent
  • Agent 框架
  • DeepSeek

【免费下载链接】deepseek-harness

DeepSeek Harness: Everything is a Plugin.

项目地址:https://gitcode.com/gh_mirrors/de/deepseek-harness
点击查看免费下载

导读

本文以 DeepSeek Harness 会话列表子系统的一次真实缺陷修复为主线,讲解客户端SessionManager的"行身份缓存"(entry-identity memoization)机制:wire 刷新每次都会铸出新对象,列表构建器按字段逐项比较以复用旧行对象,保证 React 侧SessionListItem的 memo 命中。这个逐字段比较的守卫曾漏掉agentPreset投影键,导致"切换预设后再切回"时 UI 自认为"已在该预设上"而不发 RPC,会话被永久锁死在错误的组合上。读完本文,你将理解行身份缓存的契约、agentPreset投影如何经ProjectionValueStore进入列表行、以及单元测试与 web e2e 如何钉死这类"字段遗漏"缺陷。

会话列表的行身份缓存:为什么 wire 刷新必须按值恢复身份

问题根源:wire 对象每次都是新的

在 DeepSeek Harness 的客户端,每个浏览器客户端持有唯一的SessionManager实例(packages/api/session-controller/src/client/sessions/manager.ts),它负责维护会话实例簇、列表状态,并通过getListSnapshot()向 React 侧提供不可变快照。列表数据不进入 zustand,React 通过subscribe/getListSnapshot读取(见 manager.ts 的模块头注释)。

每次 wire 刷新(refreshList()调用remote.session.list)都会返回一批全新的SessionSummary对象。如果每次刷新都把这些新对象直接放进列表快照,那么每个SessionListItem的 memo 比较都会因为引用变化而全部 miss,整张会话列表每次刷新都会重渲染——这正是 memoization 要避免的成本。

契约:按值比较,复用旧对象

manager.ts 定义了行身份缓存:

private listSnapshotCache: SessionListSnapshot /** Entry-identity cache (reference stability): list rebuilds reuse the previous entry * object when every field matches — wire refreshes mint all-new summary objects, so identity * must be recovered by value or every SessionListItem memo misses on every refresh. */ private entryCache = new Map<SessionId, SessionListEntry>()

buildListSnapshot()(manager.ts)把每条 summary 与entryCache中上一次的条目逐字段比较,全部相等才复用旧对象:

const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running && prev.blank === entry.blank && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.projectionValues === entry.projectionValues && prev.completed === entry.completed ) return prev

注意两点关键设计:

  1. projectionValues用引用比较而非值比较。这一条恰恰是后文"替代方案"里拒绝"通用深比较"的原因,见下文分析;
  2. 该枚举中没有agentPresetagentPreset并非SessionSummary的顶层字段,而是作为投影键agentPreset存在,见 types.ts 中SessionSummary的定义。

列表行的数据结构SessionListEntry定义在 lineage.ts:sessionId / title / updatedAt / running / blank / parentSessionId / origin / cwd / projectionValues / completed / depth

投影如何进入列表行:ProjectionValueStore 与 agentPreset 键

agentPreset是一个通用投影键(generic projection key),与titlemodelSelection等一样,走ProjectionValueStore通道。

projection-store.ts 实现了每个会话一份的ProjectionValueStore

  • 内部为key → { value, seq }的行结构;
  • apply(key, value, seq)遵循higher seq wins规则:seq <= row.seq的旧帧直接丢弃(projection-store.ts);
  • values()返回引用稳定的冻结值表,直到某行变化才重建(projection-store.ts)——这正是行身份守卫里projectionValues === entry.projectionValues引用比较成立的前提:值没变时引用不变,值变了引用必变

投影值的写入路径有两条(均由 Host 计算、客户端只持有成品值):

  1. 列表拉取时按行种入:refreshList()result.value.itemsprojections块逐键store.apply(key, value, block.asOfSeq)(manager.ts);
  2. 控制流推帧:handleControlFrame收到type: 'projection'的帧时projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq)(manager.ts)。

buildListSnapshot()再把投影值折叠进列表行(manager.ts):

const projectionStore = this.projectionStores.get(summary.sessionId) const title = projectionStore?.get('title') const projectionValues = projectionStore?.values() return { ...summary, ...(typeof title === 'string' && title !== '' ? { title } : {}), ...(projectionValues === undefined ? {} : { projectionValues }), }

agentPreset投影从standard变到minimal时,values()返回新冻结表,引用必然变化,因此行身份守卫中prev.projectionValues === entry.projectionValues这一项本身就能检测到切换——前提是agentPreset键确实进入了投影表。真正出问题的地方在下一节。

缺陷复现:一次预设切换后永远切不回去

切换如何发生

预设(preset)决定会话的插件组合:web 组合禁用 host 平面的skill-filesystemtool-skillplan-modecommand-compact等行,由 preset 提供,因此"会话跑什么命令和技能"是会话组合的属性,而非部署的属性。空白会话的 hero chip 允许用户在会话开始前重组合(recompose)。agentPreset投影在 Host 端由sessionProjections计算(见 session-controller/src/agent.tsstateOf(session, 'agentPreset')的读取),并通过投影机制广播。

applyMutation 的合并语义:updatedAt 不跟进

切换选中后,客户端的recordMutation通过applyMutation把 upsert 合并进 summaries(manager.ts)。upsert分支刻意设计为"只填空、不改写":

const filled: SessionSummary = { ...existing, // Blank only lowers ... blank: existing.blank && mutation.summary.blank, ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}), ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined ? { parentSessionId: mutation.summary.parentSessionId } : {}), ...(existing.origin === undefined && mutation.summary.origin !== undefined ? { origin: mutation.summary.origin } : {}), }

upsert 不会采用 mutation 的updatedAt。因此一次预设切换落地后,会话行相对于缓存行只在projectionValues(内含agentPreset)上不同,其余字段(含updatedAt)全部一致。

旧守卫把切换判成了"无变化"

缺陷时的守卫逐字段枚举里没有agentPreset,而projectionValues的引用比较在切换发生时是能感知变化的……那么为什么会被吞掉?关键在于:守卫读的是列表行,而列表行当时的projectionValues引用必须确实变了才会被判为"变了"。修复前的实际问题链条是:

  • noteAgentPreset(或等效的宿主确认回显)upsert 进 summaries,合并刻意不带updatedAt
  • 若该 upsert 之外,没有任何东西让ProjectionValueStoreagentPreset键发生apply,那么重建列表行时projectionValues的引用与缓存行完全相同
  • 逐字段比较全部命中 → 返回旧行对象 → 列表快照"看起来什么都没变" → 下游 memo 命中,所有读者继续读旧值。

此时管理器自己的 summaries 已经写着minimal,但任何读取投影快照的读者(session 头标签、hero chip)看到的仍是standard——管理器内部状态与投影出去的状态分裂

影响:第二次切换永远到不了 Host

agentPreset投影的读者之一是 hero chip(选择控件)和 session 头标签。e2e 注释给出了最直观的后果描述(apps/web/tests/agent-preset-selection.e2e.ts):

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.

chip 把候选值与它读取到的行身份做比较:由于行从未重新投影出第一次切换,chip 看到"当前就是 standard",于是第二次切换(切回创建时预设)不发任何 RPC。用户第一次从standard切到minimal成功,却再也切不回standard——会话组合被永久锁死。

修复决策:让守卫覆盖 agentPreset

修复本身

修复极其收敛:把agentPreset纳入行身份比较,与其它 summary 字段一视同仁。在 manager.ts 的buildListSnapshot行身份守卫中,projectionValues已经以引用比较覆盖了agentPreset键的变化——只要agentPreset键确实进入了投影表,值切换必然导致values()返回新引用,从而打破"全部字段匹配"的复用条件,返回新行对象,下游读者随即看到minimal

需要说明:在修复提交中,agentPreset进入列表行投影表本身就是既成事实(session-listprojections块携带agentPreset值,如单元测试 sessions-service.client.spec.ts 所示:feed 行带projections: { agentPreset: 'standard' }后,projectionValues.agentPreset即变为'standard')。修复的落点是确认这一投影值全程参与行身份判定——这正是原 note 标题 "The session-row identity guard covers the preset" 的含义:守卫的契约("every field matches")本就该包含 preset 字段,旧实现只是漏了枚举。

修复后,其余机制全部保持原样:

  • memoization 不变:每次拉取仍复用旧行对象,直到确有字段不同;
  • upsert 合并语义不变:仍不采纳 mutation 的updatedAt
  • chip 的无操作判断不变:chip 依旧比较候选与行身份——只是行身份现在真的反映了预设切换。

为什么不能做"通用深比较"

note 的 Alternatives 明确否定了把逐字段枚举替换为通用结构比较的方案,理由是列表行携带projectionValues,其引用身份是"投影仓库重新发布了"这一信号的刻意载体。若将projectionValues折叠进值比较,则:

  • 投影仓库每次发布新值表,即便语义值相同(如重放帧),也会触发全列表重渲染;
  • 或者反过来,为了抑制重渲染而做值级相等判断,又会把"真实的发布"与"无意义的重放"混为一谈,掩盖真实变化。

也就是说,projectionValues必须保持引用比较,而引用比较对agentPreset键的变化天然敏感——这正是修复不需要动projectionValues比较方式的原因。

替代方案辨析

note 记录了两个被否决的替代方案,它们共同界定了"为什么修复必须落在行身份守卫上":

  1. 让 chip 直接重读 host 而不是读列表行("Have the chip re-read the host instead of the list row")。否决理由:agentPreset投影同时也是session 头标签标注自身的来源(见 AgentPresetLabel.tsx 读取state.byId[sessionId]?.projectionValues?.agentPreset),只修 chip 会让最显眼的表面上继续存在陈旧值;而且任何未来的SessionSummary.agentPreset读者都会继承同一个陷阱。修一行、不修来源,等于给每个读者埋雷。

  2. 取消行身份 memoization,每次快照全量重建("Drop the entry-identity memoization and rebuild rows every snapshot")。否决理由:这会直接抹掉该缓存存在的意义——wire 刷新每次铸新对象,取消缓存意味着每次刷新都重渲染整张会话列表,性能代价不可接受。

两个替代方案与最终决策形成鲜明对比:修复选择"让守卫的枚举与契约一致",既保留了引用稳定性的性能收益,又让投影读者看到的事实与宿主确认的事实保持一致。

后果与维护约束

修复的代价是一个显式的维护纪律:行身份守卫仍是手写枚举,未来给SessionSummary(或列表行)新增字段时,必须同步评估是否要加入身份比较。note 的 Consequences 明确指出:"a field added toSessionSummarylater must be added here too"——这正是本次缺陷的根因类别,因此配套的测试目标不是只钉住agentPreset这一个字段,而是为下一类字段遗漏提供失败信号。

另外两个值得注意的语义边界:

  • agentPreset在会话第一个 turn 之后被锁定(host 对后续切换回答agent-preset-locked,见 apps/web/tests/agent-preset-selection.e2e.ts),因此可切换窗口只有空白会话阶段,hero chip 是唯一能发起切换的表面——这解释了为什么 e2e 聚焦 chip 而非其它入口;
  • 头标签是静态展示而非控件:e2e 断言头标签区not.toContain('button "Minimal mode"')(agent-preset-selection.e2e.ts),"能改"的承诺只属于新建会话屏幕。

测试验证:单元测试 + 组装应用 e2e

单元层:投影重投影

sessions-service.client.spec.ts 的reprojects a blank session from the generic agent-preset projection直接对应本次修复的回归场景:

it('reprojects a blank session from the generic agent-preset projection', async () => { const b = bench() await feedList(b, [{ id: 's1', blank: true, projections: { agentPreset: 'standard' } }]) expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('standard') b.svc.handleControlFrame({ type: 'projection', sessionId: sid('s1'), key: 'agentPreset', value: 'minimal', seq: 1, }) await Promise.resolve() expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('minimal') })

该测试喂入一个空白行、推入一次切换帧、断言投影快照报告新值。note 的 Testing 一节点明了它的针对性:"it fails on the old guard because the row differs in nothing else"——旧守卫下,该行除投影外与缓存行无一不同,因此被判为"无变化",断言失败;新守卫下投影引用变化即打破复用,断言通过。

组装层:web e2e 的往返切换

apps/web/tests/agent-preset-selection.e2e.ts 的re-reads the slash catalog through the composition the switch installed覆盖了"切下去再切回来"的完整往返:

  • 首次从Standard mode切到Minimal mode后,断言/菜单不再包含compactplan以及项目技能preset-catalog-demo(技能经standard挂载的skill-filesystem行可见,minimal下不可见),而客户端自带的model命令保留——证明目录跟随组合变化;
  • 关键的第二段:切回Standard mode,断言livePreset轮询(通过 hostFetch/api/session/list按 id 读取活动会话的投影值)最终为'standard'。修复前,第二次切换因 chip 误判"已在 standard"而根本不会发出 RPC,轮询永不满足;
  • 随后/菜单重新出现compactplan与技能条目,证明目录随组合恢复。

e2e 的livePreset辅助函数有一个针对性细节:它按 id 寻址活动会话而非扫描序列化列表(agent-preset-selection.e2e.ts),因为种子会话本身记录minimal,整表子串匹配会在切换落地前就给出错误答案——与本次修复同源的"读错事实来源"教训,也体现在测试自身的写法上。

此外 e2e 全程零模型调用(无 replay fixture 挂载,见文件头注释 agent-preset-selection.e2e.ts),并以watchConsole断言页面无报错、无流警告收尾。

与 slash 目录失效修复的关系

本次修复与另一份缺陷记录.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md互为上下游:

  • 目录失效修复解决"菜单内容":CommandDirectoryui-commands)与技能单飞缓存(ui-skill)在预设切换后仍服务旧组合,修复在提交点(agent-preset/selected事件)让两个目录订阅失效/软刷新;
  • 行身份守卫修复解决"切换本身是否到达 Host":目录失效是方向无关的,但它响应的事件必须先发生——而旧守卫恰恰会让第二次切换的事件永不发生。note 原文点明:在本次守卫修复落地前,agent-preset-selection.e2e.ts只能演练第一次切换。

两条修复合起来,才构成"切换能到达、菜单能跟随"的完整闭环。

延伸阅读

  • 行身份守卫与列表快照构建:manager.ts
  • 列表行结构与谱系展平:lineage.ts
  • 投影值存储与 higher-seq-wins 规则:projection-store.ts
  • 会话摘要与投影键类型:types.ts
  • 头标签的投影读取:AgentPresetLabel.tsx
  • 单元回归:sessions-service.client.spec.ts
  • 组装应用 e2e:agent-preset-selection.e2e.ts
  • 配套修复:slash 目录跟随预设切换(.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md
  • 人工智能
  • AI Agent
  • Agent 框架
  • DeepSeek

【免费下载链接】deepseek-harness

DeepSeek Harness: Everything is a Plugin.

项目地址:https://gitcode.com/gh_mirrors/de/deepseek-harness
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 11:25:31

SSM架构员工管理系统全解析:从环境部署到功能拆解

简介&#xff1a;一套基于SSM的员工管理系统完整项目&#xff0c;面向JavaWeb学习者和毕业设计人群&#xff0c;用于解决传统员工管理效率低、数据分散的问题。系统整合SSM框架&#xff0c;前端采用JSP与JS技术&#xff0c;数据库使用MySQL&#xff0c;按照超级管理员、普通管理…

作者头像 李华
网站建设 2026/9/20 11:22:16

用BrewUI把Homebrew打包成可视化Web应用,包管理更直观

如果你跟我一样&#xff0c;平时习惯在终端里靠 Homebrew 管理软件&#xff0c;一定遇到过这样的场景&#xff1a;同事问某个软件是怎么装的&#xff0c;你啪啪啪敲一串brew list、brew search、brew info&#xff0c;对方看完还是一脸茫然。Homebrew 的命令确实不难&#xff0…

作者头像 李华
网站建设 2026/9/20 11:18:55

BetterNCM Installer 安装与插件失效排查全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 11:15:43

Visual Studio 2022 的 Copilot Chat,模型通道改到 TaoToken 通道行不行?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 11:15:34

BrewUI:macOS原生Homebrew图形化工作流平台

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 11:10:49

OrcaTerm性能优化实战:从DOM泥潭到Canvas分层渲染

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华