Claude Code 101|10|Session Resume

Claude Code 的 Resume 不是加载聊天记录,而是从 transcript 中恢复消息、任务、成本、worktree、agent 身份和运行时状态。

Claude Code 101|10|Session Resume

很多产品把“继续对话”理解为把历史消息重新显示出来。对普通聊天应用来说,这基本够用:用户看到上一轮问答,模型拿到若干历史 message,就能继续生成。但 Coding Agent 的 Session Resume 不是这个问题。Claude Code 要恢复的是一个 Agent Runtime:它曾经在哪个项目工作,使用了什么 agent 身份,是否有 TodoWrite 状态,是否发生过上下文压缩,成本统计到哪里,transcript 指针在哪里,历史 worktree 是否仍然存在。

如果只恢复 messages,用户表面上看到了旧对话,系统却失去了运行时位置。后续工具调用可能在错误目录执行,任务列表可能消失,成本统计可能断裂,compact 后的上下文可能被重复展开,fork 会话可能缺少 content replacement 记录。这类问题不会在 demo 里出现,却会在真实长期工作里直接破坏可信度。

这一篇要回答的工程问题是:一个 Coding Agent 如何把过去的 transcript 重放成可继续执行的 runtime state,而不只是把聊天记录重新加载到界面上。

工程问题

Agent 会话与聊天会话的差异在于,Agent 会话包含副作用和中间状态。一次 Claude Code 会话可能已经读写文件、运行测试、创建任务、请求权限、压缩上下文、切换 worktree、启动子 agent、记录成本,并把一部分内容替换为压缩引用。继续这样的会话,必须知道的不只是“用户说过什么”和“助手答过什么”。

Session Resume 至少需要处理五类状态:

  • 对话状态:用户消息、助手消息、工具调用、工具结果、合成消息。
  • 应用状态:任务列表、文件历史、attribution、UI 可展示的运行信息。
  • 运行状态:agent 身份、模型 override、permission 相关上下文、cost state。
  • 工程位置:当前项目、git worktree、跨项目 resume 检查。
  • 压缩状态:context collapse、snapshot、content replacement、transcript pointer。

这些状态来自不同系统。如果恢复流程没有统一的事件源,就会出现“消息恢复了,但 runtime 没恢复”的半恢复状态。Claude Code 的设计核心,是把 transcript 从用户可读日志提升为运行时恢复的事件源。

概念边界

上一篇 Memory 处理的是跨会话长期知识。Session Resume 处理的是具体会话的连续性。Memory 可以被多个 session 共享,Resume 面向一个 session 的事件流。Memory 的目标是抽取稳定事实,Resume 的目标是重建历史状态。

下一篇 Subagents and Swarm 会讨论多 Agent 如何被调度为隔离执行上下文。Resume 是多 Agent 能力的前提之一:如果一个 agent 任务可以后台运行、fork、进入 worktree 或成为 teammate,那么系统就必须能在之后重新找到它的上下文。

Claude Code 这个案例说明:Resume 不是一个 UI 菜单项,而是一条从持久日志到 runtime state 的恢复管线。它要处理 session 存储、状态重建、conversation recovery、跨项目恢复、fork 选择和持久化记录。

机制一:transcript 是事件源

成熟 Agent Runtime 需要在关键事件发生时写入日志,而不是等成功结束后再保存摘要。因为崩溃、取消、网络中断、进程退出都可能发生在任意位置。只有 transcript 具备事件源语义,系统才有机会从中恢复。

Claude Code 的恢复流程不是简单读取一个 messages 数组,而是从 log 中重建 AppState、消息、任务和相关元数据。Resume 的对象不是聊天记录,而是一次 runtime 状态恢复。

这也解释了为什么 Agent 系统通常要在模型调用前就持久化用户输入。用户输入是一个已经发生的事件,不应依赖后续模型成功响应才写入。如果模型调用失败,恢复后仍应该知道用户发起过这一轮请求,并能决定重试、取消或继续。

事件源式 transcript 与普通 chat history 的差异在于:它不仅保存可展示内容,还保存足以重建运行时的结构化事件。工具调用、工具结果、系统合成消息、compact marker、snapshot、agent metadata 都可能参与恢复。

机制二:恢复对象不是 messages,而是 AppState

只恢复 messages 的系统通常会漏掉 Agent 产品最重要的中间状态。例如 TodoWrite 工具维护的任务列表不是普通聊天内容,但它决定用户能否看到当前任务进度;file history 不是模型回答,但它影响“最近修改了哪些文件”的产品体验;cost state 不是对话语义,但它影响用量统计和预算判断。

