---
title: "React Hooks"
description: "使用 useSession Hook 在 React 中运行 Beeuni 会话。"
---

`@beeuni/sdk/react` 在 SDK 之上添加了 React hooks。本页介绍
`useSession`（端到端运行会话的 hook），以及其他已确认可供 React 应用稳定使用的 hooks。

## useSession(projectId, sessionId, options?)

`useSession` 启动会话，打开服务器选定的事件传输通道，并
同步消息、状态和待处理提示。每个会话视图调用一次。

```tsx
import { useSession } from '@beeuni/sdk/react';

function Chat({ projectId, sessionId }: { projectId: string; sessionId: string }) {
  const s = useSession(projectId, sessionId);

  if (s.phase !== 'ready') return <Booting stage={s.stage} onRetry={s.retry} />;

  return (
    <>
      {s.messages.map(({ info, parts }) => (
        <Message key={info.id} info={info} parts={parts} />
      ))}
      <Composer busy={s.isBusy} onSend={s.send} onStop={s.cancel} />
    </>
  );
}
```

就绪状态以服务器为准。当 `POST /start` 返回
`stage: 'ready'` 时，运行时即已就绪。`useSession` 不会在客户端另行执行健康检查。

<a id="returns"></a>

### 返回值

| 字段 | 类型 | 内容 |
|---|---|---|
| `phase` | `'starting' \| 'ready' \| 'error'` | 整体状态。在 `ready` 之前显示启动界面。 |
| `messages` | `{ info, parts }[]` | 消息列表。各部分实时流式传入。 |
| `savedTranscript` | `'loading' \| 'shown' \| 'none'` | 已保存的对话是否能在计算机唤醒前显示。`loading`：已保存的副本正在传入。`shown`：消息已在列表中。`none`：运行时响应前无法显示任何内容。 |
| `conversationEmpty` | `boolean` | 已保存的副本证实对话为空，此后没有回合结束，且没有正在进行或排队的内容。显示消息输入区，而不是启动界面。 |
| `status` | `SessionStatus` | 会话状态。 |
| `isBusy` | `boolean` | 智能体正在生成回复。 |
| `questions`, `permissions` | array | 待处理的智能体问题和工具审批请求。此处的 `permission` 是一次运行时工具审批，而不是 IAM 权限。 |
| `diffs`, `todos` | array | 实时文件差异和待办事项。 |
| `sendError` | `BeeuniSendError \| null` | 最近一次 `send` 失败：`billing`、`runtime-not-ready` 或 `runtime-error`。 |
| `rewindMessageId` | `string \| null` | 可逆回退暂存期间选中的用户消息。 |
| `rewindPending` | `boolean` | 回退或恢复请求正在进行。 |
| `rewindError` | `BeeuniSendError \| null` | 最近一次回退或恢复失败。 |
| `models`, `agents`, `defaultAgent`, `commands` | — | 可选模型、可选智能体、默认智能体和斜杠命令。运行时启动前即可使用。 |
| `retry` | `() => void` | 强制重新检查 `/start`。 |

<a id="actions"></a>

### 操作

| 操作 | 功能 |
|---|---|
| `send(text, override?)` | 发送提示。`override` 仅为此消息设置 `{ model?, agent? }`。 |
| `sendParts(parts, override?)` | 通过选定的传输通道发送文本和运行时 URL 文件部分。私有暂存句柄使用平台提示路由。 |
| `rewind(messageId)` | 将此规范会话回退到一条用户消息。选中的消息及后续路径会被隐藏，但可恢复。 |
| `restoreRewind()` | 在另一条提示提交替代路径之前，恢复已移除的路径。 |
| `cancel()` | 停止当前运行并清除待处理的问题和权限请求。先暂停会话中排队的提示，避免排队的提示启动新的运行。 |
| `runCommand(command, args)` | 执行项目斜杠命令。 |
| `answerQuestion(id, answers)` | 回答待处理的智能体问题。 |
| `rejectQuestion(id)` | 拒绝待处理的智能体问题。 |
| `answerPermission(id, reply, message?)` | 回应工具审批请求。`reply` 为 `'once'`、`'always'` 或 `'reject'`。 |

`useSession` 还会返回 `removeQuestion` 和 `removePermission`。不要使用
它们。它们会从本地状态中清除提示，却不会通知智能体，因此
运行仍会阻塞。请改用 `answerQuestion`、`rejectQuestion` 或
`answerPermission`。

