# 一条消息的生命周期

> 在默认 TUI 里敲下回车之后：SDK 请求穿过 RPC 隧道进入 HttpApi，SessionPrompt 主循环调模型、跑工具、问权限，部件更新再经事件总线回到屏幕。

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

这一篇把各层串起来走一遍：默认 `opencode` 命令下，一条用户输入从键盘到屏幕的完整路径。细节留给后面各篇。

```mermaid
sequenceDiagram
    participant U as 用户
    participant T as TUI<br/>packages/tui
    participant W as Worker<br/>HttpApi
    participant P as SessionPrompt
    participant L as Processor 与 LLM
    participant E as 事件总线

    U->>T: 输入并回车
    T->>W: session.prompt 经 RPC 隧道
    W->>P: prompt → createUserMessage → loop
    loop runLoop 每一轮
        P->>L: handle.process
        L->>L: streamText 与工具执行
        L->>E: updatePart 发布事件
        E-->>T: global.event
    end
    P-->>W: 最后一条 assistant 消息
    W-->>T: HTTP 响应
```

## 1. TUI 提交

提交逻辑在 `packages/tui/src/component/prompt/index.tsx`。没有当前会话就先 `session.create`（带上 Agent 与模型），然后分三路：Shell 模式调 `session.shell`，以已知命令名开头的输入调 `session.command`，其余调 `session.prompt`，`parts` 里依次放编辑器上下文、正文文本和附件（`index.tsx:1094`）。这个调用前面没有 `await`：请求一发出，输入框就清空，之后看到的所有进度都来自事件，而不是这个请求的返回值。

## 2. SDK 与隧道

