# 一条消息的旅程

> 按下回车之后：输入经运行协调器进入 CliService，回合系统受理并开跑，pi 的 Agent 循环调模型、跑工具，事件再经会话流回到转录区。

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

这一篇把前三篇的地图串起来：在交互式 TUI 里输入一句话、按下回车，它经过哪些模块，在哪些接口上换了形状，模型的输出又怎样一路流回屏幕。每一站只点出关键的文件与接口，细节留给后面各篇。

先分清一个词。本书说的“回合”（Turn）指从一条用户消息到最终答复的整个过程，对应 pi 的一次 `agent.prompt()`；pi 自己的 `turn` 只是其中一次模型响应加上随后的一批工具调用，相当于别处说的“步”。

```mermaid
sequenceDiagram
    participant U as 用户
    participant T as TUI<br/>ChatController
    participant C as CliService<br/>应用层
    participant S as 回合系统
    participant H as LocalAgentHost
    participant P as PiTurnRunner
    participant A as pi Agent 循环

    U->>T: 回车
    T->>C: sendMessage（进程内调用）
    C->>C: 预订会话流的订阅
    C->>S: submit（allowQueue 为 false）
    S->>S: 持久化受理，登记活动回合
    C->>C: 提交用户消息，放行执行
    S->>H: host.run
    H->>P: runTurn
    P->>A: agent.prompt
    loop 直到既无工具调用也无引导消息
        A->>A: 流式调模型，执行工具
        A-->>P: AgentEvent
        P-->>H: RuntimeEvent
        H-->>C: runtime-event 帧
        C-->>T: dataJson
    end
    A-->>P: agent_end
    H-->>C: turn-terminal 帧
    C-->>T: 流结束
```

## 1. 回车：命令分发

编辑器的提交回调由 `InputFlow` 接到命令流上（`packages/tui/src/tui/controller/interaction/input-flow.ts:161`）。如果此时有回合在跑，回车和 `Alt+Enter` 会先给这次提交打上标记：前者是 `steer`，后者是 `queue`（`input-flow.ts:366`）。

`TuiCommandFlow.submit`（`packages/tui/src/tui/controller/product/command-flow.ts:218`）依次分流：以 `!` 开头的交给 Shell（`command-flow.ts:284`）；命令表认得的斜杠命令就地执行（`command-flow.ts:427`）；剩下的才是发给 Agent 的消息。有回合在跑时，带 `steer` 标记的消息调 `activeRunFlow.steer` 插进当前回合（`command-flow.ts:530`），否则排进队列（`command-flow.ts:569`）；只有空闲时，消息才交给 `ChatController.submit`（`command-flow.ts:626`）。下面先跟着空闲时的这条主路走，引导与排队放到后面。

## 2. ChatController 与运行协调器

`ChatController.submit`（`packages/tui/src/tui/controller/chat-controller.ts:344`）先生成 turn id，把用户消息以 `pending` 状态写进转录区（`chat-controller.ts:372`），这样屏幕上立刻有了这一行；接着检查登录（`chat-controller.ts:394`）、确保会话存在，再调用 `executeTuiInteractiveTurn`（`chat-controller.ts:428`）。

真正驱动这次运行的是 `TuiRunCoordinator.execute`（`packages/tui/src/application/run-coordinator.ts:165`）。它同一时间只允许一个活动回合，负责超时、取消与最终答案的挑选，并用 `toSendMessageRequest` 把请求整理成运行时认识的 `CliSendMessageReq`（`run-coordinator.ts:400`）。exec 与 ACP 两个入口用的也是这个协调器（`packages/tui/src/headless/runner.ts:147`、`packages/tui/src/acp/agent.ts:304`），三个入口从这里开始走同一条路。

## 3. 过边界：CliService

协调器调 `TuiRuntimeAdapter.sendMessage`（`packages/tui/src/runtime/adapter.ts:171`），适配器转给 `TuiConversationAccess`（`packages/tui/src/runtime/adapters/conversation-access.ts:19`），后者调用 `CliService.sendMessage`（`packages/local-runtime-v2/src/local/cli-service.ts:282`）。除了启动和退出时直接碰一下宿主，TUI 与运行时的往来都经过这里：同一进程里的一次方法调用，没有 HTTP，也没有 RPC 封套。

