# 05 Session 学习记录

## 本节目标

本节目标是理解 Pi 的产品级会话系统：多轮历史如何保存、如何恢复、如何在历史中切分支，以及上下文变长时 compaction 如何介入。

重点问题：

- `Agent`、`AgentSession`、`SessionManager` 各自管什么。
- 为什么 session 文件要保存 `user`、`assistant`、`toolResult`。
- JSONL session 为什么不是普通数组，而是 append-only tree。
- `/tree` 如何回到旧消息继续分支。
- `/tree`、`/fork`、`/clone`、`/new` 的区别。
- 为什么切分支后要 `buildSessionContext()` 并覆盖 `agent.state.messages`。
- compaction 为什么追加 entry，而不是删除旧历史。

## 我们怎么学的

1. 先看 `packages/agent/src/agent.ts`，确认 `Agent` 是运行时状态控制器。
2. 再看 `packages/coding-agent/src/core/session-manager.ts`，确认 session 文件是 append-only JSONL tree。
3. 看 `AgentSession._handleAgentEvent()`，理解消息何时持久化。
4. 通过 `/tree` 实际观察 session tree，理解切回旧 entry 后继续分支。
5. 看 `navigateTree()`，理解 `leafId` 改变后如何重建 agent context。
6. 最后看 `compact()`、`_checkCompaction()`、`buildSessionContext()` 中 compaction 的处理。

## 三层职责

05 的第一条主线是三层职责：

```text
Agent = 当前运行时状态
SessionManager = 持久化历史和分支树
AgentSession = 产品层会话控制器，把两者接起来
```

### Agent

`Agent` 是内存里的运行控制器。

它持有：

- 当前上下文 `messages/tools/model`。
- 当前是否在跑 `activeRun`。
- 运行中插队消息 `steering/followUp`。
- 事件监听 `listeners`。
- abort、continue、prompt 等运行控制。

真正执行模型和工具循环时，`Agent` 会把这些状态打包成 context snapshot 和 loop config，交给 `runAgentLoop()`。

### SessionManager

`SessionManager` 管 session 文件和历史树。

它持有：

- 当前 session id/file/dir。
- `fileEntries`：JSONL 中的完整历史。
- `byId`：entry id 索引。
- `leafId`：当前分支末端。
- label、session name、parent session 等元数据。

它提供：

- `appendMessage()`。
- `appendCompaction()`。
- `branch()` / `resetLeaf()` / `branchWithSummary()`。
- `getBranch()`。
- `buildSessionContext()`。

### AgentSession

`AgentSession` 是产品层会话控制器。

它负责把 agent 事件、session 持久化、extensions、compaction、UI/RPC 行为串起来。

关键点是 `message_end` 时持久化：

```text
event.type === "message_end"
  user / assistant / toolResult -> appendMessage()
  custom -> appendCustomMessageEntry()
```

所以 session 不是保存每一个运行时事件，而是保存可恢复上下文需要的最终消息和 entry。

## 为什么保存 toolResult

session 必须保存三类核心消息：

```text
user        用户意图
assistant   模型行为，包括文本和 tool call
toolResult  外部世界返回给模型的信息
```

如果只保存 `user` 和 `assistant`，恢复后会缺少工具观察结果。

例如：

```text
user: 看一下 a.ts
assistant: toolCall read(a.ts)
toolResult: a.ts 内容...
assistant: 根据文件内容回答
```

如果 `toolResult` 没保存，resume 后模型只知道它曾经调用了 `read(a.ts)`，但不知道工具返回了什么。

更严重的是，provider 协议通常要求 assistant tool call 和 tool result 稳定配对：

```text
assistant.toolCallId
toolResult.toolCallId
```

缺少 tool result 不只是上下文少了内容，还可能让 provider payload 不合法。

一句话：

```text
assistant 负责“模型决定调用什么工具”；
toolResult 负责“工具实际返回了什么”。
```

## append-only session tree

session 是 append-only tree，不是简单数组。

核心字段：

```text
id        当前 entry 自己的 id
parentId  它从哪个 entry 接出来
leafId    当前会话所在的末端
```

三者不要混在一起：

```text
fileEntries
= JSONL 文件里的完整 append-only entry 列表

parentId
= 单条 entry 指向上一条 entry 的边

leafId
= 当前选中的分支末端，不代表文件里只有这一条路径
```

每次追加消息时：

```text
entry.parentId = this.leafId
append entry
this.leafId = entry.id
```

线性对话是：

```text
A -> B -> C -> D
leafId = D
```

如果回到 B 分支，`branch(B)` 不删除 C/D，只是把 `leafId` 移到 B。

下一条新消息 E 会变成：

```text
A -> B -> C -> D
     \
      E
```

所以：

```text
fileEntries = 所有历史 entry 的追加日志
leafId = 当前选中的对话路径末端
getBranch() = 从 leafId 往 parentId 反向走回 root
buildSessionContext() = 把当前 branch 转成 LLM messages
```

Mermaid 源码：

```mermaid
flowchart TD
  A[user A] --> B[assistant B]
  B --> C[user C]
  C --> D[assistant D]
  B --> E[user E]
  E --> F[assistant F]
```