<a id="options"></a>

### 选项

| 选项 | 默认值 | 功能 |
|---|---|---|
| `waitMs` | `15000` | 发送给 `/start` 的长轮询时间预算。 |
| `replayStartStash` | `true` | 会话就绪后，重放在会话创建之前保存的提示。 |
| `enabled` | `true` | 设置 `false` 以延迟 hook，例如等到账单检查通过。 |
| `chatEngine` | `true` | 如果应用为此会话挂载了自己的聊天界面，请设置 `false`，避免重复同步消息。 |
| `initialRuntimeSessionId` | `null` | 服务器已为此会话持有的运行时会话固定引用。在 `/start` 运行期间，对话记录根据此引用渲染；仍以 `/start` 的响应为准。`initialOpenCodeSessionId` 是其已弃用的名称。 |
| `browserPresence` | `false` | 当已登录的用户使用页面时（过去 10 分钟内有输入），持有此标签页的在线状态租约。该租约使会话的计算机保持唤醒。 |
| `presenceAlerts` | `false` | 此标签页显示自己的通知。通过在线状态租约发送：仅在用户正在使用会发出提醒的标签页时，服务器才会跳过手机通知和 Web Push。变更会立即发送。仅当项目的 `notification_center` 标志开启时才传入 `true`；否则服务器会忽略它。需要 `browserPresence`。 |
| `presencePageExit` | `false` | 页面关闭或进入后退/前进缓存时结束在线状态租约，确保标签页关闭后立即结束的回合仍会发送通知。开启时，`pagehide` 会报告离线，且每次离线报告都通过 `keepalive` 发送，因此可在页面结束后继续存在。关闭时：不发送 `pagehide` 报告，也不使用 `keepalive`。关闭中的标签页仍会变为隐藏并报告离线，但浏览器可能在页面卸载时取消该请求；此时租约会持续到 90 秒后到期。传入项目的 `notification_center` 标志。变更立即生效，不会发送新的在线状态报告。需要 `browserPresence`。 |
| `repositoryMode` | — | 仅用于遥测。仓库替换前创建的会话无需此选项，也会启动并使用项目的当前配置运行。只有其 `/workspace` 克隆仍来自旧仓库，因此从该克隆推送前需要先执行 rebase。 |

发送采用乐观更新。`send` 会立即显示你的消息，随后流式
事件填入智能体的回复。

`rewind(messageId)` 从不创建会话。它使用来自
`POST /start` 的规范会话。运行时会恢复文件状态，并保留已移除的对话记录
路径，使其可恢复。下一条被接受的提示会提交替代路径。

<a id="other-stable-hooks"></a>

## 其他稳定的 hooks

`@beeuni/sdk/react` 还导出了 React Query hooks，用于不需要
运行中会话的数据。每个 hook 都对应
[客户端](/docs/zh/sdk/reference) 上的一个方法，无需 provider。

