# 用户输入与会话

> 本章以 OpenCode 源码版本 `v1.18.16`（提交 `a3647eb025c7`）为证据基线。我们沿用上一章的典型输入：CLI 向一个 session 发送“解释这个项目”，并附带 `package.json`。这里追踪的是源码允许的一条典型路径，不是一次真实会话录屏。

## 0. 本章学习目标

学完这一章，你应该能够：

1. 画出 `HTTP payload -> SessionPrompt.prompt -> user message + parts -> agent loop` 的位置图。
2. 区分 session、message、part 与 prompt input 四种数据。
3. 解释 agent/model 为什么被记录在 user message，而不是只存在请求参数中。
4. 沿源码追踪 text part 与本地 file part 怎样被解析、扩展并持久化。
5. 说明 `noReply`、插件修改、文件读取失败和实验事件双写的边界。
6. 为自己的 mini agent 设计一份可恢复的会话账本。

## 1. 一句话讲明白

用户输入与会话层把一次短暂的外部请求，转换成 session 中可持久化、可重放的 **user message 与 typed parts**，再把这份稳定事实交给 agent loop。

本章的中心问题是：

> **CLI 发来的文本和附件，为什么不能原样塞进一个字符串就交给模型？**

因为后续循环需要知道这条输入属于哪个 session、选择了哪个 agent/model、哪些内容来自用户、哪些是文件解析产生的 synthetic context，还要支持恢复、插件、事件与多种附件。证据入口见 `packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/prompt.ts`。

## v1.18.16 相比旧基线改变了什么

- `prompt` 仍坚持“先保存 user message 与 parts，再决定是否进入 loop”，并把临时 `tools` 开关转换成 session permission rules。
- message 读取接口现在更明确地提供 `page`、`stream`、`parts` 与 `latest`：账本既支持分页恢复，也支持按 session 顺序消费。
- 消息契约进一步下沉到 `SessionV1` 公共类型；教程因此不再把 runtime 本地文件误当成唯一 schema 来源。

<!-- source-ref path="packages/opencode/src/session/prompt.ts" lines="1046-1071" title="先持久化，再进入 loop" note="noReply 和临时工具权限都在持久化之后处理。" -->

<!-- source-ref path="packages/opencode/src/session/message-v2.ts" lines="425-492" title="消息分页、流与 parts" note="这些接口共同构成可恢复的 session 账本读取面。" -->

## 2. 为什么会话层是 Agent 的“账本入口”

一次 HTTP 请求会结束，一次 CLI 进程也会退出；coding agent 的任务却可能跨越多轮模型请求、多个工具调用，甚至被中断后继续。

如果只把最后一句字符串留在内存中，会立刻遇到这些问题：

- 工具结果属于哪次模型回应？
- 恢复 session 时使用哪个 agent 和 model？
- 文件、图片、MCP resource 与普通文本怎样区分？
- UI 如何看到 part 的增量变化？
- 插件应在何处修改输入，才不会绕过持久化？

所以 OpenCode 先“记账”，再“干活”。这一顺序由 `SessionPrompt.prompt` 的控制流直接证明：`createUserMessage` 与 `sessions.touch` 发生在 `loop(...)` 之前。来源：`packages/opencode/src/session/prompt.ts`。

## 3. 先画地图：四层数据不要混在一起

```text
CLI / SDK / 其他客户端
          |
          v
PromptPayload / PromptInput       一次调用的输入草稿
          |
          v
SessionPrompt.createUserMessage   选择 agent/model，解析 parts
          |
          +------> MessageV2.User     这一轮用户消息的元信息
          |
          +------> MessageV2.Part[]   文本、文件、agent、subtask...
                          |
                          v
                Session.Service       持久化并发布更新
                          |
                          v
                SessionPrompt.loop    消费整段历史
```

| 概念 | 最短定义 | 生命周期 |
| --- | --- | --- |
| session | 一个可持续推进的任务档案 | 跨多条消息、可继续或 fork |
| message | user 或 assistant 的一轮记录 | 属于一个 session |
| part | message 内的一块 typed content/state | 可独立更新 |
| prompt input | 外部调用提交的草稿 | 只存在于入口转换阶段 |

Java 可以把 session 暂时类比为 aggregate root，message/part 类似子实体。但不要把它直接等同于 JPA 聚合：OpenCode 的 Schema 还承担运行时校验，message parts 又与流事件更新紧密相连。

