Book 第二十五章:Transcript 与 Resume 为什么是会话骨架
第五部分:会话续航与治理

第二十五章:Transcript 与 Resume 为什么是会话骨架

把 transcript、resume、rewind 看成 append-only session journal 和恢复管线。

1. 为什么这一块必须单独拆

如果你把 Claude Code 当成普通 CLI,很容易下意识地以为它的“会话保存”就是:

  • 把消息数组写到磁盘
  • 下次启动再读回来

但源码真正做的事情远不止这个。

Claude Code 的 transcript 本质上更像一份:

带 parent 链、带 typed metadata、带 compact 边界、带运行时快照的 append-only 会话事件日志。

而 resume 做的也不是“还原聊天记录”,而是:

从日志里重建一份还能继续执行的 runtime。

这块对你以后做 C# 版非常重要,因为它直接影响:

  • TranscriptStore 怎么设计
  • SessionResumePipeline 怎么拆
  • file history / worktree / context collapse / content replacement 放哪一层
  • --continue--resume--fork-session 三种语义怎么分开

相关源码锚点:

  • src/utils/sessionStorage.ts
  • src/utils/sessionStoragePortable.ts
  • src/utils/conversationRecovery.ts
  • src/utils/sessionRestore.ts
  • src/main.tsx
  • src/screens/ResumeConversation.tsx
  • src/utils/fileHistory.ts

2. 先说结论

我对这套设计的判断是:

Claude Code 把 session persistence 设计成了“三层分工”。

  1. sessionStorage.ts 负责写入协议和底层读取协议。
  2. conversationRecovery.ts 负责把磁盘上的 transcript 变成“可恢复的对话链”。
  3. sessionRestore.ts 负责把对话链重新接回当前 runtime。

这三层分得很值钱,因为它避免了一个常见坏味道:

既不把 resume 逻辑全塞进存储层,也不把存储协议散落到 UI 或启动层。

3. 总体结构图

flowchart TD
    A["运行中的消息 / 元数据 / 快照"] --> B["sessionStorage.ts"]
    B --> C["JSONL transcript<br/>append-only"]

    C --> D["loadTranscriptFile()"]
    D --> E["按 parentUuid 重建链<br/>恢复 typed metadata<br/>过滤 compact 前脏数据"]

    E --> F["conversationRecovery.ts"]
    F --> G["反序列化 + 清洗 + interruption 检测"]
    G --> H["loadConversationForResume()"]

    H --> I["sessionRestore.ts"]
    I --> J["switchSession / adoptResumedSessionFile"]
    I --> K["restoreSessionMetadata"]
    I --> L["restoreWorktreeForResume"]
    I --> M["restore context collapse"]
    I --> N["restore fileHistory / attribution / todos / agent"]

    J --> O["REPL / QueryEngine 继续跑"]

这张图里最关键的一点是:

Claude Code 持久化的是“可重建信息”,不是某个单一的内存对象快照。

4. Transcript 不是单一消息列表,而是混合型事件流

源码锚点:

  • src/utils/sessionStorage.ts:1408
  • src/utils/sessionStorage.ts:3472

recordTranscript()loadTranscriptFile() 看,Claude Code 的 transcript 里至少有三类东西:

4.1 主链消息

也就是用户、assistant、system、attachment 这些真正参与对话链重建的 message。

它们通过:

  • uuid
  • parentUuid
  • timestamp

串成一条或多条链。

4.2 typed metadata

比如:

  • custom-title
  • tag
  • agent-name
  • agent-color
  • agent-setting
  • mode
  • worktree-state
  • pr-link
  • file-history-snapshot
  • attribution-snapshot
  • content-replacement
  • marble-origami-commit
  • marble-origami-snapshot

也就是说,transcript 里写的不是只有“对话内容”,还有大量 runtime side data。

4.3 resume 专用辅助检查点

最典型的是:

  • compact boundary
  • turn duration checkpoint
  • leaf / parent chain 推导

这些东西不是为了 UI 显示,而是为了保证以后 resume 时能重建得对。

5. 写入协议的核心不是“追加消息”,而是“维护链完整性”

源码锚点:

  • src/utils/sessionStorage.ts:1408

recordTranscript() 最值钱的地方在于,它不是无脑 append。