Claude Code 在 src/utils/sessionRestore.ts 中引入了多类恢复逻辑:cost tracker、attribution state、TodoWrite 状态、上下文 collapse 信息、agent definition、worktree session 等都在恢复边界内。src/state/AppStateStore.tssrc/state/selectors.ts 说明这些状态会被 UI 和 runtime 多处读取。

这带来一个重要架构原则:Session Resume 的输出应该是一个运行时状态包,而不是一个消息数组。

它可以包含:

  • messages:模型可见或 UI 可展示的历史消息。
  • appState:任务、文件、attribution、交互状态。
  • sessionMetadata:session id、transcript pointer、fork 来源。
  • agentContext:agent 名称、颜色、模型、权限模式。
  • workspaceContext:项目路径抽象、worktree 状态、跨项目检查结果。
  • accountingState:成本、token、请求统计。

恢复流程要保证这些对象彼此一致。否则用户看到的会话和 runtime 实际执行的会话会分裂。

机制三:上下文压缩让恢复更复杂

在长会话中,Agent 不可能把所有历史原文都持续放进上下文。Context collapse、snapshot、content replacement 等机制会把一部分历史压缩或替换。恢复时如果忽略这些记录,就会出现两种风险:要么把已经压缩的内容再次展开,导致上下文膨胀;要么丢失替换映射,导致模型看不到必要背景。

在成熟 Agent Runtime 中,context collapse、snapshot、content replacement 和 bridge pointer 都会影响恢复边界。Resume 不能假装历史从未被压缩;它必须尊重当时的上下文替换结果。

这说明 Resume 不是“把历史消息全部读回来”。它需要尊重历史中已经发生的压缩边界。被 compact 的内容应以 compact 后的表示继续参与上下文,而不是回退到未经压缩的无限历史。对于 fork session,这一点更重要:fork 不是复制屏幕文本,而是复制一个带有替换记录和压缩状态的运行时分支。

可以把它理解为 Git 的提交历史与工作区状态:你不能只复制文件内容,还要知道当前 HEAD、index、未提交变更和引用关系。Agent session 也有类似的结构性状态。

机制四:worktree 和项目边界是 Coding Agent 特有问题

聊天产品恢复会话通常不关心“在哪个目录继续”。Coding Agent 必须关心。一次历史会话可能在某个 repo、某个 git worktree、某个分支或某个临时隔离目录中工作。Resume 时如果直接在当前目录继续,可能把后续修改写到错误项目。

Claude Code 这个案例把 same-repo、all-projects、cross-project resume、fork 选择和 worktree session 都纳入恢复边界。也就是说,Resume 恢复的不只是文本历史,还包括“应该在哪个工作区继续”。

这类设计的目的不是“更聪明地找文件”,而是降低错误副作用。Resume 必须回答:历史会话属于哪个项目?当前用户是否在同一项目?历史 worktree 是否还存在?如果不存在,应该清理 stale state、提示用户,还是以降级模式恢复?

代码 Agent 的会话恢复必须把工作区当作状态的一部分。否则所有工具能力都会放大 resume 错误。

机制五:Resume UI 是状态选择器

/resume 看起来像一个 UI 功能,但它承担的是状态选择器职责。用户需要从多个历史 session 中找到正确的那一个,理解它属于哪个项目,判断是否继续原会话,还是 fork 出一个分支。

src/screens/ResumeConversation.tsx 体现了这个产品层复杂度:progressive loading session logs、同项目和跨项目列表、fork session、sessionId 切换、transcript pointer 重置、错误提示等,都不是简单的“列出聊天记录”。它们对应的是不同恢复语义。

继续原会话意味着沿用同一个状态线;fork 会话意味着从历史点复制一份可独立发展的 runtime state;跨项目 resume 意味着用户可能正在当前目录之外恢复历史上下文,需要额外确认和防护。

这说明 Resume UI 不是底层恢复 API 的装饰,而是 Agent Runtime 的安全入口。用户通过它选择状态分支,系统通过它展示恢复风险。

机制六:agent 身份也必须恢复

Claude Code 支持不同 agent definition、model override、standalone agent name / color 等身份信息。继续会话时,如果 agent 身份改变,同样的历史消息可能产生完全不同的行为。一个 code reviewer agent、一个 planner agent、一个 general agent,对工具、风格、权限和输出格式的选择都可能不同。

