# 会话与消息模型

> Session、消息与 12 种 Part 的结构，ID 生成规则，会话状态与待办，以及它们如何经事件落进 SQLite。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.org/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/opencode/session-model
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

读会话引擎，先把数据模型装进脑子：一个 **Session** 包含一串 **Message**，每条消息由若干 **Part** 组成。循环、处理器、压缩、撤销，都在这三层结构上做增删改。

## 类型住在哪里

V1 的类型定义在 `packages/schema/src/v1/session.ts`，由 `packages/core/src/v1/session.ts` 以 `SessionV1` 之名重导出；`packages/opencode/src/session/` 下的 `session.ts`、`message-v2.ts` 负责服务与读写。别被 `message-v2` 这个文件名误导：它就是现役 V1 的消息层，与[V2 会话内核](https://daiw.org/manual/opencode/session-v2)无关。同目录的 `message.ts` 是更早的旧格式，只剩 `packages/web` 的分享页还以类型方式导入它。

## Session：会话头

`Session.Info`（`packages/opencode/src/session/session.ts:224`）的字段可以分成四组：

| 分组 | 字段 | 说明 |
| --- | --- | --- |
| 身份 | `id`、`slug`、`projectID`、`workspaceID`、`directory`、`path` | `path` 是相对 worktree 的子目录 |
| 谱系 | `parentID`、`title`、`version` | 子代理会话带 `parentID`；默认标题形如 `New session - <ISO 时间>` |
| 运行 | `agent`、`model`、`permission`、`revert` | 当前 agent 与模型、会话级权限规则、[撤销](https://daiw.org/manual/opencode/snapshot-revert)边界 |
| 统计 | `cost`、`tokens`、`summary`、`time` | `time` 含 `created`、`updated`、`compacting`、`archived` |

`fork`（`session.ts:691`）复制指定消息之前的历史，给消息与 Part 换新 ID 并改写 `parentID`、`tail_start_id` 等引用，标题追加 `(fork #N)`。

## Message：user 与 assistant

消息是按 `role` 判别的联合类型。User 消息（`packages/schema/src/v1/session.ts:332`）记录“这一轮用什么跑”：`agent`、`model`（`providerID`、`modelID`、`variant`）、可选的 `system`（追加进系统提示词）、`format`（`text` 或 `json_schema` 结构化输出）、不再推荐的 `tools`，以及记录文件差异的 `summary`。

Assistant 消息（同文件 `:453`）记录“这一轮跑出了什么”：`parentID` 指向触发它的 user 消息，另有模型、`agent`、`path`、`cost`、`tokens`（含 `reasoning` 与 `cache` 读写）、`finish`（结束原因）、`error`、`structured` 与布尔值 `summary`。

<Callout type="info">
同名字段 `summary` 在两处含义完全不同：user 消息上是差异统计对象，assistant 消息上是布尔标记，表示这条消息是[上下文压缩](https://daiw.org/manual/opencode/compaction)生成的摘要。
</Callout>

`error` 是按 `name` 判别的联合，共 8 种，如 `APIError`、`MessageAbortedError`、`ContextOverflowError`。其中 `ContextOverflowError` 在默认配置下不记为消息错误，而是被处理器转去压缩。

## Part：12 种片段

消息的内容全部是 Part，`packages/schema/src/v1/session.ts:357` 的联合类型共 12 种，其中 10 种如下：

| type | 关键字段 | 来源 |
| --- | --- | --- |
| `text` | `text`、`synthetic`、`ignored` | 用户输入、模型输出、系统注入 |
| `reasoning` | `text`、`metadata`（签名等） | 模型思考过程 |
| `tool` | `tool`、`callID`、`state` | 工具调用，见下文状态机 |
| `file` | `mime`、`url`、`source` | 用户附件与 @ 引用的文件 |
| `agent` | `name`、`source` | 输入里的 @agent |
| `subtask` | `prompt`、`agent`、`command` | 以子代理运行的斜杠命令 |
| `step-start` | `snapshot` | 每个模型步开始 |
| `step-finish` | `reason`、`snapshot`、`cost`、`tokens` | 每个模型步结束 |
| `patch` | `hash`、`files` | 本步改动过的文件 |
| `compaction` | `auto`、`overflow`、`tail_start_id` | 压缩请求 |

`synthetic` 标记“系统代写”的文本，比如 @ 文件展开出的“Called the Read tool…”。它照样发给模型，只在判断“是否真有用户发言”（如生成标题）时被排除；`ignored` 才表示不发给模型。

另两种 `snapshot` 与 `retry` 仍在 schema 中，但各包 `src` 下找不到创建它们的代码：快照哈希如今挂在 `step-start` / `step-finish` 上，重试进度改走会话状态。

### 工具调用的状态机

`tool` Part 的 `state` 按 `status` 判别，单向推进：

```mermaid
stateDiagram-v2
  [*] --> pending: tool-input-start
  pending --> running: tool-call 带完整参数
  running --> completed: tool-result
  running --> error: tool-error 或权限被拒
  pending --> error: 中断收尾
  completed --> [*]
  error --> [*]
```

`pending` 下 `input` 为空、`raw` 为空串（增量参数并不回写）；`running` 后有了参数与 `time.start`，工具可通过 `metadata()` 回调刷新 `title` 与 `metadata`；`completed` 带 `output`、`attachments` 与 `time.end`。`time.compacted` 表示输出已被[修剪](https://daiw.org/manual/opencode/compaction)，`metadata.interrupted` 表示调用被中断收尾，见[流处理器](https://daiw.org/manual/opencode/processor)。

## ID：前缀 + 时间 + 随机

ID 都是“前缀 + 下划线 + 26 个字符”：`ses`、`msg`、`prt`、`per`（权限）、`que`（提问）等。生成逻辑在 `packages/opencode/src/id/id.ts:51`：

```ts
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
  const currentTimestamp = timestamp ?? Date.now()

  if (currentTimestamp !== lastTimestamp) {
    lastTimestamp = currentTimestamp
    counter = 0
  }
  counter++

  let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)

  now = direction === "descending" ? ~now : now

  const timeBytes = Buffer.alloc(6)
  for (let i = 0; i < 6; i++) {
    timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
  }

  return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
```

前 12 位十六进制编码“毫秒 × 4096 + 计数器”，后 14 位是 base62 随机数。`ascending` 让字典序等于创建顺序，Part 只需按 `id` 排序就能还原次序；会话 ID 用 `descending`（按位取反），越新字典序越小。`packages/schema/src/identifier.ts` 有一份同算法实现，供 schema 层使用。

<Callout type="warn">
按代码计算，6 字节只容纳 48 位，而当前的“毫秒 × 4096”已超过 2 的 52 次方，前缀实际是低 48 位，约每 795 天回绕一次。好在消息分页以 `time_created` 为主序、ID 只做决胜。
</Callout>

## 会话状态与待办

会话状态（`status.ts`）只有 `idle`、`busy` 和带 `attempt`、`message`、`next`（下次重试时间戳）的 `retry` 三种。它只存在实例内存里，不落库；查不到即视为 `idle`。

待办（`todo.ts`）则落库：`todo` 表以 `(session_id, position)` 为主键，`update` 在一个事务里先删光再按顺序插入，然后发出 `todo.updated`。待办工具见[子代理、待办与提问](https://daiw.org/manual/opencode/task-and-todo)。

## 持久化：写事件，不写表

`Session.updateMessage` 与 `updatePart` 本身不碰数据库，只发布 `message.updated`、`message.part.updated` 事件（`session.ts:629`）。这两类事件是 durable 的：发布时在同一个 SQLite 事务里分配序号、依次执行投影器（projector），由投影器写表。Part 的投影（`packages/core/src/session/projector.ts:310`，删减后）：

```ts
    yield* events.project(SessionV1.Event.PartUpdated, (event) =>
      Effect.gen(function* () {
        // ...
        const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
        yield* db
          .insert(PartTable)
          .values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data })
          .onConflictDoUpdate({ target: PartTable.id, set: { data } })
          .run()
          .pipe(Effect.orDie)
        const previous = row && usage(row.data)
        const next = usage(event.data.part)
        if (previous) yield* applyUsage(db, row.session_id, previous, -1)
        if (next) yield* applyUsage(db, sessionID, next)
      }),
    )
```

表结构在 `packages/core/src/session/sql.ts`：`session`、`message`、`part`、`todo` 四张表服务 V1，消息与 Part 的正文整体存进 JSON 列 `data`。投影时还顺手记账：`step-finish` 的花费与用量以“先减旧值、再加新值”的方式累加到 `session` 行，删除时反向扣除。

流式增量走 `message.part.delta`，它不是 durable 事件，只推给界面、不落库；文本结束时再整段写一次。读取由 `MessageV2.page` 负责，以 `(time_created, id)` 为游标倒序分页。细节见[存储：SQLite 与事件投影](https://daiw.org/manual/opencode/storage)。

下一篇：[主循环 SessionPrompt](https://daiw.org/manual/opencode/prompt-loop)——一条 user 消息如何驱动循环一步步跑下去。
