桌面 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 结果"]
UA 和 UB 是同一个父节点的两个孩子。旧分支没有被覆盖。
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 路径:
- 获取旧 Leaf 的 root-or-compaction 路径;
- 获取目标 Entry 的 root-or-compaction 路径;
- 在两条可遍历路径中找最深公共祖先;共同祖先若只存在于 Compaction 边界以前,结果可能是
null; - 从旧 Leaf 向上收集,直到公共祖先但不包含它;
- 反转为时间顺序。
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_treeHook 在默认摘要开始前取消导航。
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 状态。桌面应用若要求“切分支同时恢复当时配置”,应:
session.buildContext();- 对
null和默认值应用明确的宿主回退; - 解析仍可用的 Model;
- 过滤有效工具;
- 决定是否调用 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[];
};
步骤:
getEntries()获取物理 Entry;- 按 ID 建立 Node Map;
- 根据
parentId挂到父节点; - 若只高亮模型 Context 路径,可从
getBranch()建立 ID Set; - 若要高亮 Compaction 边界以前的完整物理祖先链,应从
getEntries()的索引自行沿parentId回溯; - 用
getLeafId()标记当前位置; - 隐藏或特殊处理
leaf这类控制 Entry; - 不把 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 的
before与at决定是否包含目标; - Branch Summary 将离开路径的关键信息带入新分支,但不会删除原历史;
- 时间旅行只是“从历史节点建立新的继续点”。
22. 自测
parentId和leafId分别解决什么问题?- 为什么
getEntries()不能直接作为模型 Context? - 修改旧 User Message 时,新 Leaf 应该放在哪里?
moveTo()会不会删除旧分支?- Branch 和 Fork 的存储结果有什么区别?
position: "before"对目标 Entry 有什么要求?- Branch Summary 收集公共祖先的哪一侧?
- 为什么 Branch Summary 和原始 Session 历史都需要保留?