返回课程首页

12

会话为什么是一棵树

认识 Fork、Branch 与从历史节点重新尝试。

源码基线:Pi v0.82.0 · commit 518855d

桌面 Agent 恢复了昨天的会话。用户翻到一条旧问题:

请用方案 A 重构登录流程。

Agent 后来沿着方案 A 走了很多步。现在用户希望把旧问题改成:

请用方案 B 重构登录流程。

产品要求是:

  • 重新从这里开始;
  • 保留方案 A 的全部历史;
  • 随时可以切回任一方案;
  • 当前模型只看到正在使用的那条路径。

如果 Session 只是一条数组,通常只能截断后半段。Pi 使用 id + parentId + leaf,让同一 Session 同时保存多条分支。

1. 数组模型会丢掉什么

线性历史:

User 1 → Assistant 1 → User A → Assistant A

User A 前重新提问时,最直接的实现是:

messages = messages.slice(0, index);
messages.push(revisedUserMessage);

结果是方案 A 的后续消息被删除。

树形历史则是:

flowchart LR
    U1["User 1"] --> A1["Assistant 1"]
    A1 --> UA["User:方案 A"]
    UA --> AA["Assistant:方案 A 结果"]
    A1 --> UB["User:方案 B"]
    UB --> AB["Assistant:方案 B 结果"]

UAUB 是同一个父节点的两个孩子。旧分支没有被覆盖。

2. parentId 怎样形成树

每个 Session Entry 都有:

interface SessionTreeEntryBase {
  type: string;
  id: string;
  parentId: string | null;
  timestamp: string;
}

第一条 Entry:

parentId = null

后续追加时:

parentId = 当前 leafId

在一条普通线性会话中:

e1(parent=null)
  └─ e2(parent=e1)
       └─ e3(parent=e2)

把当前 Leaf 移回 e1,再追加 e4

e1
  ├─ e2
  │    └─ e3
  └─ e4

树不是额外复制出来的数据结构。它已经编码在每条 Entry 的 parentId 中。

3. Leaf 是“当前所在位置”

Session 可以保存整棵树,但 Agent 一次只能沿一条活动路径继续。

flowchart TD
    R["e1"] --> A["e2"]
    A --> B["e3:旧分支"]
    R --> C["e4"]
    C --> D["e5:当前 Leaf"]

leafId = e5 表示当前 Context 从 e5 沿父链回溯。没有 Compaction 时会到物理根:

e1 → e4 → e5

e2 → e3 仍在物理 Session 中,但不属于当前活动路径。

因此要区分:

await session.getEntries();

返回物理保存的全部 Entry;

await session.getBranch();

返回从当前 Leaf 到物理根或 Compaction Checkpoint/保留起点的 Context 路径;

await session.buildContext();

对活动路径应用 Compaction 和 Entry 投影,得到 Agent Context 所需消息和设置。

4. 用 Session.moveTo 创建分支

假设当前路径是:

const firstUserId =
  await session.appendMessage(firstUser);

const firstAssistantId =
  await session.appendMessage(firstAssistant);

await session.appendMessage(planAUser);
await session.appendMessage(planAAssistant);

现在回到第一次 Assistant 之后:

await session.moveTo(firstAssistantId);

await session.appendMessage({
  role: "user",
  content: "请改用方案 B。",
  timestamp: Date.now(),
});

moveTo() 不删除方案 A。它让 Storage 的活动 Leaf 指向 firstAssistantId,下一条消息自然成为其新孩子。

源码中的流程:

flowchart LR
    M["session.moveTo(targetId)"] --> V["确认目标 Entry 存在"]
    V --> L["storage.setLeafId(targetId)"]
    L --> A["下一次 appendEntry"]
    A --> P["新 Entry.parentId = targetId"]

5. Leaf 的变化怎样持久化

内存中改一个 leafId 很容易,但应用重启后怎样知道最后选择了哪条分支?

通用 JSONL Storage 在 setLeafId() 时追加控制 Entry:

interface LeafEntry
  extends SessionTreeEntryBase {
  type: "leaf";
  targetId: string | null;
}

它记录“活动 Leaf 改为哪个 Entry”。重新打开 JSONL 时按文件顺序扫描:

  • 普通 Entry 让当前 Leaf 前进到该 Entry;
  • Leaf Entry 让当前 Leaf 跳到 targetId

Leaf Entry 是持久日志的一部分,但 buildContext() 从它指向的目标开始走父链,不把控制 Entry 发给模型。