它会先做几件事:

  1. 清理不适合记录的消息
  2. messageSet 去重
  3. 判断“已记录消息是不是当前 slice 的 prefix”
  4. 通过 startingParentUuid 把新写入片段正确挂回原链

这里有个很重要的设计细节:

已记录消息只有在形成前缀时,才会被拿来更新 parent hint。

原因是 compact 之后会出现一种特殊情况:

  • 前面先写入新的 compact boundary / summary
  • 后面再带一段旧消息

如果还把那段旧消息当“前缀”,就会把新 boundary 错挂到旧链上,直接破坏 --continue / --resume 的语义。

这说明 Claude Code 的 transcript 写入器,本质上是一个链维护器。

6. 它持久化的不只是消息,还持久化“能帮助恢复的旁路状态”

源码锚点:

  • src/utils/sessionStorage.ts:1476
  • src/utils/sessionStorage.ts:1538
  • src/utils/sessionStorage.ts:2884
  • src/utils/fileHistory.ts

Claude Code 在一轮会话里,除了消息本身,还会持续往 transcript 或相关 sidecar 里打这些东西:

6.1 file history snapshots

文件修改历史不是只活在内存里。

fileHistory.ts 会在关键节点调用 recordFileHistorySnapshot(),把:

  • 某个 message 对应的 snapshot
  • snapshot update

记录下来。

这样 resume 以后还能继续支持:

  • rewind
  • 文件状态回溯
  • turn 级文件变更理解

6.2 content replacements

这是 Claude Code 特别有意思的一层。

工具结果正文太大时,系统可能会把真实内容外置存储,只在 transcript 里留 replacement 记录。resume 时如果没有这层记录,后面再读到原来的 tool_use_id 就会找不到对应替换关系,导致:

  • prompt cache 命中变差
  • 结果被当成 frozen full content 回灌
  • 上下文体积失控

6.3 worktree state

saveWorktreeState() 会把当前 session 的 worktree 信息写回 transcript 元数据。

这样 --resume 不只是能恢复“对话内容”,还知道:

  • 当时是不是在 worktree 里
  • worktree 路径是什么
  • 原始 cwd 是什么

6.4 context collapse 提交和快照

Claude Code 把 context collapse 的 commit log 和 staged snapshot 也写进 transcript。

这说明 transcript 已经不是“聊天历史”,而是一个更广义的 session journal。

7. 读取协议最核心的工作,是“少读错读”

源码锚点:

  • src/utils/sessionStorage.ts:3472
  • src/utils/sessionStoragePortable.ts:469

loadTranscriptFile() 不是简单 readFile + JSON.parse(lines)

它首先解决的是两个大问题:

  1. 大 transcript 不能把整份历史全吃进内存
  2. compact 之前的大量历史,其实很多已经不该再参与 resume

所以它做了几件特别有价值的事情。

7.1 对大文件走 chunked forward read

sessionStoragePortable.ts 里有一套专门的 forward chunked read 逻辑。

目的不是炫技,而是为了:

  • 跳过无价值的大块历史
  • 跳过 attribution snapshot 这种没必要全部进内存的内容
  • 把峰值内存占用控制在“有效输出大小”附近,而不是“整个文件大小”

7.2 利用 compact boundary 做 pre-boundary skip

当 transcript 足够大时,Claude Code 会定位 compact boundary,只保留 boundary 之后真正还活着的部分。

但它没有粗暴地把 boundary 前面的东西全扔掉,因为那里可能还有 session-scoped metadata。

所以它会:

  • 截断大块历史正文
  • 额外扫描 pre-boundary metadata lines
  • 把 title、tag、mode、agent setting、worktree state 这些元数据补回来

这一步特别值钱,因为它说明作者明确区分了:

  • 需要参与对话重建的消息历史
  • 只需要补回会话属性的元数据

8. loadTranscriptFile() 本质上是在做“事件归档投影”

从返回值就能看出来,它不是只返回 messages,而是返回:

  • messages
  • summaries
  • customTitles
  • tags
  • agentNames
  • agentColors
  • agentSettings
  • modes
  • worktreeStates
  • fileHistorySnapshots
  • attributionSnapshots
  • contentReplacements
  • contextCollapseCommits
  • contextCollapseSnapshot
  • leafUuids

也就是说,这个函数做的不是“载入 transcript 文件”,而是:

把 append-only 事件流投影成一组可恢复索引。