src/utils/sessionRestore.ts 对 agent definition 和 metadata 的恢复,src/tools/AgentTool/loadAgentsDir.ts 对 agent 定义的加载,以及 src/utils/sessionStorage.ts 中 agent metadata 写入,都说明身份是 session state 的一部分。

这点对多 Agent 更重要。主会话、subagent、teammate 或 background task 都可能有自己的名称、颜色、模型和工具约束。Resume 如果丢失这些身份,用户看到的“同一会话”其实已经换了执行者。

TypeScript-style pseudocode

下面用伪代码抽象 Session Resume 的恢复形状:

type TranscriptEvent =
  | UserInputEvent
  | AssistantMessageEvent
  | ToolUseEvent
  | ToolResultEvent
  | TodoSnapshotEvent
  | ContextCompactEvent
  | AgentMetadataEvent
  | WorktreeEvent
  | CostEvent

type RestoredRuntime = {
  messages: RuntimeMessage[]
  appState: AppState
  agent: AgentIdentity
  workspace: WorkspaceState
  accounting: CostState
  transcriptPointer: TranscriptPointer
}

async function resumeSession(sessionId: string, options: ResumeOptions) {
  const transcript = await readTranscriptLog(sessionId)
  const events = parseTranscriptEvents(transcript)

  const messages = rebuildMessages(events, {
    respectCompaction: true,
    includeSyntheticEvents: true,
  })

  const appState = rebuildAppState(events, {
    restoreTodos: true,
    restoreFileHistory: true,
    restoreAttribution: true,
  })

  const agent = restoreAgentIdentity(events)
  const workspace = await restoreWorkspace(events, {
    currentProject: options.currentProject,
    allowCrossProject: options.allowCrossProject,
  })

  const accounting = restoreCostAndTokenState(events)
  const pointer = computeTranscriptPointer(events, options.forkFrom)

  return {
    messages,
    appState,
    agent,
    workspace,
    accounting,
    transcriptPointer: pointer,
  }
}

async function forkSession(sourceSessionId: string, fromPointer: TranscriptPointer) {
  const restored = await resumeSession(sourceSessionId, { forkFrom: fromPointer })
  const newSessionId = createSessionId()

  await copyContentReplacementRecords(sourceSessionId, newSessionId, fromPointer)
  await writeInitialForkMetadata(newSessionId, restored)

  return { sessionId: newSessionId, runtime: restored }
}

这个伪代码的重点是:恢复结果是 RestoredRuntime,不是 Message[]。只有这样,后续 Agent Loop、Tool Runtime、Permissions、TUI 和 Telemetry 才能在一致状态上继续工作。

工程启发

第一,把 transcript 当事件源设计,而不是把它当日志副产品。事件源不要求每条记录都给用户阅读,但要求它足以让系统恢复关键状态。对 Agent 来说,工具调用、权限结果、压缩边界、任务快照都可能是恢复事件。

第二,恢复流程要有一致性检查。项目是否匹配、worktree 是否存在、agent definition 是否仍可加载、content replacement 记录是否完整,这些都应该在恢复时验证。静默降级会让用户误以为系统已经完整恢复。

第三,fork 和 resume 是不同语义。Resume 是沿着同一条状态线继续;fork 是从历史点创建新分支。二者对 transcript pointer、content replacement、成本统计和 UI 标识的处理不同,不应混用。

第四,不要把 UI 选择视为简单列表。Session Resume 的 UI 是状态导航器。它需要帮助用户理解会话来源、项目归属和分支风险。否则长期会话越多,恢复错误的概率越高。

第五,保存 messages 永远不够。只要 Agent 能执行工具、维护任务、压缩上下文或启动子任务,就必须保存可恢复的 runtime state。否则“继续对话”只是视觉上的连续,工程上已经断裂。

小结

Claude Code 的 Session Resume 是运行时恢复系统,而不是聊天记录加载器。它从 transcript 中恢复 messages、AppState、TodoWrite、cost、agent 身份、worktree、compact 边界和 transcript pointer,并通过 Resume UI 让用户选择继续、跨项目恢复或 fork。

Memory 让长期知识跨会话存在;Session Resume 让具体会话在中断后继续。下一篇进入 Subagents and Swarm:当一个 runtime 不再只有一个执行上下文,而是能启动子 Agent、后台任务、worktree 隔离和 teammate 协作时,恢复与状态隔离会变得更加关键。