## /tree 怎么切回旧历史

交互模式里用：

```text
/tree
```

流程：

```text
/tree
-> showTreeSelector()
-> session.navigateTree(entryId)
-> sessionManager.branch(newLeafId)
-> sessionManager.buildSessionContext()
-> agent.state.messages = sessionContext.messages
-> UI 重新 renderInitialMessages()
```

如果选中的是 assistant message：

```text
leaf = selected assistant
```

新问题会接在该 assistant 后面。

如果选中的是 user message：

```text
leaf = user message 的 parent
editorText = 这条 user message 的文本
```

也就是 Pi 会把这条 user prompt 放回输入框，让你可以修改后重跑。

例如：

```text
US:A
PI:B
US:C
PI:D
US:E
PI:F
```

选中 `US:C` 后，会回到 `PI:B` 后面，并把 `C` 放回编辑器。

差异可以这样记：

```text
选 assistant
= 从这个回答后面继续

选 user
= 回到这条问题之前，把原问题放回编辑器，修改后重跑
```

Mermaid 源码：

```mermaid
flowchart TD
  B[assistant B] --> C[user C]
  C --> D[assistant D]
  B --> E[editorText = C, leafId = B]
  E --> R[修改后重新 prompt]
```

## session tree 手动画图练习

题目：

```text
起点：
A -> B -> C -> D
leafId = D

操作：
/tree 选中 B
输入 E
```

答案：

```text
A -> B -> C -> D
     \
      E

leafId = E
fileEntries = A/B/C/D/E 全部保留
```

检查点：

- `E.parentId = B`，因为输入 E 前已经把 `leafId` 移到 B。
- C/D 没有删除，因为 `fileEntries` 是完整事实历史。
- 当前模型上下文不是 `fileEntries` 全量，而是从 `leafId` 回溯出来的 branch。

## /tree 和 /fork /clone /new

可以先记成两类：

```text
当前 session 文件内导航
创建或切换 session 文件
```

### /tree

```text
同一个 JSONL 文件里切分支
不删除旧 entry
只移动 leafId
重建 agent.state.messages
```

### /fork

```text
从某个 user message 复制当前路径
创建一个新的 session 文件
```

### /clone

```text
从当前 leaf 复制当前路径
创建一个新的 session 文件
```

### /new

```text
创建新的空 session
```

## 为什么 /tree 后必须 rebuild context

Pi 里有两份状态：

```text
SessionManager.leafId
= 当前历史路径指针

agent.state.messages
= 下一次 prompt 真正发给模型的内存上下文
```

`/tree` 只改 `leafId` 还不够。

如果不重新：

```text
buildSessionContext()
agent.state.messages = sessionContext.messages
```

就会出现错位：

```text
sessionManager.leafId = 三轮前
agent.state.messages = 仍然是五轮完整对话
```

下一次新问题持久化会接到新分支，但模型推理却基于旧分支。

这会导致：

```text
文件树上：从 B 分支
模型脑子里：还以为在 E 后继续
```

所以 `/tree` 后必须做双同步：

```text
SessionManager 同步当前 branch
Agent 同步当前 LLM context
UI 同步当前显示
```

## compaction

Compaction 的核心不是删除历史，而是：

```text
在 session tree 上追加一个 compaction entry
然后 buildSessionContext() 改变给模型看的 messages
```

手动 `/compact` 主线：

```text
compact()
  -> abort 当前 agent 操作
  -> getBranch()
  -> prepareCompaction()
  -> 生成 summary
  -> appendCompaction(summary, firstKeptEntryId, tokensBefore)
  -> buildSessionContext()
  -> agent.state.messages = sessionContext.messages
```

`buildSessionContext()` 看到 compaction 后，会这样构造 messages：

```text
1. 先放 compaction summary message
2. 再放 firstKeptEntryId 之后、compaction 之前的 kept messages
3. 再放 compaction 之后的新 messages
```

所以压缩后，模型看到的是：

```text
[compaction summary]
[最近保留的一段原始消息]
[压缩之后的新消息]
```

原始 session entry 仍然保存在 JSONL 文件里。

Mermaid 源码：

```mermaid
flowchart LR
  H[完整历史 entries] --> C[append compaction entry]
  C --> V[buildSessionContext]
  V --> M[模型看到 summary + kept messages + new messages]
  H --> K[原始 JSONL 历史仍保留]
```

## 自动 compaction

自动 compaction 有两个触发：

```text
overflow
= 模型返回上下文溢出错误
-> compact
-> 自动 retry

threshold
= 上下文快满了
-> compact
-> 不自动 retry，等用户继续
```

threshold 判断的核心是：

```ts
contextTokens > contextWindow - settings.reserveTokens
```

也就是给下一次模型调用预留一部分 tokens。

## 为什么 compaction 不删除旧 entries

因为 session 是 append-only 历史树，compaction 只是改变“发给模型的上下文视图”，不应该破坏原始历史。

直接删除旧 entries 会带来问题：