## 4. 最小机制：先看从草稿到账本的骨架

```ts
async function prompt(input: PromptInput) {
  const session = await sessions.get(input.sessionID)
  const user = chooseAgentAndModel(input, session)
  const parts = await Promise.all(input.parts.map(resolvePart))

  await plugins.beforeSave({ message: user, parts })
  await sessions.saveMessage(user)
  await sessions.saveParts(parts)

  if (input.noReply) return { info: user, parts }
  return agentLoop(input.sessionID)
}
```

这是教学骨架，不是源码逐行翻译。真实代码还会处理图片规范化、引用、MCP resource、agent mention、旧 `tools` 字段、实验事件系统与 revert cleanup。

## 5. 读源码前，只补两个数据模型

### 5.1 Message 保存“这一轮是谁、用什么身份工作”

`MessageV2.User` 的核心字段包括：

```ts
{
  id,
  sessionID,
  role: "user",
  time,
  agent,
  model: { providerID, modelID, variant },
  system?,
  tools?,
  format?
}
```

真实 Schema 见 `packages/opencode/src/session/message-v2.ts`。

为什么 model 与 agent 要落在 message 上？因为历史可能跨越模型/agent 切换；下一轮若只看 session 的“当前配置”，就无法准确解释旧消息是在什么执行身份下产生的。

### 5.2 Part 保存“这一轮具体有哪些内容和状态”

`MessageV2.Part` 是以 `type` 为判别字段的 union，包含 text、file、tool、reasoning、step、patch、agent、compaction 等。来源：`packages/opencode/src/session/message-v2.ts`。

本章输入端只接受四类草稿：`TextPartInput`、`FilePartInput`、`AgentPartInput`、`SubtaskPartInput`。来源：`packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/message-v2.ts`。

这一区分很重要：**存储模型比入口模型更丰富**。工具状态、reasoning 等由后续 runtime 产生，不该伪装成用户可以随意提交的 part。

## 6. 追一条典型源码旅程：文本加本地文件

入口收到的典型 payload 可以抽象成：

```ts
{
  agent: "build",
  model: { providerID: "...", modelID: "..." },
  parts: [
    { type: "file", url: "file:///project/package.json", filename: "package.json", mime: "text/plain" },
    { type: "text", text: "解释这个项目" },
  ],
}
```

具体 provider/model 值由用户配置决定；这里不假设某一家模型。

### 6.1 第一站：API payload 从内部 input 派生

HTTP 层没有另写一份易漂移的 DTO，而是从 `SessionPrompt.PromptInput` 去掉路径中已经提供的 `sessionID`：

```ts
export const PromptPayload = Schema.Struct(
  Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]),
)
```

来源：`packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`

endpoint 是 `POST /session/:sessionID/message`，成功类型为 `MessageV2.WithParts`。来源：`packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`、`packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`。


这种派生减少了 API schema 与 service input 漂移的机会，但也让读者必须追到内部 Schema 才能看全请求形状。

### 6.2 第二站：handler 只补齐边界数据

handler 先确认 session 存在，再把 path param 中的 `sessionID` 与 payload 合并：

```ts
const message = yield* promptSvc.prompt({
  ...ctx.payload,
  sessionID: ctx.params.sessionID,
})
```

来源：`packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`

这是一层薄 adapter。它不解析文件、不选择模型，也不运行循环。同步 endpoint 最终把完整 message JSON 作为 stream response 返回；异步 endpoint 则 fork 执行并立即返回 No Content。来源：`packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`。

### 6.3 第三站：`createUserMessage` 固化 agent 与 model

`createUserMessage` 先解析 agent；若 agent 不存在，会发布 session error 并抛错。然后按以下优先级决定 model：

```text
input.model
   ?? agent.model
   ?? currentModel(sessionID)
```

来源：`packages/opencode/src/session/prompt.ts`。

选定 variant 后，代码创建 `MessageV2.User`，写入 message id、session id、agent、model、system、format 等。来源：`packages/opencode/src/session/prompt.ts`。


这里还会在 agent/model 与 session 当前值不同时发布切换事件。来源：`packages/opencode/src/session/prompt.ts`。这是 UI/事件层的可观察性，不改变上述选择优先级。