返回的不是最终结果，而是一个“流的打开结果”：失败时带状态码和错误键，TUI 把它变成 `TuiFailure` 抛出；成功时是一个帧的异步迭代器，TUI 边读边把每一帧解析成界面事件（`conversation-access.ts:35`）。

## 4. 应用层：先订阅，再提交

`ConversationApplication.sendMessage`（`packages/local-runtime-v2/src/application/conversation/conversation-application.ts:154`）先过几道闸：执行截止时间、暂停中的队列、保留的 turn id，以及 `/review` 的准入；然后把附件登记成本地资产，交给直发投递 `DirectSendDeliveryService.open`（`packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts:69`）。

`open` 第一步是向会话流预订一个订阅（`SessionStreamService.reserve`，`packages/local-runtime-v2/src/service/session-system/stream/session-stream-service.ts:107`），第二步才以 `allowQueue: false` 调用回合系统的 `submit`（`direct-send-delivery.ts:74`）。回合被受理时，`onAccepted` 回调把这个订阅的过滤条件绑到新的 turn id 上（`direct-send-delivery.ts:88`；过滤的实现见 `session-stream-service.ts:126`）。先订阅、再提交，回合一开始产出的帧也能收到。

## 5. 回合系统：受理与开跑

回合系统的 `submit` 在这里分岔（`packages/local-runtime-v2/src/service/turn-system/turn-submission.service.ts:53`）：

```ts
  const makeTurnId = options.makeTurnId ?? (() => `turn_${randomUUID()}`);
  return {
    submit: (input) =>
      input.allowQueue
        ? submitQueued(options, input, makeTurnId)
        : submitImmediate(options, input, makeTurnId),
    resumeUserInput: (input) => resumeUserInput(options, input, makeTurnId),
  };
```

直发走 `submitImmediate`，经由应用层的 `UserMessageTurnDeliveryService` 调到 `TurnExecutionService.submit`（`packages/local-runtime-v2/src/service/turn-system/execution/turn-execution.service.ts:53`）。`submitTurn`（`turn-execution.service.ts:280`）先做提交前的准备，再调 `repository.admit` 把这个回合持久化受理（`turn-execution.service.ts:356`）；受理成功后，`startAcceptedTurn` 在进程内登记活动回合，并通过执行协调器开跑（`turn-execution.service.ts:497`）。