| Hook | 读取内容 |
|---|---|
| `useProjectModels(projectId)` | 项目可选的模型。 |
| `useProjectModelPickerCatalog(projectId)` | 每个传输模型的原始 `/model-picker` 记录（`reasoning_options`、`temperature`、`limit`），以及每条符合条件的托管路由的 `managedPricingRoutes` 客户价格估算。实际提供服务的路由决定最终的积分扣费。 |
| `useProjectSessions(projectId, { parent, startedBy, q, limit })` | 单个项目的会话，分页返回。`parent: 'root'` 返回带有 `child_count` 的顶层会话；`startedBy: 'me' \| 'others' \| 'automated'` 按运行发起者筛选；`q` 搜索查看者有权打开的所有会话，而不只是已加载的页面。每组筛选条件都有独立的缓存条目。 |
| `useSessionChildren(projectId, parentSessionId, { q, limit, enabled })` | 单个父会话生成的会话（`parent=<id>`），按最新优先排序，分页返回。在父会话打开之前设置 `enabled: false`。 |
| `useVisibleAgents({ projectId })` | 项目中可见的智能体。 |
| `useProjectConfig(projectId)` | 项目的运行时配置：默认智能体、命令。 |
| `useProjectSecrets(projectId)` | 密钥：列出、添加、移除及个人覆盖设置。 |
| `useAccountSecretResources(accountId)` | 账户的提供商密钥元数据和使用授权。绝不返回密钥值。 |
| `useSessionProviderSecretPools(projectId, sessionId)` | 已配置的池，包括空选择；`setPool` 更新一个提供商并刷新会话缓存。 |
| `useProjectTriggers(projectId)` | 触发器：列出、创建、更新、移除、触发。 |
| `useProjectTriggerEventTypes(projectId, connector)` | 连接器可据以触发的应用事件。在设置 `connector` 之前保持空闲。 |
| `useChangeRequests(projectId, status?)` | 变更请求：列出、打开、合并、关闭、请求修改。 |
| `useRuntimeSupports(capability)` | 根据运行时重连轮询器读取的 `/beeuni/health` 响应，判断当前打开会话的运行时是否提供某项功能（`session.rewind`、`session.compact`、…）。收到第一个响应前为 `true`。参见[运行时支持的功能](/docs/zh/sdk/sessions#what-the-runtime-supports)。 |
| `useNotificationInbox({ userId, limit?, enabled? })` | 调用者收件箱的最新一页（`limit` 默认为 20）、`unreadCount`，以及 `markRead(ids)`、`markAllRead()`、`markSessionRead(sessionId)`。页面可见时每 60 秒轮询一次，获得焦点时重新获取。写入会立即更改缓存，失败时恢复缓存，随后重新获取所有收件箱条目。没有 `userId` 就不发送请求。 |
| `useNotificationPreferences({ userId, enabled? })` | 调用者按类型设置的推送和邮件偏好、`email_available`，以及 `update(patch)`（乐观更新）。 |
| `useSessionWatch({ userId, projectId, sessionId, enabled? })` | 单个会话的 `{ watching }`，以及用于将其静音或关注的 `setWatching(watching)`（乐观更新）。 |
| `resetRuntimeQueries(queryClient)` | 不是 Hook。清除所有缓存的运行时查询（会话、消息、智能体、命令、差异）。在重启或重新加载配置导致会话运行时被替换后调用，使下一次读取来自新的运行时。 |

这三个通知 Hook 以 `userId`（`qk.notifications`）作为缓存键，
因此在同一标签页中切换用户时，绝不会显示上一个用户的收件箱。请传入
已登录用户的 ID。

通知由每个项目的 `notification_center`
[功能标志](/docs/zh/feature-flags)控制，默认关闭。使用
`enabled: useFeatureFlag(projectId, 'notification_center').enabled` 控制每个 Hook，确保
关闭该标志的项目不发送任何通知请求。

<a id="connector-setup-links"></a>

## 连接器设置链接

智能体可以生成一键连接链接（`/connect/<token>`）。该流程的数据
和运行时策略位于 SDK 中；宿主基于它们渲染自己的对话框，并
注入自己的适配器。

| 导出项 | 作用 |
|---|---|
| `useConnectorSetup(token, options)` | 完整流程：加载链接指定的内容、`POST /start`，通过注入的 `openPopup` 打开提供商的托管页面，按 3 秒 / 5 秒 / 5 分钟的调度轮询 `POST /finalize`，并报告 `phase`（`loading`、`ready`、`starting`、`opened`、`connected`、`error`）、应用信息、错误以及账户被授权使用的身份。`options` 携带宿主的 `backendUrl`、`storage` 适配器和 `openPopup`；`onOpened` 在每次弹出窗口打开后触发。 |
| `useConnectorLinkInfo(token, options)` | 每个链接令牌都有一个基于存储的共享缓存，因此所有卡片和模态框共用一次对限流公共路由的 GET 请求，并在首帧绘制应用标志。使用相同的 `backendUrl`/`storage` 选项。 |
| `createConnectorLinkInfoCache(fetchInfo, { storage })` | 缓存工厂本身（不依赖框架，存储由外部注入），供需要自有实例的宿主使用。 |
| `resolveConnectorStart({ start, finalize })` | `/start` 的结果规则：托管 URL → 弹出窗口，已连接 → 完成一次，否则显示错误消息。 |
| `nextConnectorPollDelay(attempt, elapsedMs)` | 有时限的轮询调度（首次 3 秒，之后 5 秒，5 分钟后停止）。 |

<a id="connector-data"></a>

## 连接器数据

`useConnectorQuery(projectId, slug, action, args, { account, enabled, staleTime })`
以缓存查询的形式读取单个连接器操作的输出。`error` 是一个
`ConnectorCallError`：根据 `error.connectUrl` 渲染连接按钮，或根据
`error.availableAccounts` 渲染账户选择器。仅将它用于读取
操作；每次获取都会运行该操作并写入审计事件。输出在
30 秒内保持新鲜，获得焦点时不会重新获取，且仅在上游出现 `429` 或 `503` 时
重试。参见[通过代码使用连接器](/docs/zh/sdk/reference#pconnectorslug--one-connector)。

<a id="start-the-open-read-early"></a>

## 提前开始打开会话的读取

`prefetchSessionOpen(queryClient, projectId, sessionId)` 在会话视图挂载前开始
会话打开读取。它发出一次 `GET .../snapshot` 请求
（会话行、轮次、队列及前 40 条对话记录消息），并用结果初始化
会话行缓存。随后 `useSession` 使用该请求，而不是
自行发起请求。

当路由指定某个会话，或会话链接被悬停、获得焦点或被触摸时，
调用它。它是只读的：绝不调用 `/start`，也绝不唤醒计算机。它
绝不会拒绝。30 秒内对同一会话重复调用不会发送
请求。

```tsx
import { prefetchSessionOpen } from '@beeuni/sdk/react';

<a
  href={`/projects/${projectId}/sessions/${sessionId}`}
  onFocus={() => void prefetchSessionOpen(queryClient, projectId, sessionId)}
/>;
```

<a id="render-from-cache-on-a-reload"></a>

## 重新加载时从缓存渲染

两个存储保留用户已经看过的内容，使重新加载或应用冷启动时能在
首帧渲染这些内容，并就地刷新。两者均按用户隔离且有容量限制，
宿主在退出登录时清除两者。

- `createPersistedQueryCache` 保留用户导航时使用的 React Query 结果：
  账户、项目、项目详情、分页会话列表和会话行
  （`isPersistableQueryKey`）。恢复的条目保留原有的数据年龄，因此会在
  挂载时重新获取。默认限制为 1,000,000 个字符和 7 天。
- `createSavedCopyStore` 保留服务器发送的对话记录的最近一次保存副本。
  使用 `setSavedCopyStore` 注册它，`useSession` 会在首帧前绘制
  保留的副本。随后，服务器副本按消息 ID 合并到其中，
  SDK 会在每个轮次结束 3 秒后重新读取它。默认限制为 12 个
  会话、1,500,000 个字符和 14 天。超过 300,000 个字符的副本会保留
  容量范围内最新的消息；当用户向上滚动时，较早的消息从服务器
  加载。

```tsx
import {
  browserKeyValueStorage,
  createPersistedQueryCache,
  createSavedCopyStore,
  isPersistableQueryKey,
  setSavedCopyStore,
} from '@beeuni/sdk';

const storage = browserKeyValueStorage(); // null where there is no localStorage
if (storage) {
  const cache = createPersistedQueryCache({ storage, userId, shouldPersist: isPersistableQueryKey });
  cache.restore(queryClient); // synchronous with localStorage: no pending frame
  const stop = cache.persist(queryClient);
  setSavedCopyStore(createSavedCopyStore({ storage, userId }));
}
```

在 React Native 上，将 AsyncStorage 作为 `storage` 传入。此时 `restore` 返回一个 Promise。

保存副本的存储仅保留服务器快照：即 API
在轮次结束时写入的对话记录镜像。实时对话记录绝不写入设备，因此
已停止的轮次不会恢复成运行中的轮次。如果运行时读取未返回任何消息，
屏幕上仍保留已保存的消息：这表示计算机丢失了状态，而不是
对话为空。首次返回消息的运行时读取会将它们合并。

<a id="sign-out-and-account-switching"></a>

## 退出登录和切换账户

SDK 在内存中按用户保留会话状态：对话记录、待处理的问题
和权限、轮次回执以及模型选择。每次身份变化时都调用 `resetIdentityState()`：
包括退出登录，以及不同用户登录。也包括
在另一个标签页登录导致用户改变、但页面未重新加载的情况。

在你的登录流程中，用户登录或退出时调用此处理函数。

```tsx
import { resetIdentityState } from '@beeuni/sdk/react';

let previousUserId: string | null = null;

function onIdentityChange(userId: string | null) {
  if (userId !== previousUserId) {
    queryClient.clear();
    resetIdentityState();
  }
  previousUserId = userId;
}
```

`resetIdentityState()` 还会移除模型存储中的 `localStorage` 条目。它
不会清除宿主自身的缓存（例如 React Query），也不会清除传给
`createBeeuni` 的令牌来源。请在同一个处理函数中重置这些内容。它绝不会抛出异常。

使用[重新加载时从缓存渲染](#render-from-cache-on-a-reload)中的存储时，
请在同一个处理函数中清除它们：先执行 `await cache.clear()`，再执行
`await savedCopies.clear()` 和 `setSavedCopyStore(null)`。这两个存储都不会
恢复其他用户的数据，但已退出登录的设备不应保留这些数据。

<a id="next"></a>

## 下一步

- [会话](/docs/zh/sdk/sessions) — `useSession` 封装的会话句柄，以及 `BeeuniSendError` 类型。
- [参考](/docs/zh/sdk/reference) — 这些钩子读取的完整 REST 接口。


<a id="saved-history-during-startup"></a>

## 启动时的已保存历史记录

`useSession(projectId, sessionId)` 会在其计算机启动时显示已保存的聊天历史记录。
SDK 读取数据库不依赖运行时是否就绪。随后，实时消息会按
原始 ID 进行协调。宿主无需单独请求历史记录，也无需单独的消息存储。

已保存历史记录的渲染方式与实时对话记录完全相同：文本、推理，以及每次工具调用
及其输入、输出和错误。子智能体的完整视图读取该子智能体自身保存的
对话记录：将父级作用域和 `savedChild: true` 传给
`useSessionSync(subAgentSessionId, { beeuniSessionScope, savedChild: true })`。初始窗口包含最新的 40 条消息，
最多 1,000,000 个字符。向上滚动会从已保存历史记录中加载更早的窗口，即使
计算机已关闭也可以。需要计算机的附件会在
运行时就绪后可用。

`savedTranscript` 告知宿主在计算机启动时应渲染什么。在
已保存副本仍可能到达时，它为 `loading`；消息进入 `messages` 后，它为 `shown`；在运行时响应前
无法显示任何内容时，它为 `none`。未知状态是 `loading`，绝不是 `none`。

没有任何需要等待的内容时，`conversationEmpty` 为 true。已保存副本证明
对话为空：对该会话运行时的完整读取没有找到任何消息。此后没有轮次结束，
也没有任何未结束或排队中的内容。缺失记录绝不会使它为 true，因此有历史记录的旧会话
绝不会被显示为空。会话在启用历史记录保存后的首次启动时获得这一证明。

```tsx
if (s.messages.length === 0) {
  // Nothing saved and nothing to wait for: the user can type now.
  if (s.conversationEmpty) return <Composer />;
  // One round trip for a saved copy; the computer can take minutes.
  return s.savedTranscript === 'none' ? <Booting stage={s.stage} /> : <MessageSkeleton />;
}
```

2026-09-28 之前保存的工具调用丢失了输入和输出。服务器返回每个调用时，
会提供其保留的标题或元数据能够证明的输入：命令调用的命令和输出、文件调用的路径、
搜索的模式、子智能体的描述、获取请求的 URL、待办列表、技能的名称。
会话下次启动时会再次逐一保存全部历史记录。
<a id="pending-prompts"></a>

### 待处理提示词

`useSessionPrompts(projectId, sessionId)` 将提示词提交到持久化会话收件箱。
服务器会自动运行待处理提示词，每个提示词各占一个轮次。

传入 `placement: 'transcript'` 可在对话中显示待处理消息。
它会先于每个 `composer` 条目运行，并在
当前工具调用完成后结束正在进行的响应。
传入 `placement: 'composer'` 可将其保留在输入框上方的可编辑列表中。
它会等待正在进行的响应结束。每种放置位置都保留提交顺序。
传入 `delivery: 'steer' | 'queue' | 'interrupt'` 可指定提示词如何送达
正在运行的轮次。`steer` 会在轮次的下一步将其交给该轮次；该轮次不会
停止。`queue` 会等待轮次结束。`interrupt` 会在
正在运行的工具结束后结束轮次。仅传入 `delivery` 时，SDK 会发送它隐含的放置位置：
`interrupt` 对应 `transcript`，其他值对应 `composer`。无法
引导轮次的引导行显示为 `delivery: 'queue'`，其 `steer_fallback` 设为 `unsupported`、
`not_prompter` 或 `turn_ended`。`interrupt(promptId)`（“Stop and send”）会将
等待中的行变成对话记录条目；已经发送出去的行会返回 `409`。

网页输入框在轮次运行时将 Enter 作为 `steer` 发送，在会话空闲时则作为
对话记录发送。Command+Enter（Windows/Linux 上为 Control+Enter）
使用输入框放置位置。Shift+Enter 插入换行。

列表行包含 `full_text`，用于在重新加载后提供完整预览。`text` 仍然
为旧客户端提供有长度限制的预览。列表响应不包含附件
字节。删除操作会返回完整提示词，以便无损撤销。

<a id="pending-delivery-and-active-responses"></a>

### 待投递状态与正在进行的响应

提示词在持久化收件箱中等待时，`useSessionWorking()` 会保持 `state: 'working'`。
这使 Stop 按钮保持可用。`pendingDelivery: true` 标记仍在
送往运行时途中的提示词；只要 `state` 为
`working`，网页应用就会显示一个工作中指示器，无论该标志的值是什么。运行时中的活动轮次和新的运行时
活动会清除此状态。取消操作超时并不能确认执行已停止。

工作进程认领只检查准入条件，并让提示词继续等待。投递在
准入成功后开始。已确认的活动轮次会清除待处理状态的显示，
即使上一次收件箱快照仍列出该提示词。在此交接过程中，运行时活动
会保留活动轮次的消息 ID。

网页输入框将 Command/Ctrl+Enter 命名为 **Queue List**，将行上的“Stop and
send”命名为 **Quick Queue**。Quick Queue 条目优先运行。等待中的 Quick Queue 条目显示为已
投递轮次之后的着色气泡，没有状态文字。Queue List 条目在投递开始前仍可编辑。
服务器会根据确切的运行时证据恢复遗漏的完成事件，包括
重新加载之后，且不会将无法访问的运行时视为已完成的轮次。

每次单独按下 Enter 都会立即显示对应的待处理提示词，即使之前的
接收请求尚未完成。只有投递失败才会添加文字：失败原因、
Retry 和 Remove。执行顺序仍由服务器收件箱管理。

钩子从会话的控制流
（`GET .../sessions/:id/events?channels=control`）读取队列变化，每个会话只有一个连接，
无论有多少组件挂载它。收件箱行的每次写入都会作为
`beeuni.control.queue` 帧到达。`GET .../prompts` 轮询仅在该
流未连接时运行。

<a id="why-a-turn-ended"></a>

### 轮次为何结束

被中断轮次的对话记录仅包含 `MessageAbortedError`。
控制平面会保留其已知的各轮次结束情况：沙箱
给出的原因，例如 `SandboxMemoryGuard`，以及哪些轮次终止时没有
给出原因。人为请求的停止——Stop 按钮、`abort()`、向忙碌会话
发送的提示词——会记录为请求，绝不会报告为
失败。

`useSessionTurnOutcome(projectId, sessionId)` 从 `useSessionWorking()`
持续更新的缓存中返回 `last_ended`、`recent_failures` 和读取时间 `atMs`。
它不会发起请求。将结果连同
对话记录中该轮次的信息一起传给 `turnEndNotice`：

```tsx
const outcome = useSessionTurnOutcome(projectId, sessionId);
const notice = turnEndNotice(outcome, turn.userMessage.info.id, {
  hasError: Boolean(turnError),
  isAbort: turnErrorIsAbort,
});
// notice: { kind: 'sandbox-memory', usedPct: 97, detail: 'sandbox memory at 97% …' }
//       | { kind: 'cause', name: 'SomeGuard', message: '…' }
//       | { kind: 'unexplained' }
//       | null
```

宿主将每个 `kind` 映射为自己的文案，绝不解析沙箱的
消息。`null` 表示“显示对话记录所显示的内容”：真实错误保留，
中止则不渲染任何内容。只有在读取时间距轮次结束已过
几秒后，才会报告原因不明的失败，因为原因往往在中止后的下一帧
才到达。`turnEndCause(outcome, messageId)` 返回记录的原始
原因，或 `null`。