SQLite 后端则在 Session 记录和物化状态中保存活动 Leaf,同样通过 SessionStorage 对上层暴露统一语义。

6. 修改旧 User Message 为什么要回到它之前

用户选择旧的:

User:请用方案 A

如果把 Leaf 放在这条 User Message 本身,再追加“请用方案 B”,模型会看到:

User:请用方案 A
User:请用方案 B

这不是“改写旧问题”,而是追加补充。

真正重新编辑时,应把 Leaf 移到旧 User Message 的父节点:

... → parent
          ├─ User:方案 A
          └─ User:方案 B

AgentHarness.navigateTree() 已经区分这两种情况:

  • 选择 User Message:新 Leaf 是该 Entry 的 parentId,并返回原文字供编辑;
  • 选择 Custom Message:行为相同;
  • 选择 Assistant、Tool Result、Compaction 等:新 Leaf 是目标 Entry 本身,编辑器留空。
const result =
  await harness.navigateTree(userEntryId);

if (
  !result.cancelled &&
  result.editorText !== undefined
) {
  desktopComposer.setText(
    result.editorText,
  );
}

用户修改后再调用:

await harness.prompt(revisedText);

新的 User Message 会成为旧 User Message 的兄弟节点。

7. navigateTree 只能在空闲时调用

源码首先检查 Harness Phase:

if (this.phase !== "idle") {
  throw new AgentHarnessError(
    "busy",
    "navigateTree() requires idle harness",
  );
}

原因很直接:Agent 正在流式生成或执行工具时,移动 Leaf 会让“本轮从哪条 Context 开始”和“结果写到哪条分支”产生竞争。

桌面 UI 应:

  • 运行中禁用树导航;
  • 或先请求 Abort,再等待 Harness settle;
  • 确认进入 idle 后才调用 navigateTree()
  • 不要只在 UI 上切换高亮而不改变 Session Leaf。

8. 同文件 Branch 和新 Session Fork

两个动作常被混淆。

Branch

await session.moveTo(entryId);
  • 仍在同一个 Session;
  • 旧分支和新分支共享 Entry 树;
  • 适合在一个任务内探索多个方案;
  • getEntries() 能看到全部分支。

Fork

const forked = await repo.fork(
  sourceMetadata,
  {
    cwd,
    entryId,
    position: "at",
  },
);
  • 创建新的 Session;
  • 复制选定路径或完整 Entry 集;
  • 新 Session 有自己的 Metadata/Header;
  • 适合把某条路径变成独立任务。
flowchart TD
    S["原 Session"] --> B["Branch:同一文件内新增孩子"]
    S --> F["Fork:创建另一个 Session"]

9. Fork 的 before 与 at

通用 Repo 的目标 Fork 支持:

position:
  | "before"
  | "at";

position: "at"

包含目标 Entry:

context boundary → ... → target

新 Session 从 target 继续。

position: "before"

默认值是 "before"。目标必须是 User Message,新 Session 复制到它的父节点:

context boundary → ... → target.parent

适合把旧 User Message 放回编辑器,修改后重新提交。

如果 "before" 的目标不是 User Message,源码返回 invalid_fork_target

10. 不传 entryId 的 Fork

getEntriesToFork() 在没有 entryId 时调用:

storage.getEntries();

也就是复制物理 Session Entry 集,而不是只复制当前 Context Message。JSONL 和 Memory Repo 再把这些 Entry 追加到新 Storage。

因此要按产品语义选择:

  • “复制整个会话及分支状态”:不传 entryId
  • “从某个历史点创建独立任务”:传 entryId
  • “修改这个旧问题”:position: "before"
  • “从这个结果继续”:position: "at"

11. Branch Summary 解决什么问题

用户在方案 A 中可能已经发现:

  • 某个 API 不可用;
  • 已修改两个文件;
  • 测试失败原因;
  • 一个关键设计约束。

直接切回公共祖先后,方案 A 不在新活动路径,模型看不到这些发现。

navigateTree(targetId, { summarize: true }) 可以先总结离开的分支:

sequenceDiagram
    participant H as AgentHarness
    participant S as Session
    participant M as Summary Model

    H->>S: old leaf path
    H->>S: target path
    H->>H: 找 deepest common ancestor
    H->>H: 收集 old leaf 到祖先之间的 Entry
    H->>M: 生成 branch summary
    M-->>H: summary + usage
    H->>S: moveTo(new leaf, summary)
    S-->>H: BranchSummaryEntry