### 6.4 第四站：text part 只补 ambient IDs

普通 text part 不需要 I/O。`resolvePart` 的兜底分支把输入字段保留，并补上 `messageID` 与 `sessionID`；随后 `assign` 在缺少 part id 时生成新 ID。来源：`packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/prompt.ts`。

这说明输入 part 是 draft，存储 part 是带完整归属信息的事实。

### 6.5 第五站：file part 在入账前展开成模型可读上下文

对 `file:` URL，代码把 URL 转成本地路径，判断真实 MIME/目录类型，并取得 registry 中的 `read` 工具。来源：`packages/opencode/src/session/prompt.ts`。

若是 `text/plain`，典型成功结果会产生：

1. 一条 synthetic text，记录等价的 Read 调用参数；
2. 一条 synthetic text，保存 read 工具输出；
3. 原 file part，或 read 工具返回的附件；
4. 若命中 reference，还会增加 reference context。

证据见 `packages/opencode/src/session/prompt.ts`。


这里的 `read.execute` 使用 `bypassCwdCheck: true`，`ask` 也是空 Effect。准确的边界是：**这是用户主动提交附件后的预解析路径**，不是模型自主调用 read 工具的普通权限路径。来源：`packages/opencode/src/session/prompt.ts`。

为什么不只保存路径？因为下一轮模型需要可消费的内容，session 恢复也不能依赖模型自己“知道去读”。同时保留 file part，又能让 UI 与支持附件的 provider 识别原始附件语义。

### 6.6 第六站：所有 parts 并发解析，再交给插件

`Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" })` 并发解析输入，再 flatten 并分配 ID。之后触发 `chat.message`：

```ts
yield* plugin.trigger(
  "chat.message",
  { sessionID, agent, model, messageID, variant },
  { message: info, parts: resolvedParts },
)
```

来源：`packages/opencode/src/session/prompt.ts`

插件拿到的是可修改对象，并且 hook 发生在保存之前。因此它是消息入账边界上的扩展点，而不是保存后的旁路补丁。

### 6.7 第七站：先保存 message，再逐个保存 parts

图片经过可选规范化后，源码执行：

```ts
yield* sessions.updateMessage(info)
for (const part of parts) yield* sessions.updatePart(part)
```

来源：`packages/opencode/src/session/prompt.ts`


为什么分开保存？从数据形状可以确认，这允许 part 独立更新；工具状态与流式文本正需要这种粒度。更底层具体数据库事务语义不在本章所列证据中，因此这里不推断它们必然原子提交。

### 6.8 第八站：记账后才决定是否运行

`SessionPrompt.prompt` 先清理 revert、创建消息、touch session，再把旧 `input.tools` 映射为 session permission。最后：

```ts
if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID })
```

来源：`packages/opencode/src/session/prompt.ts`

所以 `noReply` 不是“不保存”，而是“只入账，不启动回复循环”。这对于批量导入、异步编排或先构造上下文很有价值。

## 7. 决定可靠性的四个边界

### 7.1 文件读取失败不会伪装成成功

本地文本读取失败时，代码发布 session error，并增加一条 synthetic text 描述读取失败；不会凭空制造文件内容。来源：`packages/opencode/src/session/prompt.ts`。

这使失败本身也成为后续可见上下文，但调用方仍应把 session error 当成错误信号。

### 7.2 输入 union 限制“用户能声称什么”

PromptInput 只接收 text/file/agent/subtask drafts，不能直接提交一个已经 `completed` 的 ToolPart。来源：`packages/opencode/src/session/prompt.ts`。这防止外部请求把 runtime 状态伪造成用户输入。

### 7.3 MCP resource 与本地文件是两条读取路径

若 file part 的 source 是 `resource`，`resolvePart` 调 `mcp.readResource`；本地 `file:` URL 则走 registry 的 read 工具。来源：`packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/prompt.ts`。它们最终都归一为 typed parts，但信任边界与失败信息不同。

### 7.4 `Prompted` / `Synthetic` 当前是条件双写

持久化后，代码把普通文本、文件、agent、reference 与 synthetic text 聚合；只有 `experimentalEventSystem` 开启时才发布 `SessionEvent.Prompted` 和 `SessionEvent.Synthetic`。源码注释明确称这是迁移期间的 temporary dual-write。来源：`packages/opencode/src/session/prompt.ts`。