这对 C# 迁移很关键,因为你以后应该把这块建模成:

  • TranscriptLoader
  • SessionProjection

而不是一个返回 List<Message> 的工具函数。

9. Resume 时真正使用的是“链”,不是“文件顺序”

源码锚点:

  • src/utils/conversationRecovery.ts:407
  • src/utils/sessionStorage.ts:3869

无论是 getLastSessionLog(),还是 loadMessagesFromJsonlPath(),最终都不是按文件顺序直接拿消息。

它们做的都是:

  1. 找 leaf
  2. 从 leaf 沿 parentUuid 往回走
  3. buildConversationChain()
  4. 再把链转成可恢复会话

这说明 Claude Code 的 transcript 逻辑语义不是“按写入顺序展示”,而是“按 parent graph 重建主会话链”。

这能解释很多设计:

  • sidechain 不会干扰主链 resume
  • 并行工具或异步消息不一定按物理顺序决定主链
  • compact 之后还能保证逻辑链不断

10. conversationRecovery.ts 负责把“磁盘链”变成“可继续的对话”

源码锚点:

  • src/utils/conversationRecovery.ts:443
  • src/utils/conversationRecovery.ts:150

loadConversationForResume() 是 resume 链路里非常关键的一层。

它做了这些事:

10.1 支持多种恢复入口

  • undefined--continue,取最近会话
  • sessionId:直接按 ID 恢复
  • LogOption:已经选好的会话
  • sourceJsonlFile:直接按外部 .jsonl 路径恢复

10.2 处理 lite log 和 full log

resume picker 里很多日志是 lite 的,需要在真正恢复时再补 full log。

10.3 恢复 plan / file history 等旁路状态

例如:

  • copyPlanForResume
  • copyFileHistoryForResume

10.4 恢复 skills 相关状态

resume 前会先从消息里恢复 invoked skills,避免 compact 之后技能状态丢失。

10.5 反序列化并清洗消息

deserializeMessagesWithInterruptDetection() 会做几件很关键的事:

  • 迁移 legacy attachment type
  • 去掉无效 permissionMode
  • 过滤 unresolved tool uses
  • 过滤 orphaned thinking-only messages
  • 过滤 whitespace-only assistant messages
  • 检测 turn interruption
  • 必要时补一条 continuation meta user message
  • 在尾部补一个 synthetic assistant sentinel,让 API 语义合法

这一层特别值钱,因为它说明 Claude Code 不相信“磁盘上的 transcript 一定可直接重放”。

它默认认为:

持久化历史在恢复时还要再做一次纠偏。

11. Resume 最难的地方不是读消息,而是恢复运行时副状态

源码锚点:

  • src/utils/sessionRestore.ts

sessionRestore.ts 的职责非常清楚:

把 resume load result 接回当前进程的 runtime。

它主要做几类恢复。

11.1 session 身份恢复

processResumedConversation() 里,如果不是 --fork-session,会:

  • switchSession()
  • renameRecordingForSession()
  • resetSessionFilePointer()
  • restoreCostStateForSession()

这说明 Claude Code 恢复的不是“一个过去的会话副本”,而是让当前进程重新接管那个 session identity。

11.2 文件指针重新接管

adoptResumedSessionFile() 很关键。

如果只切了 sessionId,但不把当前 sessionFile 指针接管回来,后面退出时:

  • metadata re-append 可能写不到正确文件
  • 新 title / mode / agent 信息可能掉地上

这是一种很典型的工程细节:resume 不只是“把内存切到旧 session”,还要把后续写路径也切过去。

11.3 worktree 恢复

restoreWorktreeForResume() 会尝试:

  • 回到当时 worktree 的 cwd
  • 恢复 worktree session
  • 清理 memory / system prompt caches

但如果 worktree 已经没了,它不会硬炸,而是把 stale 状态覆盖掉,避免下次又把坏路径写回 transcript。

这很稳。

11.4 context collapse 恢复

无论是 CLI --resume,还是交互式 picker resume,都会调用:

  • restoreFromEntries(contextCollapseCommits, contextCollapseSnapshot)

这说明 context collapse 已经是 resume 语义的一等组成部分,而不是可选附属功能。

11.5 file history / attribution / todos 恢复

restoreSessionStateFromLog() 会把:

  • file history
  • attribution
  • TodoWrite state

重新接回 AppState

12. --fork-session 不是普通 resume,而是“继承对话,不继承身份”

