Skip to content

Session 存储与分支 ​

实现在 packages/coding-agent/src/core/session-manager.ts。这一层回答:消息怎么落盘、怎么表示分支、恢复时怎么重建上下文。

JSONL 格式 ​

一个 session 文件的第一行是 header,之后每行一个 entry:

json
{"type":"session","version":3,"id":"...","timestamp":"...","cwd":"...","parentSession":"..."}
{"type":"message","id":"...","parentId":null,"timestamp":"...","message":{...}}
{"type":"model_change","id":"...","parentId":"...","timestamp":"...","provider":"anthropic","modelId":"..."}

读取时逐行 JSON 解析,坏行直接跳过。CURRENT_SESSION_VERSION 是 3,加载时通过 migrateToCurrentVersion() 升级旧版本。

Entry 类型 ​

所有 entry 共享 type / id / parentId / timestamp。

type作用
message普通消息:system / user / assistant / toolResult,也可能带 bashExecution
custom_message扩展注入、参与 LLM 上下文的消息
custom扩展状态,不参与 LLM 上下文
compaction压缩摘要,带 firstKeptEntryId 和 systemMessage
branch_summary分支切换时对被放弃路径的摘要
model_change模型选择记录
thinking_level_change思考级别记录
context_edit对早期 entry 内容的追加式改写
label用户书签
session_info会话显示名
usage计费记录,如缓存预热

appendMessage() 不允许写入 compaction / branch_summary;它们必须走专用方法,便于按类型查找。

树结构 ​

text
SessionManager
  fileEntries  按写入顺序的全部 entry
  byId         id -> entry
  leafId       当前分支末端

关键操作:

  • branch(id):只把 leafId 移到该 entry,不修改也不删除任何 entry
  • resetLeaf():leafId = null,下一次追加会成为新的根
  • getBranch(fromId?):从叶子沿 parentId 回溯到根,再正序返回
  • getTree():构建完整的父子结构;孤立节点按根处理
  • getChildren(parentId):直接子节点

所以「分支」不是复制数据,而是移动一个指针,所有历史 entry 永远保留。

投影:从树到模型上下文 ​

投影分三步:

text
buildSessionPath()      叶子 -> 根
buildContextEntries()   处理 compaction
buildSessionProjection() 应用 context_edit,产出消息

buildSessionPath():

  • leafId 为 null 时返回空
  • 有 leafId 时用它,否则用最后一条 entry
  • 沿 parentId 回溯后反转

buildContextEntries() 找到路径上最新的 compaction:

text
结果 = [compaction]
     + firstKeptEntryId 开始保留的 entry
     + compaction 之后的所有 entry

被摘要掉的旧 entry 不进入结果。注意从 firstKeptEntryId 开始会跳过其中的 system 消息,避免重复提示词。

buildSessionProjection() 还做两件事:

  • 收集路径上的 context_edit,对目标 entry 做替换或省略(replacement: null 表示从模型上下文里去掉)
  • 回放 model_change / thinking_level_change / 最后一条 assistant 消息,得到恢复用的模型和思考级别

context_edit 是分支局部的:另一个分支看不到它。

entry 到消息 ​

sessionEntryToContextMessages() 做转换:

entry产出
message原消息;缺失 content 时补成空字符串或空数组
custom_messagecreateCustomMessage()
branch_summarycreateBranchSummaryMessage()
compaction有 systemMessage 时产出 system 消息 + 压缩摘要消息,否则只有摘要
其它空数组

容错是有意为之:session 文件可能来自旧版本、fork 或手工编辑。

追加与持久化 ​

所有 appendXxx() 都会:

text
生成 id
parentId = 当前 leafId
push 进 fileEntries、写 byId
leafId = 新 entry id
按需写盘

写盘策略:_hasConversation() 为 true(存在 user 或 assistant 消息)之前不创建文件。这样只是打开 Pi 看看,不会留下空的 session 文件。第一条用户消息出现后,header 和已有 entry 一次性写入,之后是纯追加。

appendMessage() 返回 entry id,AgentSession 把它记进 WeakMap,后续扩展事件和 context edit 都靠它定位。

compaction entry ​

ts
appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage)

它还会保存当时的系统提示词快照(systemMessage)。firstKeptEntryId 为空时等于自身 id,表示不保留任何更早的 entry。

分支与 fork ​

操作效果
branch(id)移动叶子,原地开新分支
branchWithSummary(id, summary)移动叶子,并追加一条 branch_summary
createBranchedSession(leafId)新建文件,只保留从根到该叶子的路径
SessionManager.forkFrom(path, cwd)新建文件,新 header 的 parentSession 指向源文件,复制全部 entry

createBranchedSession() 需要重新串联 parentId,因为 label entry 会被过滤掉,而某些 entry 可能是 label 的子节点。

常用构造入口:

text
SessionManager.create(...)        新会话
SessionManager.continueRecent(...) 继续最近的会话
SessionManager.forkFrom(...)       从别的项目目录 fork
SessionManager.inMemory(...)       不落盘的会话
SessionManager.findById(...)       按 id 找会话文件

恢复流程 ​

text
loadEntriesFromFile()
  -> 迁移版本
  -> _buildIndex()
     leafId = 最后一条 entry
  -> buildSessionProjection()
     messages / model / thinkingLevel
  -> AgentSession 写回 agent.state

UI 侧用 buildContextEntries() 渲染保留的历史,用 getBranch() 拿到完整路径。

易错点 ​

  • firstKeptEntryId 可能等于 compaction 自身 id。
  • agent.state.messages 是投影结果,不是持久化真相;要看原始历史用 getBranch() / getEntries()。
  • leafId 会随任意追加前进,包括 label、usage、model_change,这是正常的,路径就是靠 parentId 串起来的。
  • context_edit 不改变原始 entry,只影响投影。

建议阅读顺序 ​

  1. packages/coding-agent/src/core/session-manager.ts 的类型定义部分
  2. buildSessionPath() / buildContextEntries() / buildSessionProjection()
  3. SessionManager 的 append 方法
  4. branch() / createBranchedSession() / forkFrom()