
对话记录怎么回退到旧节点?如果你把会话当成一串消息,答案只有两个:删掉尾部重来,或者复制一份新会话。pi 两件事都没做。它让存储保持 append-only,再用 parentId 表示每条 entry 接在哪个节点之后。文件里的行仍然按发生时间向后追加,模型上下文却只取 active leaf 所在的祖先路径。
这个设计同时保留了两类事实:旧分支做过什么,当前分支又选中了什么。它也解释了上一章为什么必须在保存点控制写入顺序——普通 append 总是把当前 leaf 作为 parent,把新 entry 变成下一个 leaf。插入位置一旦错了,改变的不只是展示顺序,而是整棵树的拓扑。
Entry 比 Message 多得多
所有 SessionTreeEntry 都有 type、id、parentId 和 ISO 时间戳。联合类型除 message 外,还包含 thinking level、model、active tools、compaction、branch summary、custom、custom message、label、session info 与 leaf。
也就是说,会话树不只保存聊天正文,也保存沿分支生效的运行配置、摘要与导航记录。模型变更、thinking level、active tools 的修改都复用 Session.appendMessage() 的追加规则:storage 先生成 id,parentId 读当前 leaf,时间在追加时产生,然后 appendEntry()。
配置因此也属于一条分支。从更早的节点另开分支时,不必继承原分支后面才发生的模型变更——这是「配置是分支数据」的直接推论。
把一段会话写成 JSONL,行顺序是时间,箭头由 parentId 决定:
1 {type:"message", id:"u1", parentId:null}
2 {type:"message", id:"a1", parentId:"u1"}
3 {type:"message", id:"u2", parentId:"a1"}
4 {type:"message", id:"a2", parentId:"u2"}
5 {type:"leaf", id:"nav1", parentId:"a2", targetId:"u1"}
6 {type:"message", id:"a3", parentId:"u1"}
按文件顺序读,u2/a2 仍然存在;按当前 leaf a3 回溯,active branch 只有 u1/a3。第 5 行记录了一次「从 a2 移到 u1」的动作,它自身不成为对话的 active leaf。storage 看到 leaf entry 时,把 targetId 而不是 leaf entry 自己的 id 设为 pointer。

Leaf 是指针,也是一条可重放记录
Session.moveTo() 先校验目标存在,再调用 storage 的 setLeafId(entryId)。JSONL backend 并不只改内存变量:它构造一条 type: "leaf" 的 entry,parent 指向移动前的 current leaf,target 指向移动后的节点,先 append 文件,再更新内存索引与 current leaf。
重新打开文件时,loader 顺序扫描所有 entry:普通 entry 让 leaf 变成自己的 id,leaf entry 则让 leaf 变成 targetId。
这比只在 header 覆盖一个 activeLeafId 多写了一行,却保留了导航发生的先后。内存 backend 遵循同一合同;SQLite backend 可以额外维护 materialized branch,但对上仍实现相同的 SessionStorage 接口。
moveTo() 若收到 summary,会在移动 pointer 后追加 branch_summary,其 parent 正是目标 entry。这样旧分支的摘要成为新分支的第一个可见节点,新的 current leaf 也随之变成 summary entry。
模型读到的是 active path 的投影
Session.getEntries() 返回 append log;Session.getBranch() 从显式 fromId 或 current leaf 调用 getPathToRootOrCompaction();buildContextEntries() 在这条 path 上先执行默认 compaction transform,再执行应用提供的 entry transforms;buildContext() 进一步把 entry 映射为 AgentMessage[],同时派生 thinking level、model 与 active tools。
默认投影也不是「entry.payload 原样交给模型」:message 直接成为 AgentMessage;custom message 转成 role: custom;compaction 与 branch summary 各自变成摘要消息;普通 custom entry 默认返回空数组,只有注册了对应 entryProjectors[customType] 才能贡献消息。label、session info、leaf 和几种配置 entry 都不直接出现在 message 列表里。
所以要区分三个集合:
| 集合 | 来源 | 包含旧分支吗 | 直接模型可见吗 |
|---|---|---|---|
| append log | getEntries() | 包含 | 否 |
| active path | getBranch() | 只含当前祖先路径(遇 compaction 还有边界) | 否 |
| context messages | buildContext() | 来自 active path 的选择与投影 | 是,之后还要经过 convertToLlm |
UI 可以用 append log 画整棵树,provider request 却不应把旧分支一起带上。反过来,某条 entry 没进入 provider context,也不代表它没有持久价值:label 用于导航提示,session info 用于列表名称,leaf entry 用于重建 pointer。
用同一套测试观察两种 backend
session.test.ts 把相同 suite 同时跑在 in-memory 与 JSONL storage 上:先追加 user1 → assistant2,再把 leaf 移回 user1,追加 branched。重新用同一 storage 创建 Session 后,context roles 仍只有 user、assistant,label 与 session name 可读。JSONL suite 还检查文件确实包含 header 和 leaf entry。
拆完这一章,我的感受是:「会话历史」这个词误导了很多人。它听起来像一串按时间排列的消息,但真正撑起「回到过去」这个能力的,是树拓扑加一个可重放的指针。把会话设计成树,代价是每次 append 都要小心 parent 是谁;收益是回退、分叉、摘要导航都变成同一种操作——追加一条记录,而不是删除什么。
源码依据:packages/agent/src/harness/types.ts L375-L464;session/session.ts L214-L281、L338-L357、L159-L200、L92-L147;session/jsonl-storage.ts L103-L146、L247-L287,pi v0.83.0,commit 845d6ff1f6643aba440341cce877ce1c43ebbc39。