buildConversationChain --- 如何从 JSONL 原样恢复对话
基于
src/utils/sessionStorage.ts源码分析。
总体流程
scss
JSONL 文件在磁盘上(无序追加的行)
│
▼
loadTranscriptFile() ← 步骤 1: 解析
│
├─ 逐行 parse JSON → Entry 对象
├─ TranscriptMessage → Map<UUID, TranscriptMessage>
├─ 元数据条目 → 各自的 Map(summaries, titles, tags...)
├─ 计算 leafUuids(哪些消息是叶子节点)
└─ applyPreservedSegmentRelinks() / applySnipRemovals()
│
▼
findLatestMessage(leafUuids) ← 步骤 2: 找锚点
│
▼
buildConversationChain(messages, leaf) ← 步骤 3: 链表回溯
│
▼
recoverOrphanedParallelToolResults() ← 步骤 4: 修复并行工具
│
▼
返回 TranscriptMessage[](从根到叶的有序数组)
步骤 1: loadTranscriptFile() --- 解析 JSONL,建立 Map
位置: src/utils/sessionStorage.ts:3472
1a. 逐行读取
typescript
const entries = parseJSONL<Entry>(buf) // 逐行 JSON.parse
每一行 JSON 被解析为一个 Entry 联合类型。解析结果分发到不同的数据结构中。
1b. 分发到不同容器
typescript
const messages = new Map<UUID, TranscriptMessage>() // 对话消息
const summaries = new Map<UUID, string>() // compact 摘要
const customTitles = new Map<UUID, string>() // 用户自定义标题
const tags = new Map<UUID, string>() // 标签
const fileHistorySnapshots = new Map<UUID, ...>() // 文件快照
const attributionSnapshots = new Map<UUID, ...>() // 归属快照
const contentReplacements = new Map<UUID, ...>() // 内容替换记录
// ... 更多
对于每一条解析出的 entry 按 type 字段分发:
ini
entry.type === 'user' → messages.set(entry.uuid, entry)
entry.type === 'assistant' → messages.set(entry.uuid, entry)
entry.type === 'system' → messages.set(entry.uuid, entry)
entry.type === 'attachment' → messages.set(entry.uuid, entry)
entry.type === 'summary' → summaries.set(entry.leafUuid, entry.summary)
entry.type === 'custom-title'→ customTitles.set(entry.sessionId, entry.customTitle)
entry.type === 'tag' → tags.set(entry.sessionId, entry.tag)
entry.type === 'mode' → modes.set(entry.sessionId, entry.mode)
... 以此类推
关键点: JSONL 文件中的行是物理追加顺序 ,不是逻辑对话顺序。解析后的 Map<UUID, TranscriptMessage> 是一个无序的哈希表 ------所有消息平铺在一个大 Map 中,逻辑顺序完全由 parentUuid 指针维护。
1c. 处理 legacy progress 桥接
在旧版本中,progress 类型的条目也参与 parentUuid 链。新版中 progress 不再属于 TranscriptMessage。为了兼容旧 transcript,需要桥接:
typescript
// src/utils/sessionStorage.ts:3629-3641
if (isLegacyProgressEntry(entry)) {
const parent = entry.parentUuid
progressBridge.set(
entry.uuid,
parent && progressBridge.has(parent)
? (progressBridge.get(parent) ?? null) // 链式解析
: parent,
)
continue
}
// 后续处理 TranscriptMessage 时跳过 progress,直连到真正的 parent:
if (entry.parentUuid && progressBridge.has(entry.parentUuid)) {
entry.parentUuid = progressBridge.get(entry.parentUuid) ?? null
}
1d. 大文件优化
对于超过 SKIP_PRECOMPACT_THRESHOLD 的大 transcript,加载时有三个优化:
readTranscriptForLoad()--- 在 fd 层面跳过 compact boundary 之前的字节(attribution-snapshot行在读取时就被过滤掉)scanPreBoundaryMetadata()--- 从 boundary 之前的字节中恢复 session 元数据(agentSetting、mode、pr-link 等)walkChainBeforeParse()--- 在 JSON 解析之前先遍历 parentUuid 链,丢弃不在链上的死分支(对分叉/合并场景可节省大量内存)
1e. 计算叶子节点
typescript
// 所有作为别人 parent 的 UUID
const parentUuids = new Set(
allMessages
.map(msg => msg.parentUuid)
.filter((uuid): uuid is UUID => uuid !== null),
)
// 叶子 = 所有消息的 UUID - 所有作为别人 parent 的 UUID
const leafUuids = new Set(
allMessages
.filter(m => isUserOrAssistantMessage(m))
.map(m => m.uuid)
.filter(uuid => !parentUuids.has(uuid))
)
叶子节点 就是没有被任何其他消息引用为 parentUuid 的消息。一个对话可以有多条链(比如分叉),因此有多个叶子。
1f. applyPreservedSegmentRelinks()
位置: src/utils/sessionStorage.ts:1839
当 compact 发生后,JSONL 中有一个 compact boundary 标记。boundary 之前的消息被"保留段"(preserved segment)机制保存:
less
JSONL 物理布局:
msg_1 (parent: null) ← pre-compact
msg_2 (parent: 1) ← pre-compact
msg_3 (parent: 2) ← pre-compact, preserved segment HEAD
msg_4 (parent: 3) ← pre-compact, preserved segment TAIL
[compact boundary] ← 带有 preservedSegment 元数据
msg_5 (parent: null) ← post-compact: boundary marker
msg_6 (parent: 5) ← post-compact: summary
msg_7 (parent: 6) ← post-compact: 新用户消息
applyPreservedSegmentRelinks 做四件事:
- 重新链接保留段头部 --- 将 preserved segment 第一条消息的
parentUuid指向 boundary 的anchorUuid - 重新链接保留段尾部 --- 将 anchor 的其他直接子消息的
parentUuid改为指向保留段的尾部 - 清零保留段内 assistant 消息的 token usage --- 防止 resume 后因旧 usage 数据立即触发 auto-compact
- 剪枝 --- 删除 boundary 之前且不在保留段内的所有消息
1g. applySnipRemovals()
删除被 snip 操作标记为移除的消息(snip 是一种部分上下文裁剪机制,按消息范围精确删除)。
步骤 2: findLatestMessage() --- 找到最新的叶子
位置: src/utils/sessionStorage.ts:2046
typescript
function findLatestMessage<T extends { timestamp: string }>(
messages: Iterable<T>,
predicate: (m: T) => boolean,
): T | undefined {
let latest: T | undefined
let maxTime = -Infinity
for (const m of messages) {
if (!predicate(m)) continue
const t = Date.parse(m.timestamp)
if (t > maxTime) {
maxTime = t
latest = m
}
}
return latest
}
从所有叶子节点中,选 timestamp 最新的那个 作为恢复的起点。这保证了 --resume 总是恢复到最后一条消息所在的链。
php
Map 中所有消息:
msg_A (parent: null, ts: 04:20:00)
msg_B (parent: A, ts: 04:21:00)
msg_C (parent: B, ts: 04:22:00) ← 被 msg_D 引用为 parent → 不是叶子
msg_D (parent: B, ts: 04:23:00) ← 无人引用为 parent → 是叶子!
leafUuids = { msg_D }
findLatestMessage → msg_D(timestamp 最大)
步骤 3: buildConversationChain() --- 沿链表回溯
位置: src/utils/sessionStorage.ts:2069
typescript
export function buildConversationChain(
messages: Map<UUID, TranscriptMessage>, // 步骤 1 构建的 HashMap
leafMessage: TranscriptMessage, // 步骤 2 选出的最新叶子
): TranscriptMessage[] {
const transcript: TranscriptMessage[] = []
const seen = new Set<UUID>()
let currentMsg: TranscriptMessage | undefined = leafMessage
// 从叶子向根遍历
while (currentMsg) {
// 环检测:防御性编程
if (seen.has(currentMsg.uuid)) {
logError(new Error(
`Cycle detected in parentUuid chain at message ${currentMsg.uuid}. ` +
`Returning partial transcript.`
))
logEvent('tengu_chain_parent_cycle', {})
break
}
seen.add(currentMsg.uuid)
// 尾插到数组(结果是倒序的)
transcript.push(currentMsg)
// 通过 parentUuid 在 HashMap 中 O(1) 查找上一条消息
currentMsg = currentMsg.parentUuid
? messages.get(currentMsg.parentUuid)
: undefined
}
// 反转数组:从倒序变为正序(根 → 叶)
transcript.reverse()
// 修复并行工具调用的孤儿结果
return recoverOrphanedParallelToolResults(messages, transcript, seen)
}
回溯过程示意
ini
JSONL Map(无序 HashMap):
┌──────────────────────────────────────────────────────┐
│ uuid: "D" type: assistant parentUuid: "C" ts:... │
│ uuid: "A" type: user parentUuid: null ts:... │
│ uuid: "C" type: user parentUuid: "B" ts:... │
│ uuid: "B" type: assistant parentUuid: "A" ts:... │
└──────────────────────────────────────────────────────┘
叶子 = "D"(最新 timestamp,无子节点)
回溯:
step 1: currentMsg = D
transcript = [D]
step 2: currentMsg = messages.get("C") = C
transcript = [D, C]
step 3: currentMsg = messages.get("B") = B
transcript = [D, C, B]
step 4: currentMsg = messages.get("A") = A
transcript = [D, C, B, A]
step 5: currentMsg = messages.get(null) = undefined → 停止
reverse → [A, B, C, D] ← 正确的对话顺序(从第一条到最新)
三个关键机制
| 机制 | 说明 |
|---|---|
| O(1) HashMap 查找 | messages.get(parentUuid) 不依赖 JSONL 中行的物理顺序 |
| 环检测 | 如果链表出现环(数据损坏),记录错误并退出,返回部分链 |
| 不完整链自动终止 | 如果 parentUuid 指向的 UUID 不在 Map 中,get() 返回 undefined,遍历自动终止 |
步骤 4: recoverOrphanedParallelToolResults() --- 恢复并行工具的孤儿结果
位置: src/utils/sessionStorage.ts:2118
这是恢复过程中最精妙的部分。要理解它,必须先理解两个问题:
- 当 LLM 发出并行工具调用时,JSONL 里到底写了什么?
buildConversationChain回溯后,为什么有些消息丢了?
4.1 前置知识:并行工具调用的 JSONL 写入
假设 LLM 在一次回复中同时发出 3 个工具调用:Bash、Read、Grep。
bash
API 返回的 assistant 消息(uuid: B):
message.content = [
{ type: "text", text: "Let me check..." },
{ type: "tool_use", id: "toolu_1", name: "Bash", ... },
{ type: "tool_use", id: "toolu_2", name: "Read", ... },
{ type: "tool_use", id: "toolu_3", name: "Grep", ... },
]
这三个工具并行执行 ,各自独立完成。每个工具完成后,结果被包装为一个 user 消息写入 JSONL(通过 recordTranscript → insertMessageChain)。
关键在于 insertMessageChain 写入时 parentUuid 是如何分配的。看代码:
typescript
// src/utils/sessionStorage.ts:1001-1068(简化)
let parentUuid: UUID | null = startingParentUuid ?? null // 初始值
for (const message of messages) {
// ★ 关键:对 tool_result 消息,使用 sourceToolAssistantUUID
let effectiveParentUuid = parentUuid
if (message.type === 'user' && message.sourceToolAssistantUUID) {
effectiveParentUuid = message.sourceToolAssistantUUID // ← 指向 assistant B!
}
const transcriptMessage = {
parentUuid: effectiveParentUuid, // ← 写入 JSONL 的 parentUuid
...message,
}
await this.appendEntry(transcriptMessage)
if (isChainParticipant(message)) {
parentUuid = message.uuid // ← 更新顺序链指针,供下一条消息使用
}
}
每个 tool_result 的 parentUuid 都指向同一个 assistant 消息 B (因为 sourceToolAssistantUUID 覆盖了 effectiveParentUuid)。但是顺序链指针 parentUuid 会在每写入一条消息后更新。
所以 JSONL 中实际写入的内容是:
css
消息 type JSONL 中的 parentUuid 原因
──────────────────────────────────────────────────────────────────
A user null 用户的第一条输入
B assistant A 正常链(B 的 parent 是 A)
C user B ←★ Bash 结果,sourceToolAssistantUUID = B
D user B ←★ Read 结果,sourceToolAssistantUUID = B
E user B ←★ Grep 结果,sourceToolAssistantUUID = B
F assistant E ←★ 顺序链:F 的 effectiveParentUuid = parentUuid 变量 = E
关键洞察:
- C、D、E 的
parentUuid都是 B ------ 它们都是 B 的"子节点" - F 的
parentUuid是 E ------ 因为写入 E 之后parentUuid变量被更新为 E
4.2 问题:回溯时发生了什么
当 buildConversationChain 从叶子 F 开始回溯:
ini
从叶子 F 出发,沿 parentUuid 链回溯:
currentMsg = F
→ transcript = [F]
→ 下一个 = messages.get(F.parentUuid) = messages.get("E") = E
currentMsg = E
→ transcript = [F, E]
→ 下一个 = messages.get(E.parentUuid) = messages.get("B") = B
↑
注意:E.parentUuid 是 B,不是 D!
currentMsg = B
→ transcript = [F, E, B]
→ 下一个 = messages.get(B.parentUuid) = messages.get("A") = A
currentMsg = A
→ transcript = [F, E, B, A]
→ 下一个 = messages.get(A.parentUuid) = messages.get(null) = undefined → 停止
reverse → [A, B, E, F]
问题暴露 : 回溯得到的链是 [A, B, E, F],C 和 D 丢了!
less
JSONL Map 中有 6 条 TranscriptMessage:
A ←── 在链上
B ←── 在链上
C ←── ★ 不在链上!parentUuid = B,但回溯走的是 B→E→F
D ←── ★ 不在链上!parentUuid = B,但回溯走的是 B→E→F
E ←── 在链上
F ←── 在链上(叶子)
回溯利用的 seen 集合 = {A, B, E, F}
不在 seen 中的 = {C, D} ← 这就是"孤儿"
为什么会这样?因为 parentUuid 只能表达一对多的"一"那侧 。消息 B 有三个子节点(C、D、E),但每个子节点只能有一个 parentUuid 值。回溯时只能沿着一条路径走(B→E→F),其他路径(B→C、B→D)被遗漏了。
4.3 恢复算法详解
recoverOrphanedParallelToolResults 的目的就是找回这些孤儿。
第一步:收集链上所有的 assistant 消息
typescript
const chainAssistants = chain.filter(m => m.type === 'assistant')
// 结果: [B, F] (链上的两条 assistant)
第二步:建立反向索引 ------ 哪些 tool_result 指向这个 assistant
typescript
// 遍历 Map 中 ALL 消息(不仅是链上的)
// 按 parentUuid 对 tool_result 消息建立索引
const toolResultsByAsst = new Map<UUID, TranscriptMessage[]>()
for (const m of messages.values()) {
if (m.type === 'user' && m.parentUuid &&
Array.isArray(m.message.content) &&
m.message.content.some(b => b.type === 'tool_result')) {
// m 是一条 tool_result 消息
const group = toolResultsByAsst.get(m.parentUuid)
if (group) group.push(m)
else toolResultsByAsst.set(m.parentUuid, [m])
}
}
// 遍历后的 toolResultsByAsst:
// B → [C, D, E] ← 三条 tool_result 都指向 B!
// (F 没有 tool_result 子节点,所以不在 Map 中)
第三步:对每个链上的 assistant,找出孤儿
typescript
for (const asst of chainAssistants) { // asst = B, 然后 asst = F
// 查找所有 parentUuid 指向该 assistant 的 tool_result
const trs = toolResultsByAsst.get(asst.uuid) // B → [C, D, E]
// 过滤出不在链上的(即 seen 集合中没有的)
for (const tr of trs) {
if (!seen.has(tr.uuid)) orphanedTRs.push(tr)
}
}
// 对于 B: seen = {A, B, E, F}
// C 不在 seen → 孤儿!
// D 不在 seen → 孤儿!
// E 在 seen → 不是孤儿(链上已有)
// orphanedTRs = [C, D]
// 对于 F: toolResultsByAsst 中没有 key=F → 无操作
第四步:按时间戳排序孤儿
typescript
orphanedTRs.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
// C 先完成 (ts: 04:20:01), D 后完成 (ts: 04:20:02)
// sorted = [C, D]
第五步:将孤儿插入到正确位置
typescript
// "锚点" = 触发这些 tool_use 的 assistant 消息本身
// 孤儿应该出现在 assistant 和下一个 assistant 之间
const anchor = asst // 即 B
inserts.set(anchor.uuid, [C, D])
// 含义:在 B 后面插入 [C, D]
最后:重建数组
typescript
const result = []
for (const m of chain) { // chain = [A, B, E, F]
result.push(m)
const toInsert = inserts.get(m.uuid)
if (toInsert) result.push(...toInsert)
}
// 遍历过程:
// m = A: result = [A], A 没有待插入项
// m = B: result = [A, B], B 有待插入项 → push(C, D)
// result = [A, B, C, D]
// m = E: result = [A, B, C, D, E], E 没有待插入项
// m = F: result = [A, B, C, D, E, F], F 没有待插入项
4.4 最终效果对比
csharp
回溯得到的链(修复前):
[A, B, E, F]
↑ ↑ ↑
│ │ └─ 第二轮 assistant
│ └─────── 只有 Grep 的 tool_result(最后完成的那个)
└────────── 用户输入
修复后的链:
[A, B, C, D, E, F]
↑ ↑ ↑ ↑ ↑ ↑
│ │ │ │ │ └─ 第二轮 assistant
│ │ │ │ └──── Grep 结果(最后完成)
│ │ │ └─────── Read 结果(恢复的孤儿)
│ │ └────────── Bash 结果(恢复的孤儿)
│ └───────────── 包含 Bash+Read+Grep 的 assistant
└──────────────── 用户输入
4.5 算法的通用性
这个算法不仅处理单个 assistant 发出多个 tool_use 的情况,还处理:
| 场景 | 示例 | 恢复方式 |
|---|---|---|
| 单个 assistant 发出 N 个并行工具 | B 发出 Bash+Read+Grep | 恢复 N-1 个孤儿 tool_result |
| 多个 assistant 各自发出工具 | B1 发出 Bash, B2 发出 Read | 每轮独立恢复 |
| 同一 API 请求的多个分片(streaming) | B 被分成 B₁、B₂ 两个消息 | siblingsByMsgId 按 message.id 分组处理 |
| 嵌套工具调用 | Tool A 触发 Agent B,Agent B 触发 Tool C | 每层 independent 恢复 |
4.6 一句话总结
并行工具调用的结果在 JSONL 中都指向同一个 assistant 消息作为 parent 。但 parentUuid 链是线性的------从叶子回溯只能走到最后完成的 那条 tool_result。
recoverOrphanedParallelToolResults通过反向索引(parentUuid → tool_result 列表)找到所有孤儿,按时间戳排序后插回正确位置。
步骤 5: Compact 恢复的特殊处理
preservedSegment 机制
当发生 compact 时,大部分旧消息被摘要替代,但最近几条核心对话可以标记为"保留段"保留在 JSONL 中。
applyPreservedSegmentRelinks
位置: src/utils/sessionStorage.ts:1839
php
恢复前的物理 JSONL:
msg_X (parent: W) ← pre-compact
msg_Y (parent: X) ← pre-compact
msg_3 (parent: Y) ← pre-compact, preserved HEAD
msg_4 (parent: 3) ← pre-compact, preserved TAIL
═══════════════════════ compact boundary
boundary_msg (parent: null) ← post-compact: boundary marker
summary (parent: boundary) ← post-compact: 摘要
new_msg (parent: summary) ← post-compact: 新对话
applyPreservedSegmentRelinks 后:
1. msg_3.parentUuid = boundary.anchorUuid ← 链接保留段头部
2. anchor 的其他子消息.parentUuid = msg_4.uuid ← 锚定保留段尾部
3. 删除 msg_X, msg_Y(不在保留段内) ← 剪枝
4. msg_3, msg_4 的 assistant 的 token usage 清零 ← 防止虚假 autocompact
恢复后的逻辑链:
... → [boundary → summary] → [msg_3 → msg_4] → [new_msg → ...]
applySnipRemovals
Snip 是一种精确的上下文裁剪操作(按消息 UUID 范围删除)。恢复时:
- 识别被 snip 标记的消息
- 从 Map 中移除它们
- 重新链接受影响的
parentUuid
完整恢复流水线
scss
磁盘上的 JSONL 文件
(按时间追加,无序行)
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
逐行 JSON.parse 分类到各容器 计算 leafUuids
Entry 对象 Map<UUID, Msg> (无子节点的消息 UUID)
│ │ │
└───────────────────┼───────────────────┘
│
▼
findLatestMessage()
选 timestamp 最大的叶子
│
▼
buildConversationChain()
叶子 → parentUuid 回溯 → 根
环检测 + reverse 反转
│
▼
recoverOrphanedParallelToolResults()
恢复并行工具的孤儿结果
插入到正确链位置
│
▼
applyPreservedSegmentRelinks()
重新链接 compact 保留段
清零旧 token usage
│
▼
applySnipRemovals()
删除 snip 标记的消息
│
▼
TranscriptMessage[]
[msg_1, msg_2, ..., msg_N]
从根到叶,完整有序的对话
核心设计思想
单向链表 + HashMap 索引 = 简单而强大的持久化方案
- 写入简单: 只需追加行,无需维护任何 B-tree 或索引结构
- 恢复高效 :
parentUuid指针 + HashMap 实现 O(1) 跳转,从叶子走到根即完成恢复 - 容错性好: 环检测、不完整链自动终止、legacy progress 桥接
- 并行安全 :
recoverOrphanedParallelToolResults修复并行工具调用导致的链断裂 - 内存优化: 大文件场景下的逐块读取、死分支跳过、fd 级过滤
关键源文件索引
| 文件 | 函数 | 作用 |
|---|---|---|
src/utils/sessionStorage.ts:3472 |
loadTranscriptFile() |
解析 JSONL,构建 Map 和元数据 |
src/utils/sessionStorage.ts:3818 |
loadSessionFile() |
定位 session JSONL 文件并调用 loadTranscriptFile |
src/utils/sessionStorage.ts:3842 |
getSessionMessages() |
获取 session 所有消息 UUID(用于去重) |
src/utils/sessionStorage.ts:2046 |
findLatestMessage() |
从叶子中选 timestamp 最新的 |
src/utils/sessionStorage.ts:2069 |
buildConversationChain() |
核心:从叶子沿 parentUuid 回溯到根 |
src/utils/sessionStorage.ts:2118 |
recoverOrphanedParallelToolResults() |
修复并行工具调用的孤儿结果 |
src/utils/sessionStorage.ts:1839 |
applyPreservedSegmentRelinks() |
重新链接 compact 保留段 |
src/utils/sessionStorage.ts:2294 |
loadTranscriptFromFile() |
完整的文件→LogOption 转换 |
src/utils/conversationRecovery.ts:416 |
loadMessagesFromJsonlPath() |
从 JSONL 路径加载消息的入口之一 |
src/types/logs.ts:221 |
TranscriptMessage |
对话消息类型定义(含 parentUuid 字段) |
src/types/logs.ts:297 |
Entry |
所有 JSONL 条目类型的联合类型 |