这是 Claude Code 很有价值的一个语义分层。

processResumedConversation() 可以明确看出来:

  • 普通 resume:复用旧 session ID
  • fork session:保留当前新 session ID,只导入旧对话内容

同时 fork 路径还会特别处理:

  • content replacements 要提前 seed 到新 session
  • worktree ownership 不继承

这说明作者很清楚“历史继承”和“会话所有权继承”不是一回事。

这对 C# 非常值得照抄,因为很多 agent 产品最后都会需要:

  • continue current session
  • open historical session read-write
  • fork a historical session as a new branch

Claude Code 这里已经把这三者语义分开了。

13. Resume consistency 监控特别有工程价值

源码锚点:

  • src/utils/sessionStorage.ts:2211

checkResumeConsistency() 是我觉得这块最能体现工程成熟度的一个点。

它会拿:

  • in-session checkpoint 记录的 messageCount
  • resume 后重建出来的 actual chain position

做一次 delta 对比,然后打点。

这解决的不是功能问题,而是“写入路径和恢复路径慢慢漂了但没人发现”的问题。

也就是:

  • in-memory 看起来是 397K
  • resume 后链重建出来变成 1.65M

这种问题如果没有 consistency telemetry,线上很难抓。

所以这条设计特别值得记住:

只要系统同时有“在线投影”和“离线重建”,就应该监控二者是否一致。

14. Claude Code 在这条链上做了哪些额外处理

这是这份源码最值钱的一批工程细节。

14.1 transcript 物理顺序不等于恢复逻辑顺序

恢复时始终按 parent chain 重建。

14.2 compact 不是简单删历史

它引入了 boundary、preserved segment、metadata scan、resume filtering 一整套机制。

14.3 历史记录并不被默认信任

resume 前还要清洗 unresolved tool uses、orphaned thinking blocks、legacy attachment type。

14.4 transcript 里存的是对话 + session 元数据 + 恢复快照

这让单文件 JSONL 能承担更多职责。

14.5 fork resume 语义被显式区分

不是偷偷复制一份消息数组,而是整个写路径和 ownership 都重新划定。

14.6 worktree / content replacement / context collapse 都被纳入恢复协议

这意味着作者从一开始就把 resume 当 runtime feature,而不是 UI convenience feature。

15. 对 C# 版最有价值的迁移建议

如果把这套设计翻成 C#,我建议至少拆成下面几层。

15.1 Transcript 存储层

public interface ITranscriptStore
{
    Task AppendMessagesAsync(
        IReadOnlyList<TranscriptMessage> messages,
        TranscriptAppendOptions options,
        CancellationToken cancellationToken);

    Task AppendMetadataAsync(
        TranscriptMetadataEntry entry,
        CancellationToken cancellationToken);

    Task<TranscriptProjection> LoadProjectionAsync(
        string transcriptPath,
        TranscriptLoadOptions options,
        CancellationToken cancellationToken);
}

15.2 恢复编排层

public interface ISessionResumeLoader
{
    Task<ResumeLoadResult?> LoadAsync(
        ResumeSource source,
        CancellationToken cancellationToken);
}

public interface ISessionRestorePipeline
{
    Task<ProcessedResume> RestoreAsync(
        ResumeLoadResult result,
        ResumeOptions options,
        CancellationToken cancellationToken);
}

15.3 关键建模建议

  • transcript 存储层只负责“事件读取与投影”
  • conversation recovery 层负责“消息清洗与链修复”
  • restore 层负责“接回当前进程 runtime”
  • 不要把 file history / worktree / context collapse 混进单纯的消息模型里
  • fork session 必须是独立语义,不要偷懒复用普通 resume

16. 这一块的最终价值

如果只从功能上看,Claude Code 的 resume 好像只是“继续上次会话”。

但从设计上看,它真正做成的是:

  • 一种 append-only 会话日志协议
  • 一种 parent-chain 驱动的链重建机制
  • 一套把副状态重新接回 runtime 的恢复流水线

这三层加在一起,才是 Claude Code 能做长生命周期 agent 会话的基础。

所以对 C# 版来说,这篇的核心结论其实很简单:

先别急着抄消息模型,先把 session journal、resume projection、restore pipeline 立住。

这一步立住了,后面的 context pressure、background agent、remote host、forked conversation 才会有稳的地基。