这里有一个刻意的顺序。投递服务的类注释说得很直白：只有在回合被持久化受理之后，才提交可见的用户消息（`packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts:93`）。执行协调器拿到回合后并不马上开跑，而是先等投递服务放行（`packages/local-runtime-v2/src/service/turn-system/execution/execution.coordinator.ts:96`），投递服务则要等用户消息写进数据库、写进会话流之后才放行。于是顺序固定为：回合先被受理，用户消息再落库，最后执行协调器才调用 `host.run`，让 Agent 开跑（`execution.coordinator.ts:121`）。受理、排队与执行的全部细节见[回合系统](https://daiw.org/manual/minimax-code/turn-system)。

## 6. Agent 宿主：装配这一回合

`LocalAgentHost.run`（`packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts:156`）是回合归属与 Agent 循环之间的适配层，按固定顺序做几件事：取得本回合的插件、应用与 MCP 能力快照（`local-agent-host.ts:186`）；预检，备好会话、Agent 配置、模型与历史（`local-agent-host.ts:193`）；装配本回合（`local-agent-host.ts:200`）——先构建工具表，再由 `@mavis/agent-runtime` 按已注册的扩展组装，这两步的生产实现见 `packages/local-runtime-v2/src/service/turn-system/agent-host/native-production-dependencies.ts:141`；跑回合开始时的处理器；最后建一条提交流水线，交给执行器（`local-agent-host.ts:326`）。

执行器 `LocalRuntimeTurnExecutor`（`packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts:130`）把系统提示词、用户消息、模型配置、工具表和取引导消息的回调打包成一次 `runTurn` 调用（`executor.ts:319`），交给 `LocalAgentTurnRunner`。按类注释，后者是 V2 的生产执行器：直接调用 pi，外加本地的输入、输出安全策略（`packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts:80`），最终调到 `PiTurnRunner.runTurn`（`local-agent-turn-runner.ts:274`）。系统提示词与内置 Agent 从哪来，见 [Agent 服务](https://daiw.org/manual/minimax-code/agent-service)。

## 7. PiTurnRunner：把回合交给 pi

`PiTurnRunner.runTurn`（`packages/agent-core/src/pi-turn-runner/pi-turn-runner.ts:114`）的注释把一个回合拆成 8 步：建立本回合的状态（工具表、组合好的模型调用函数、事件桥），发出第一条 `session.status`，新建 pi 的 `Agent`，装上工具钩子与模型钩子，订阅 Agent 的事件，接好取消信号，运行 Agent，最后排空事件、补写尾部历史并发出终态。几个要点：

- **Agent 是每回合新建的**。`newAgent`（`packages/agent-core/src/pi-turn-runner/agent.ts:33`）把系统提示词、模型、工具与历史作为初始状态交给 pi 的 `Agent`，工具执行方式设为 `parallel`（`agent.ts:80`）。`runAgent` 再用 `agent.prompt` 把用户消息交进去（`agent.ts:103`）。
- **调模型的函数层层包装**。`composeStreamFn` 在 pi-ai 的 `streamSimple`（宿主也可以换成自己的实现）外面依次套上宿主的输出上限、缓存保留、`maxTokens`、请求头与宿主注入的 fetch，最外层是超时（`packages/agent-core/src/pi-turn-runner/llm.ts:13`），默认 20 分钟（`packages/agent-core/src/pi-turn-runner/defaults.ts:21`）。协议层见[模型协议层 pi-ai](https://daiw.org/manual/minimax-code/pi-ai)。
- **工具调用先过钩子链**。`setToolHooks` 把宿主给的前置钩子装到 pi 的 `beforeToolCall` 上，任何一个钩子返回 `block` 就拦下这次调用（`packages/agent-core/src/pi-turn-runner/tools.ts:71`）。V2 的钩子链依次是重复调用检查、安全防护、插件的 PreToolUse 钩子、工具策略与各扩展注册的钩子，最后才是权限判定（`packages/local-runtime-v2/src/service/turn-system/agent-host/execution/plugin-hook-tool-lifecycle.ts:64`）。见[工具体系](https://daiw.org/manual/minimax-code/tool-system)与[权限模式与规则](https://daiw.org/manual/minimax-code/permission)。

## 8. pi 的 Agent 循环

`agent.prompt` 最终进入 `runLoop`（`third_party/pi-mono/packages/agent/src/agent-loop.ts:155`）。它是两层循环：内层每轮先把待注入的引导消息插进上下文（`agent-loop.ts:182`），然后调模型、执行工具（`agent-loop.ts:192`）：

```ts
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn);
newMessages.push(message);

if (message.stopReason === "error" || message.stopReason === "aborted") {
	await emit({ type: "turn_end", message, toolResults: [] });
	await emit({ type: "agent_end", messages: newMessages });
	return;
}

// Check for tool calls
const toolCalls = message.content.filter((c) => c.type === "toolCall");

const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
	const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit);
	toolResults.push(...executedToolBatch.messages);
	hasMoreToolCalls = !executedToolBatch.terminate;

	for (const result of toolResults) {
		currentContext.messages.push(result);
		newMessages.push(result);
	}
}
```

除了出错和被中止，继续还是结束只看这次回复里有没有工具调用，不看模型报的结束原因。并行模式下，一批工具调用先按顺序逐个准备（校验参数、过 `beforeToolCall`），再一起并发执行（`agent-loop.ts:485`）。一轮结束后，循环再从 Agent 的引导队列里取新消息（`agent-loop.ts:253`）；内层循环在既无工具调用、也无引导消息时退出，外层再看有没有后续消息，没有就发出 `agent_end` 收工。

运行中按回车发出的引导消息就是从这个口子进来的：它经 `CliService.steer`（`cli-service.ts:466`）进入回合系统等待消费；pi 每跑完一步、发出 `turn_end`，回合执行器就向宿主要一次引导消息（`packages/agent-core/src/pi-turn-runner/events.ts:181`），用 `agent.steer` 塞进 pi 的引导队列（`events.ts:377`），在下一次调模型之前插进上下文。排队的消息则走另一条路：`Alt+Enter` 经 `CliService.enqueueMessage`（`cli-service.ts:317`）以 `allowQueue: true` 提交（`conversation-application.ts:402`），写进会话队列；当前回合结束、释放会话后，调度器才把它取出来开一个新回合（`packages/local-runtime-v2/src/service/turn-system/initialize.ts:111`）。pi 这一层的全部细节见 [pi 的 Agent 循环与回合执行器](https://daiw.org/manual/minimax-code/pi-agent)。

## 9. 事件回流

pi 发出的每个 `AgentEvent` 都进 PiTurnRunner 的串行队列，由 `EventBridge.processEvent`（`packages/agent-core/src/event-bridge/bridge.ts:282`）翻译成运行时事件：消息的开始、增量、结束和工具执行的起止成为正式事件，pi 自己的 `agent_start`、`turn_start` 这类生命周期事件只变成调试用的 trace（`bridge.ts:294`）。

运行时事件交给宿主传入的写入器，进入提交流水线 `TurnCommitPipeline.onRuntimeEvent`（`packages/local-runtime-v2/src/service/turn-system/agent-host/events/turn-commit-pipeline.ts:119`）：终态事件先暂存，等回合收尾时统一提交；其余事件按顺序交给 `RequiredAgentEventDelivery`，它按会话串行地依次调用四个投影器（`packages/local-runtime-v2/src/service/turn-system/agent-host/events/required-agent-event-delivery.ts:228`）：

```ts
    const authoritative = await this.options.projectors.session.projectRuntimeEvent({
      context,
      event,
    });
    if (!authoritative) {
      throw new AgentEventAcknowledgementError(terminalOutcome(event) ?? 'non-terminal', 'missing');
    }
    validateAcknowledgement(event, authoritative);
    await this.options.projectors.messages.projectRuntimeEvent({ context, event });
    await this.options.projectors.stream.projectRuntimeEvent({ context, event });
    await this.options.projectors.turnFacts.projectRuntimeEvent({ context, event });
```

`session` 更新会话状态，`messages` 把供界面展示的消息写进数据库，`stream` 把事件写进会话流，`turnFacts` 更新回合的事实记录。其中写进会话流的，非终态记为 `runtime-event` 帧，终态记为 `turn-terminal` 帧（`packages/local-runtime-v2/src/service/session-system/agent-projection.ts:111`）。发给模型的那份规范历史走另一路，经 `onHistoryChanged` 单独落库（`turn-commit-pipeline.ts:158`），细节见[会话系统](https://daiw.org/manual/minimax-code/session-system)。

写进会话流的帧，经第 4 步预订的订阅回到应用层。`mapV2TurnStreamResult` 把它们转成 `dataJson` 字符串（`packages/local-runtime-v2/src/application/conversation/session-stream-delivery.ts:15`），格式带着 SSE 的影子：第一帧是心跳，最后以 `[DONE]` 收尾（`session-stream-delivery.ts:35`、`session-stream-delivery.ts:64`）。TUI 一侧的 `projectTuiSessionStreamFrame`（`packages/tui/src/runtime/stream-events.ts:139`）再把 JSON 解析回 `TuiStreamEvent`，常见的有文字增量 `delta`、完整消息 `message`、会话状态 `session-status` 与 `error`（`stream-events.ts:102`）。

运行协调器每拿到一个事件，就交给 `ChatController.applyRuntimeTurnEvent`（`chat-controller.ts:690`），由回合投影 `TurnProjection.applyStreamEvent` 更新这一回合的各个单元格（`packages/tui/src/tui/controller/projection/turn-projection.ts:122`），落到转录存储 `TranscriptStore.upsert`（`packages/tui/src/tui/transcript/store.ts:116`），再由 TUI 引擎画到屏幕上。转录区与渲染见 [TUI 应用](https://daiw.org/manual/minimax-code/tui-app)与 [TUI 引擎](https://daiw.org/manual/minimax-code/tui-engine)。

## 10. 收尾

pi 发出 `agent_end` 后，PiTurnRunner 排空事件队列、补写尾部历史，发出终态的 `session.status`（`pi-turn-runner.ts:228`）；宿主提交终态，会话流里出现 `turn-terminal` 帧，订阅随之完成，TUI 读到 `[DONE]`。运行协调器汇总出这一回合的状态、答案与用量，`ChatController` 把界面状态置回空闲。回合系统这边，执行协调器结算回合归属并释放会话，释放信号触发队列调度——如果刚才有人按过 `Alt+Enter`，下一个回合就从这里接着开始。

下一篇：[内置的 pi-mono：四个包与补丁台账](https://daiw.org/manual/minimax-code/pi-mono)——`third_party/pi-mono` 里的 agent、ai、coding-agent、tui 各管什么，哪些是上游原样，哪些是 MiniMax 的补丁。