Summary Entry 位于新分支起点之后,并会转成 BranchSummaryMessage 进入 Context。它不是完整旧分支,只是一份有损摘要。

12. Branch Summary 收集哪一段

collectEntriesForBranchSummary() 使用 Session.getBranch(),所以比较的是当前可遍历的 root-or-compaction 路径:

  1. 获取旧 Leaf 的 root-or-compaction 路径;
  2. 获取目标 Entry 的 root-or-compaction 路径;
  3. 在两条可遍历路径中找最深公共祖先;共同祖先若只存在于 Compaction 边界以前,结果可能是 null
  4. 从旧 Leaf 向上收集,直到公共祖先但不包含它;
  5. 反转为时间顺序。
flowchart LR
    C["公共祖先"] --> O1["旧分支 1"]
    O1 --> O2["旧 Leaf"]
    C --> T1["目标分支"]

    O1 -.进入摘要.-> S["Branch Summary"]
    O2 -.进入摘要.-> S

公共历史已经会出现在目标路径中,不需要重复总结。

13. Branch Summary 的模型调用

generateBranchSummary() 会:

  • 从 Entry 投影可总结消息;
  • 提取已读和已改文件;
  • 按模型 Context Window 留出输出空间;
  • 把分支序列化;
  • 请求模型生成 Goal、Constraints、Progress、Decisions、Next Steps;
  • 保存 Summary、Usage 和文件详情。

Tool Result Message 不直接作为普通摘要消息加入,但文件操作会从相关消息和已有 Summary Details 中提取。摘要有自己的模型调用、Token 和失败路径。

桌面应用可以让用户选择:

  • 不生成摘要,立即切换;
  • 生成默认摘要;
  • 提供自定义关注点;
  • 由 Hook 返回摘要;
  • session_before_tree Hook 在默认摘要开始前取消导航。

14. Summary 失败时不能静默切换

Harness 的默认路径中:

  • 用户请求 Summary;
  • Summary 模型失败;
  • navigateTree() 抛出 Branch Summary Error;
  • finally 把 Phase 恢复为 idle。

它不会在默认失败路径中假装摘要成功再继续切换。

底层 Summary 结果若以 aborted 结束,Harness 会映射为:

{ cancelled: true }

UI 应明确显示导航未完成,不要只移动界面选中项。

但当前通用 AgentHarness 没有公开可中止“已经开始的默认 Branch Summary”的 Controller。harness.abort() 只中止普通 Agent Turn,不会中止这里内部新建的 Signal。桌面停止按钮可以在导航前取消或由 Hook 预检阻止,不能承诺在默认 Summary 请求开始后立即打断它。

15. Branch Summary 与原始历史的关系

Summary 不会删除旧分支。

物理 Entry:

旧分支全部保留
+ 新分支
+ BranchSummaryEntry

当前模型 Context:

公共祖先路径
+ Branch Summary
+ 新分支后续

这正是 Session 历史和 Model Context 分离的又一个例子。

16. 模型与 Thinking 设置也在路径上

Model Change、Thinking Level Change 和 Active Tools Change 都是树中的 Entry。Session.buildContext() 只从当前 root-or-compaction 路径内推导状态,初始值分别是:

model = null
thinkingLevel = "off"
activeToolNames = null

切回较早节点时,目标路径之后的设置 Entry 不再属于新 Context。

同样,如果某项设置 Entry 只存在于 Compaction 边界以前,它也可能已经不在可遍历路径中:

  • Thinking 回落为 "off"
  • Model 变成 null,除非边界后的 Assistant Message 又提供模型信息;
  • Active Tools 变成 null

通用 AgentHarness 的运行字段仍由宿主维护;navigateTree() 不会自动把 SessionContext.model、Thinking 和 Active Tools 写回 Harness 状态。桌面应用若要求“切分支同时恢复当时配置”,应:

  1. session.buildContext()
  2. null 和默认值应用明确的宿主回退;
  3. 解析仍可用的 Model;
  4. 过滤有效工具;
  5. 决定是否调用 Harness 设置 API。

setModel()setThinkingLevel()setTools() 等方法会追加新 Entry,不能当成无副作用的读取恢复。如果产品要求跨 Compaction 精确恢复配置,应在 Checkpoint 后重写设置 Entry,或由宿主持久化独立配置快照。

17. 怎样构建桌面树视图

