- 人工智能
- AI Agent
- Agent 框架
- DeepSeek
【免费下载链接】deepseek-harness
DeepSeek Harness: Everything is a Plugin.
导读
本文以 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注意两点关键设计:
projectionValues用引用比较而非值比较。这一条恰恰是后文"替代方案"里拒绝"通用深比较"的原因,见下文分析;- 该枚举中没有
agentPreset。agentPreset并非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),与title、modelSelection等一样,走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 计算、客户端只持有成品值):
- 列表拉取时按行种入:
refreshList()对result.value.items的projections块逐键store.apply(key, value, block.asOfSeq)(manager.ts); - 控制流推帧:
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-filesystem、tool-skill、plan-mode、command-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 之外,没有任何东西让
ProjectionValueStore里agentPreset键发生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-list的projections块携带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 记录了两个被否决的替代方案,它们共同界定了"为什么修复必须落在行身份守卫上":
让 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读者都会继承同一个陷阱。修一行、不修来源,等于给每个读者埋雷。取消行身份 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后,断言/菜单不再包含compact、plan以及项目技能preset-catalog-demo(技能经standard挂载的skill-filesystem行可见,minimal下不可见),而客户端自带的model命令保留——证明目录跟随组合变化; - 关键的第二段:切回
Standard mode,断言livePreset轮询(通过 hostFetch/api/session/list按 id 读取活动会话的投影值)最终为'standard'。修复前,第二次切换因 chip 误判"已在 standard"而根本不会发出 RPC,轮询永不满足; - 随后
/菜单重新出现compact、plan与技能条目,证明目录随组合恢复。
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互为上下游:
- 目录失效修复解决"菜单内容":
CommandDirectory(ui-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.
相关推荐
DeepSeek Harness JSONL 存储身份绑定:在修复与追加之前锁定会话身份的设计与实现
DeepSeek Harness JSONL 存储身份绑定:在修复与追加之前锁定会话身份的设计与实现 JSONL 会话存储是 DeepSeek Harness
人工智能AI AgentAgent 框架DeepSeekDeepSeek Harness 会话 Surface:事件日志上的有序投影与历史操纵基础设施
DeepSeek Harness 会话 Surface:事件日志上的有序投影与历史操纵基础设施 会话事件日志(event log)是 DeepSeek Harn
人工智能AI AgentAgent 框架DeepSeekDeepSeek Harness 会话缓存命中率:TUI 页脚 `cache N%` 的设计与实现
DeepSeek Harness 会话缓存命中率:TUI 页脚 cache N% 的设计与实现 本篇技术笔记基于 DeepSeek Harness 归档的设计决
人工智能AI AgentAgent 框架DeepSeek
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考