生成的 SDK（`packages/sdk/js/src/v2/gen/sdk.gen.ts:3742`）把调用变成 `POST /session/{sessionID}/message`，工作目录经请求头 `x-opencode-directory` 传递。默认 TUI 的 `fetch` 是 RPC 隧道：请求被序列化给 Worker，由 `Server.Default().app.fetch` 在进程内处理（见[启动链路](https://daiw.net/manual/opencode/startup)）。

## 3. 路由与处理函数

端点定义在 `packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:316`，标识 `session.prompt`，载荷是去掉 `sessionID` 的 `PromptInput`。处理函数（`packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:295`）确认会话存在后调用 `SessionPrompt.prompt`，把最终消息序列化成 JSON 返回——这个 HTTP 请求要**等整轮对话结束**才返回。另一个端点 `prompt_async` 把同样的调用 fork 出去，立即回 204。

## 4. SessionPrompt.prompt

`packages/opencode/src/session/prompt.ts:1052`：取会话、`revert.cleanup` 收尾撤销状态、`createUserMessage`、刷新会话时间；`noReply` 为真就只落消息不回复，否则进入 `loop`。

`createUserMessage`（`prompt.ts:635`）确定 Agent（未指定用默认 Agent）与模型（请求优先，其次 Agent 配置，最后是会话当前模型），再展开输入部件：文件部件会被真正读出来，变成一段合成的“Called the Read tool...”文本加读取结果（`prompt.ts:860`），MCP 资源与 `@agent` 提及也各有处理；最后逐条 `updateMessage`、`updatePart` 落库。

## 5. loop 与 runLoop

`loop` 只有一行（`prompt.ts:1346`）：

```ts
return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
```

`SessionRunState` 给每个会话配一个 `Runner`（`packages/opencode/src/effect/runner.ts`）：空闲时启动 `runLoop`，已在运行时新调用只等待同一个结果（`runner.ts:120`）。所以会话忙时再发消息不会开第二个循环——新消息已经落库，正在跑的 `runLoop` 下一轮重读消息时自然会看到它。

`runLoop`（`prompt.ts:1081`）每一轮：状态置为 busy、重读消息、判断能否退出；处理子任务与压缩任务；创建空的 assistant 消息与 `SessionProcessor`；用 `SessionTools.resolve` 备好工具；拼好系统提示词后调用 `handle.process(...)`。退出条件旁的注释值得一读（`prompt.ts:1103`）：

> Some providers return "stop" even when the assistant message contains tool calls. Keep the loop running so tool results can be sent back to the model, but ignore cleanup-marked interrupted orphans.

`LLM.run` 调 `streamText` 时没有设置 `stopWhen`（AI SDK 默认只跑一步），工具结果回灌给模型靠的正是 runLoop 的下一轮。

## 6. 调模型

`SessionProcessor.process`（`packages/opencode/src/session/processor.ts:641`）订阅 `llm.stream(...)`，逐个事件交给 `handleEvent`，出错按重试策略重来，最后给外层一个三值结论（`processor.ts:693`）：

```ts
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"
```

`LLM.stream`（`packages/opencode/src/session/llm.ts:357`）默认走 AI SDK 的 `streamText`，把 `fullStream` 经 `LLMAISDK.toLLMEvents` 转成统一的 `LLMEvent`；设了 `OPENCODE_EXPERIMENTAL_NATIVE_LLM` 且模型受支持时，改由 `@opencode-ai/llm` 直接产出 `LLMEvent`。处理器分不出两者，见[模型调用层](https://daiw.net/manual/opencode/llm-bridge)。

## 7. 工具与权限

交给 `streamText` 的每个工具都由 `SessionTools.resolve` 包过：`execute` 先触发插件钩子 `tool.execute.before`，再调工具本体，最后 `tool.execute.after`（`packages/opencode/src/session/tools.ts:102`）。工具需要授权时调用上下文里的 `ask`，它把 Agent 与会话的规则合并后交给权限服务（`tools.ts:81`）。

`Permission.ask`（`packages/opencode/src/permission/index.ts:67`）逐个模式求值：命中 `deny` 直接失败，全是 `allow` 就放行，否则挂起（`index.ts:98`）：

```ts
const deferred = yield* Deferred.make<void, PermissionV1.RejectedError | PermissionV1.CorrectedError>()
pending.set(id, { info, deferred })
yield* events.publish(Event.Asked, info)
return yield* Effect.ensuring(
  Deferred.await(deferred),
  Effect.sync(() => {
    pending.delete(id)
  }),
)
```

工具就停在这个 `Deferred` 上：`permission.asked` 事件让 TUI 弹出询问，用户选择后调 `sdk.client.permission.reply`（`POST /permission/:requestID/reply`），`Permission.reply` 完成或拒绝它。选“始终允许”会记下规则，并放行同会话里其他已满足的挂起请求；拒绝则让同会话的挂起请求一起失败。以 `--auto` 启动时，`packages/tui/src/context/sync.tsx:196` 收到询问就自动回复 `once`。

处理器同时把进展写成 `tool` 部件：`tool-input-start` 建一个 `pending` 部件，`tool-call` 转为 `running`，`tool-result` 或 `tool-error` 收尾（`processor.ts:315` 起）。同一工具连续三次同参调用会触发 `doom_loop` 询问；默认配置下工具被拒绝会让处理器返回 `stop`，本轮结束。

## 8. 部件回到屏幕

每次 `Session.updatePart` 都发布 `message.part.updated`（`packages/opencode/src/session/session.ts:635`）；文本与推理的增量走 `updatePartDelta`，发布 `message.part.delta`。看定义（`packages/schema/src/v1/session.ts`）：前者带 `durable` 声明，会被 core 的 `SessionProjector` 写进 SQLite；后者没有，只服务于界面上的逐字显示。

事件从这里出发回到 TUI：

1. `EventV2Bridge` 监听所有事件，转成 `GlobalBus.emit("event", ...)` 并附上目录、项目与工作区（`packages/opencode/src/event-v2-bridge.ts:35`）。
2. Worker 订阅 `GlobalBus`，经 `Rpc.emit("global.event", ...)` 推回主线程；对外监听时改由 `/global/event` 的 SSE 端点推送，另带 10 秒一次的心跳。
3. TUI 的 SDK 上下文把事件排队，16 毫秒内到达的合并成一批，用 Solid 的 `batch` 一次应用（`packages/tui/src/context/sdk.tsx:68`）。
4. `packages/tui/src/context/sync.tsx` 按类型更新 store：`message.part.updated` 二分查找后插入或替换，`message.part.delta` 把增量拼到对应字段，`permission.asked` 进入待回复列表。
5. 会话页把 `sync.data.part[message.id]` 交给 `AssistantMessage` 组件渲染（`packages/tui/src/routes/session/index.tsx:1289`）。

收尾时，`step-finish` 记下用量、费用与快照补丁；`runLoop` 退出后 `Runner` 把状态置回 `idle`，`session.status` 事件让界面结束忙碌，那个一直挂着的 HTTP 请求这才带着最后一条 assistant 消息返回。同一套事件也经 SSE 喂给 Web、桌面端与 `attach` 进来的客户端——请求只负责发起，进度全走事件。

下一篇：[会话与消息模型](https://daiw.net/manual/opencode/session-model)——会话、消息与各种部件的数据结构，以及它们怎样落进 SQLite。