- 分支会坏，其他 branch 可能还引用旧 entry。
- 压缩摘要是有损的，删除原始记录会影响恢复、审计、debug。
- compaction 是上下文策略，不是存储策略。
- append-only 更安全，不需要重写 JSONL 旧行。
- 支持多次 compaction，每次只是新增一个上下文边界。

可以分清三种历史：

```text
fileEntries
= 完整事实历史，append-only

branch path
= 当前 leaf 对应的路径

Context.messages
= 当前这次要发给模型的压缩视图
```

compaction 只影响第三个，最多通过 compaction entry 标记第二个；不破坏第一个。

## 和 Codex auto compact 的对照

我们补充看了 Codex 的 auto compact 资料和源码：

- 页面资料：`https://auto-compact-explainer.pages.dev/`
- Codex 源码：`openai/codex` 里的 `codex-rs/core/src/compact.rs`
- Codex 远端压缩源码：`codex-rs/core/src/compact_remote.rs`
- Codex 官方 manual：`/compact` 用于总结可见对话、释放 tokens；长任务中 Codex 也可能自动 compact。

从公开源码看，Codex 有两条 compact 路径：

```text
inline/local compact
= 用普通模型请求生成摘要

remote compact
= 通过支持 remote compaction 的 provider 调专门 compact endpoint
```

### Codex inline compact

inline compact 的思路接近传统总结：

```text
history
-> compact prompt
-> 普通 Responses streaming 请求
-> 生成 summary
-> build_compacted_history()
-> replace_compacted_history()
```

如果 compact 请求本身超过上下文，源码里会从历史开头移除最旧 item 后重试：

```text
ContextWindowExceeded
-> history.remove_first_item()
-> retry
```

它的结果更接近：

```text
最近真实用户消息 + SUMMARY_PREFIX 摘要
```

### Codex remote compact

remote compact 更特殊。它不是简单返回一段 summary text，而是返回一份新的 replacement transcript。

流程可以理解成：

```text
history + tools + base instructions + personality + reasoning settings
-> model_client.compact_conversation_history(...)
-> 服务端返回 Vec<ResponseItem>
-> process_compacted_history()
-> replace_compacted_history()
```

本地客户端还会过滤 remote 输出：

- 丢掉 stale/duplicated developer messages。
- 丢掉非真实 user wrapper。
- 丢掉推理、工具调用、工具输出等噪声 item。
- 保留真实 user message、assistant message、compaction/context compaction item。

所以 remote compact 的产物不是：

```text
一段摘要字符串
```

而是：

```text
压缩后的结构化 history / replacement transcript
```

这说明 Codex 的 remote compact 背后服务端策略是不透明的：本地源码能看到调用、过滤、安装逻辑，但看不到服务端具体模型、prompt 和摘要算法。

### 触发与阶段

`auto-compact-explainer` 页面里总结的关键点：

```text
默认触发：context window 的 90%
触发阶段：pre-turn + mid-turn
执行路径：本地摘要或远端 compact
最终效果：替换 history 并重算 token
```

其中 `model_auto_compact_token_limit` 可以控制触发阈值，但仍会被 context window 的 90% 上限限制。

mid-turn compact 需要特殊处理，因为它发生在工具循环中，模型后面还要继续工作。Codex 会把 initial context 插回到模型期望的位置，避免压缩后丢掉当前环境基线。

### Pi 和 Codex 的核心差异

Pi 的 compact 是：

```text
保留完整 session entries
追加 compaction entry
buildSessionContext() 动态构造压缩视图
```

Codex 的 compact 更像：

```text
生成或请求一份 replacement_history
replace_compacted_history() 安装为新的 live history
```

对比：

```text
Pi
= append-only 历史树 + compaction summary 边界 + 当前 Context.messages 视图重写

Codex inline
= summary + compacted replacement history

Codex remote
= 服务端返回结构化 replacement transcript
```

所以 Pi 更像“版本化历史 + 压缩视图”：

```text
旧历史仍在 JSONL
当前发给模型的是 summary + kept recent messages + new messages
```

Codex 更像“当前工作上下文重写”：

```text
active history 被替换成 compacted replacement_history
```

这也解释了两者设计取向不同：

- Pi 的实现更透明，适合学习和审计。
- Pi 的 append-only tree 对 `/tree`、分支、恢复更友好。
- Codex remote compact 更强，但服务端策略闭源。
- Codex remote compact 不只是总结文本，而是可以重写整个对话结构。

## 本节完成度

本节已经完成：

- 理解 `Agent`、`AgentSession`、`SessionManager` 的职责边界。
- 理解为什么 session 必须保存 `toolResult`。
- 理解 append-only session tree。
- 理解 `/tree` 如何从旧历史长出新分支。
- 理解 `/tree`、`/fork`、`/clone`、`/new` 的区别。
- 理解为什么切分支后必须 `buildSessionContext()`。
- 理解 compaction 是上下文视图重写，不是删除历史。

下一节建议进入扩展系统：

```text
05 看 pi 如何保存和恢复长期会话；
06 看 pi 如何把专用能力装进 skills、commands、templates 和 extensions。
```