因此不能把这些实验事件描述成所有环境下唯一的消息存储。

## 8. OpenCode 的选择：替代方案与代价

| 设计问题 | OpenCode 的选择 | 可选做法 | 收益与代价 |
| --- | --- | --- | --- |
| 一条消息怎样建模 | message 元信息 + typed parts | 单一 content 字符串 | 可表达附件与状态；模型和转换更复杂 |
| API schema 从哪里来 | 从 PromptInput omit `sessionID` | 维护独立 DTO | 减少漂移；外部合同与内部类型耦合更紧 |
| 附件何时读取 | user message 创建阶段展开 | 等模型自己调用 read | 首轮就有确定上下文；入口阶段 I/O 更重 |
| 插件何时修改 | 解析后、保存前 | 保存后再补写 | 入账内容一致；插件可影响核心输入，需谨慎治理 |
| 先保存还是先运行 | 先持久化，再进 loop | 在内存里跑完再保存 | 可恢复、可观察；要处理部分完成和失败记录 |

表中的行为由源码直接证明；收益与代价是基于结构的设计解释。

## 9. TypeScript / Effect：只学会挡路的三处

### 9.1 discriminated union

`part.type === "file"` 后，TypeScript 能把 part 缩小到 FilePartInput。可以类比 Java sealed interface + pattern matching；不同点是 Effect Schema 同时做运行时解码。

### 9.2 `Struct.omit`

它从现有 Schema 的 fields 派生新结构，不只是 TypeScript 编译期的 `Omit<T, K>`。因此 API 层获得的仍是运行时可校验 Schema。

### 9.3 `Effect.scoped`

`createUserMessage` 被包在 scope 中，并注册 finalizer 清理 instruction 状态。可以暂时理解为结构化资源作用域；不要把它等同于普通 `finally`，因为 scope 可管理多个 Effect 资源。

## 10. 可以带走的方法

### 方法一：把外部输入转换成稳定领域事实

先解析默认值、分配 ID、补归属信息，再持久化；不要让后续 loop 反复解释原始 HTTP DTO。

验证问题：换成 IDE 客户端后，agent loop 是否仍只读取同一种 message/part？

### 方法二：让入口类型小于内部状态空间

用户只能提交允许的 drafts；tool/reasoning/step 状态只能由 runtime 产生。

验证问题：外部调用者能否伪造一条“工具已成功执行”的记录？

### 方法三：把可恢复性设计进写入顺序

先保存用户委托，再启动长时间运行的 agent loop。

验证问题：模型请求前进程中断，重启后还能否看见并继续这条委托？

## 11. 费曼复述与练习阶梯

### 11.1 60 秒复述

不要看上文，补全：

> session 是 ______，message 是 ______，part 是 ______。HTTP payload 从 ______ 派生。`createUserMessage` 先选择 ______，再解析 ______。本地文本附件会产生 ______，保存完成后，除非 ______，否则进入 agent loop。

如果你在“file part 是否就是文件正文”处犹豫，请回看 `packages/opencode/src/session/prompt.ts`。

### 11.2 读源码练习

1. 入门：列出 `PromptInput.parts` 允许的四种输入类型。
2. 进阶：画出 model 的三层选择优先级，并解释 variant 何时继承 agent 默认值。
3. 辨析：比较 stored FilePart 与解析产生的 synthetic TextPart。
4. 失败路径：追踪本地文件读取失败后，bus event 与 parts 分别发生什么变化。

### 11.3 小实现

写一个内存版会话层：输入只支持 text/file drafts，保存 `UserMessage` 与 `Part[]`；file 读取成功和失败都必须产生可审计 part；`noReply` 时只保存不调用假模型。

## 12. 最后复盘：账本已经准备好，谁来推进它

本章主链是：

```text
PromptInput -> 选择 agent/model -> 解析 typed parts -> plugin -> 保存 -> loop
```

你现在应该能回答中心问题：文本和附件没有被揉成一个字符串，而是被转换为带身份、归属、来源与类型的持久化事实；后续 agent 才能从同一本账本重建上下文。

下一章要解决的悬念是：账本已经有了 user message，模型第一次只选择工具而不回答时，谁负责执行、回填并再次调用模型？这就是“Agent 核心循环”。