通用 Harness Session 提供所有 Entry 和活动 Leaf,桌面应用可以建立:

type SessionTreeViewNode = {
  id: string;
  parentId: string | null;
  label: string;
  isActivePath: boolean;
  children: SessionTreeViewNode[];
};

步骤:

  1. getEntries() 获取物理 Entry;
  2. 按 ID 建立 Node Map;
  3. 根据 parentId 挂到父节点;
  4. 若只高亮模型 Context 路径,可从 getBranch() 建立 ID Set;
  5. 若要高亮 Compaction 边界以前的完整物理祖先链,应从 getEntries() 的索引自行沿 parentId 回溯;
  6. getLeafId() 标记当前位置;
  7. 隐藏或特殊处理 leaf 这类控制 Entry;
  8. 不把 Tool Result、Label、Setting Entry 都渲染成普通聊天气泡。

完整 coding-agent 的专用 SessionManager.getTree() 已提供 SessionTreeNode[],并解析 Label;这是专用 Manager API,不是通用 Session 的方法。

18. “时间旅行”只是一个比喻

树导航看起来像回到过去,但源码没有修改过去:

移动 Leaf
→ 从目标沿父链重建 Context
→ 后续 Entry 作为新孩子追加

旧 Entry:

  • 不被重写;
  • 不被删除;
  • ID 不改变;
  • 仍可用于另一条活动路径或 Fork。

所以更准确的说法是:

选择一个历史节点作为新的继续点。

19. 把方案 A / B 串起来

完整流程:

sequenceDiagram
    participant U as 用户
    participant UI as 桌面树视图
    participant H as AgentHarness
    participant S as Session
    participant M as 模型

    U->>UI: 选择“方案 A”旧 User Message
    UI->>H: navigateTree(userEntryId)
    H->>S: 收集旧路径与目标路径
    H->>S: moveTo(user.parentId)
    H-->>UI: editorText = 原问题
    U->>UI: 改成方案 B
    UI->>H: prompt(修改后的文字)
    H->>S: buildContext()
    S-->>H: 公共路径,不含旧方案 A
    H->>M: 公共 Context + 方案 B
    M-->>H: 新回答
    H->>S: appendMessage

若启用 Summary,moveTo() 前会先总结方案 A,并把 Summary Entry 加入新路径。

20. 常见误区

“Branch 会复制前面的所有 Entry”

错误。同一 Session Branch 只移动 Leaf,公共历史仍由 parentId 共享。

“Fork 和 Branch 是同一个动作”

错误。Branch 留在同一 Session;Fork 创建新 Session。

“选择旧 User Message后把 Leaf 放在它上面”

如果目的是编辑并重新提交,应放在它的父节点。

“getEntries() 就是模型看到的历史”

错误。它包含其他分支和控制 Entry;模型使用活动路径投影后的 Context。

“Branch Summary 能无损保留旧分支”

错误。它是有损摘要,原始 Entry 仍靠 Session 树保留。

“树导航会修改过去”

错误。它只改变继续点,并追加新 Entry。

21. 本章小结

  • parentId 让 Session Entry 构成树;
  • Leaf 表示当前继续位置;
  • getEntries() 是物理全集,getBranch() 是到物理根或 Compaction Checkpoint 的 Context 路径,buildContext() 是模型视图;
  • Session.moveTo() 移动 Leaf,下一条 Entry 形成新分支;
  • JSONL 用 Leaf Entry 持久化当前选择;
  • 编辑旧 User Message 时应回到它的父节点;
  • AgentHarness.navigateTree() 已实现 User/Custom 与其他 Entry 的不同选择语义;
  • Branch 留在同一 Session,Fork 创建独立 Session;
  • Fork 的 beforeat 决定是否包含目标;
  • Branch Summary 将离开路径的关键信息带入新分支,但不会删除原历史;
  • 时间旅行只是“从历史节点建立新的继续点”。

22. 自测

  1. parentIdleafId 分别解决什么问题?
  2. 为什么 getEntries() 不能直接作为模型 Context?
  3. 修改旧 User Message 时,新 Leaf 应该放在哪里?
  4. moveTo() 会不会删除旧分支?
  5. Branch 和 Fork 的存储结果有什么区别?
  6. position: "before" 对目标 Entry 有什么要求?
  7. Branch Summary 收集公共祖先的哪一侧?
  8. 为什么 Branch Summary 和原始 Session 历史都需要保留?