---
title: "会话"
description: "运行会话、流式传输其事件，并处理它可能引发的错误。"
---

会话是一次智能体运行，拥有独立的沙箱和 git 分支。
`beeuni.session(projectId, sessionId)` 返回用于执行会话所有操作的句柄：启动会话、发送提示、流式接收事件以及读取状态。本页介绍该句柄、就绪握手、流式传输，以及 SDK 调用可能抛出的类型化错误。

```ts
const s = beeuni.session(projectId, sessionId);
```

`s` 是用于执行会话所有操作的句柄。会话 ID、沙箱 ID 和分支名称的值相同。概念说明请参阅
[会话](/docs/zh/work/sessions)。

<a id="session-lifecycle"></a>

## 会话生命周期

| 方法 | 封装 | 功能 |
|---|---|---|
| `s.get(opts?)` | `GET /projects/:pid/sessions/:sid` | 读取会话详情 |
| `s.update(input)` | `PATCH …/sessions/:sid` | 重命名会话、替换其标签或合并元数据键 |
| `s.start(waitMs?)` | `POST …/sessions/:sid/start` | 配置并启动运行时 |
| `s.restart()` | `POST …/sessions/:sid/restart` | 重启运行时；保留原沙箱 |
| `s.configState()` | `GET …/sessions/:sid/config` | 读取配置是否为最新（`stale`）以及配置发布状态（`release`） |
| `s.reloadConfig(input?)` | `POST …/sessions/:sid/reload` | 重新编译智能体配置，并在验证后替换运行时 |
| `s.reloadConfigStream(input, onEvent)` | `POST …/sessions/:sid/reload-stream` | 执行相同的重新加载，并发出服务器确认的进度阶段 |
| `s.stop()` | `POST …/sessions/:sid/stop` | 停止运行时；保留会话 |
| `s.delete()` | `DELETE …/sessions/:sid` | 删除会话 |
| `s.setSharing(intent)` | `PUT …/sharing` | 设置共享和可见性 |
| `s.participants()` | `GET …/sessions/:sid/participants` | 读取谁可以打开会话，以及每条提示由谁发送 |
| `s.watch()` | `GET …/sessions/:sid/watch` | 读取你是否接收此会话的[通知](/docs/zh/work/notifications)。需要 `notification_center`；关闭时 `403` `feature_disabled` |
| `s.setWatch(watching)` | `PUT …/sessions/:sid/watch` | 为你关注（`true`）或静音（`false`）会话通知。需要 `notification_center`；关闭时 `403` `feature_disabled` |
| `s.presence({ tab_id, active, alerts? }, { keepalive? })` | `PUT …/sessions/:sid/presence` | 报告已打开的标签页。`alerts: true` 表示该标签页自行显示通知，因此不发送手机推送。关闭 `notification_center` 时，只要存在任何标签页，就不发送手机推送。`keepalive` 允许请求在页面关闭后继续；报告标签页不存在时默认开启 |
| `s.cost()` | `GET /usage/session-costs/:sid` | 读取已结算的 LLM 和计算费用，不启动运行时 |
| `s.scope()` | `GET …/sessions/:sid/scope` | 读取已存储的密钥范围收窄设置和已实例化的连接绑定 |
| `s.rescope(input)` | `PUT …/sessions/:sid/scope` | 为下一次提示或工具调用替换所提供的范围字段 |
| `s.commit(input?)` | — | 提交智能体的工作 |
| `s.providerSecretPool.get(providerId)` | `GET …/provider-secret-pools/:providerId` | 读取已选择的账户密钥 ID |
| `s.providerSecretPool.list()` | `GET …/provider-secret-pools` | 列出已配置的池（包括空池），并报告 `can_edit` |
| `s.providerSecretPool.set(providerId, secretIds)` | `PUT …/provider-secret-pools/:providerId` | 替换选择；`null` 恢复项目默认值 |

:::warning
  `s.delete()` 会删除会话及其运行时。此操作无法撤销。若要暂停会话而不丢失它，请改用 `s.stop()`。
:::

<a id="labels-and-metadata"></a>

## 标签和元数据

每个会话都带有 `labels: string[]` 和一个 `metadata` 对象。用它们对会话分类，并决定应用如何呈现会话。

- **标签**可自由定义：每个标签会去除首尾空白，长度为 1–64 个字符，每个会话最多 20 个，重复项会被移除。匹配为精确匹配，区分大小写。
- **元数据**是供你自定义键的自由格式 JSON 对象，每次写入最多 16,384 个 JSON 字符。服务器也会在其中存储自己的键（`custom_name`、`source`、…）；指定服务器管理的键的请求会被拒绝，并返回 `400`。为你的键添加前缀（`app_…`），以避免冲突。

```ts
// Set both at create.
const project = beeuni.project(projectId)
const created = await project.sessions.create({
  initial_prompt: 'Triage ticket T-142',
  labels: ['support', 'tier:gold'],
  metadata: { app_ticket: 'T-142', app_priority: 2 },
})

// Update later. `labels` replaces the list; `metadata` merges keys and a
// `null` value removes that key.
const s = beeuni.session(projectId, created.session_id)
await s.update({ labels: ['support', 'resolved'], metadata: { app_priority: null } })

// List by label, server-side: a session must carry EVERY given label.
const { items } = await project.sessions.listPage({ labels: ['support', 'resolved'] })

// Render conditionally.
for (const session of items) {
  const badge = session.labels?.includes('tier:gold') ? 'Gold' : null
  const ticket = session.metadata.app_ticket as string | undefined
}
```

在 React 中，将 `labels` 传给 `useProjectSessions(projectId, { labels })`；每组标签都有独立的缓存条目。智能体可通过 CLI（`beeuni sessions new --label … --meta k=v`、`beeuni sessions update --label …`）和托管的 MCP 服务器（`start_session` `labels` / `metadata`、
`list_sessions` `labels`）设置相同字段。可运行的版本是 `packages/sdk/examples/12-session-labels.ts`。

启用 `pooled_provider_secrets` 和 `llm_gateway` 后，会话可以为一个提供商选择多个可用的账户密钥。通过 `beeuni.accounts.secretResources.create(accountId, { project_id: projectId, ...input })` 创建项目范围的资源。
默认情况下，每个项目成员都可以使用它。通过 `.setAccess(accountId, secretId, 'members', userIds)` 限制使用权限。读取操作返回元数据和授权，绝不会返回密钥值。如果提供商在输出开始前发生 `429`，网关会尝试另一个已选择的密钥。

```ts
await s.providerSecretPool.set('anthropic', [primarySecretId, backupSecretId]);
const selected = await s.providerSecretPool.get('anthropic');
await s.providerSecretPool.set('anthropic', null); // inherit project default
```

调用方需要显示重新加载进度时，请使用流式方法：

```ts
await s.reloadConfigStream({ refresh_repo: false }, (event) => {
  if (event.type === 'phase') console.log(event.phase);
});
```

各阶段为 `checking-session`、`refreshing-workspace`、`compiling-config`、
`applying-config` 和 `confirming-config`。当 `refresh_repo` 为 `false` 时，服务器会省略 `refreshing-workspace`。
`applying-config` 阶段包含守护进程验证过的运行时替换。

当 API 支持配置发布时，`s.configState()` 返回 `release`。
其类型为 `SessionConfigRelease`。重新加载结果携带相同对象。

```ts
const { stale, release } = await s.configState();
if (release?.fallback_reason) {
  // The desired config failed on the box. An earlier config runs the session.
  console.warn(release.fallback_reason, release.running_release_id);
} else if (stale === true) {
  // Moves the session onto the base branch's current config AND
  // fast-forwards its /workspace checkout. `workspace_checkout` says what
  // happened to the checkout; `detail` words both halves.
  const result = await s.reloadConfig();
  console.log(result.workspace_checkout, result.detail);
}
```

`stale` 有三种状态。`null` 表示 Beeuni 无法判断。应检查 `stale === true`，切勿检查 `!stale`。旧版 API 会省略 `release`。请参阅
[配置收敛](/docs/zh/work/runtime#config-convergence)。

`s.participants()` 返回 `{ participants, total, multi_user }`。`participants` 列出当前可以打开会话的人，所有者排在首位，最多 20 人，每人均为 `{ user_id, name, email, avatar_url, is_viewer }`；`total` 是完整人数。
当有两人或更多人可以打开会话时，`multi_user` 为 `true`。在 React 中，`useSessionParticipants(projectId, sessionId)` 读取相同数据。每条消息的作者由 `s.messageAuthors()` 表示。

该句柄还提供另外三个读取方法：

- `s.previews()` — 运行时公开的候选预览端口。
- `s.publicShares.list()` / `.create(input)` / `.revoke(shareId)` — 公开分享链接。
- `s.reminders.list()` / `.create(input)` / `.update(reminderId, { enabled })` / `.remove(reminderId)` — 定时发送到此会话的提示。参见[提醒](/docs/zh/connect/reminders)。
  `.create({ transcript: true })` 创建对话的只读链接（每个会话只有一个有效链接；再次调用会返回同一链接）。使用 `getPublicSessionShareMessages(token)` 可匿名读取；`findActiveTranscriptShare(shares)` 从 `.list()` 中选出有效链接。
- `s.audit(limit?)` — 此会话的智能体操作审计记录。
- `s.attachments.upload(file, options?)` — 在启动前保存私有文件。最大 50 MiB。返回用于提示文件部分的稳定 URL。
- `s.attachments.read(attachmentId, signal?)` — 将已保存的字节读取为 `Blob`，无需启动沙箱。
- `s.transcript(options?)` — 精简的服务端对话记录（包含文本和工具调用，不含工具输入或输出）。可使用项目范围的会话令牌。
- `s.transcriptSync(options?)` — 服务端在每轮结束时保存的对话记录，采用 运行环境 消息封装格式。除附件字节外，每个部分都按 1:1 保留，包括文本、推理，以及每次工具调用的输入、输出、错误和元数据。沙箱停止或仍在唤醒时也能返回结果，无需启动沙箱。传入 `limit`（最多 500）获取最新窗口，将某个窗口的 `next_cursor` 作为 `before` 传入可读取其前一个窗口。窗口也受 1,000,000 个 JSON 字符的限制：它保留最新的消息，此时 `next_cursor` 指明所保留的最早消息。传入 `child`（子智能体的 `ses_…` id，即子智能体行打开的会话）可读取该子智能体自身已保存的对话记录；对话派出的每个子智能体都会被保存。`source` 为 `mirror`；仅当窗口包含所有已保存的消息，且保存的副本追溯到会话的第一条消息时，`complete` 才为 true。对于运行时已完整读取且没有消息的会话，会返回 `available: true`、`complete: true`、`total: 0`，且不含消息。2026-09-28 之前保存的工具调用，当时保存的历史记录会丢弃工具输入和输出，返回时会包含其标题或元数据可证实的输入；若为命令，还会包含其输出。
- `s.voiceTranscript(options?)` — 此会话的实时语音通话记录（口述轮次以及 `ask_beeuni`/`run_command` 工作智能体工具调用）。会话没有实时通话时返回空列表，而非 404。

<a id="files-a-session-stored"></a>

### 会话存储的文件

`findSessionAttachments(messages)` 按顺序列出对话记录引用的所有已存储文件，每个文件只列出一次：用户上传的文件、提示中的 `<file>` 引用，或保存的历史记录中保留的智能体展示文件副本。它既能读取实时对话记录，也能读取已保存的记录，且不会因记录格式错误而抛出异常。

```ts
import { findSessionAttachments } from '@beeuni/sdk'

const saved = await s.transcriptSync({ limit: 500 })
for (const file of findSessionAttachments(saved.messages)) {
  const blob = await s.attachments.read(file.attachment_id) // no sandbox needed
  console.log(file.filename, file.role, blob.size)
}
```

每个结果都是 `{ url, attachment_id, filename, mime, message_id, role }`。
对于上传文件，`role` 为 `user`；对于智能体展示的文件，则为 `assistant`；
后者的 `mime` 为 null，字节本身携带类型信息。

<a id="who-wrote-each-message"></a>

## 每条消息由谁撰写

每条消息都有作者。服务端从发送提示的凭据中读取作者：项目成员，或另一会话的智能体。运行时对话记录不携带此信息。

```ts
const { authors, initial_author } = await beeuni.session(projectId, sessionId).messageAuthors()
// getSessionMessageAuthors(projectId, sessionId) is the same call as a function.

const author = authors[runtimeMessageId] // undefined when the sender is unknown
```

`authors` 以运行时消息 id 为键。`initial_author` 是派生会话第一条消息的父会话。如果会话没有首条提示，则为 `null`。每位作者都是一个 `SessionMessageAuthor`：

| `kind` | 字段 |
| --- | --- |
| `'member'` | `user_id`、`name`、`email`（`string \| null`） |
| `'session'` | `session_id`、`name`（会话标题）、`agent`（其运行的智能体，如有名称） |

<a id="which-model-answered-a-turn"></a>

## 哪个模型回答了某一轮

对话记录中的消息会标明该轮请求的模型。当项目的[回退链](/docs/zh/project/models) 使用其他模型回答时，只有网关的请求记录会保存实际回答的模型及 Beeuni 的计费金额。

```ts
const { latest, billed_cost, turns } = await beeuni.session(projectId, sessionId).modelUsage()
// getSessionModelUsage(projectId, sessionId) is the same call as a function.

latest?.served_model   // 'glm-5.3-flash': the model that answered the newest request
latest?.fallback_from  // 'codex/gpt-6.1-sol' when a fallback model answered, else null
turns[userMessageId]   // { served_models, fallback_from, billed_cost } for one turn
```

首次回答前，`latest` 为 `null`。`billed_cost` 是 Beeuni 为此会话的模型调用扣除的美元金额。`turns` 以启动每轮的提示的运行时消息 id 为键。`served_models` 列出该轮中作答的模型，按请求次数从多到少排列。在 React 中，`useSessionModelUsage(projectId, sessionId)` 读取同一记录；在一轮结束时调用其 `refetch()`。

在 React 中，`useSessionMessageAuthors(projectId, sessionId, messageCount)` 读取相同数据。传入对话记录的消息数。只有新消息才会添加作者，因此消息数变化时，查询会重新获取数据。

<a id="readiness-is-a-handshake"></a>

## 就绪需要握手确认

发送提示前，调用 `ensureReady()`。它会在需要时配置沙箱，等待运行时启动，并返回解析得到的运行时。

```ts
const { runtimeSessionId, runtimeUrl, sandboxId } = await s.ensureReady();
```

冷启动时，`ensureReady()` 可能抛出 `RUNTIME_UNAVAILABLE`。参见[冷启动时重试](#retry-on-a-cold-boot)，了解其含义及重试方法。

`s.send()` 和 `s.abort()` 会代你调用 `ensureReady()`。

<a id="seed-a-server-authorized-runtime-session-pin"></a>

### 预置服务端授权的运行时会话固定值

服务端渲染的 React 宿主可以提供已为同一 Beeuni 会话持久化的运行时会话固定值：

```tsx
const session = useSession(projectId, sessionId, {
  initialRuntimeSessionId: persistedSession.runtime_session_id ?? persistedSession.opencode_session_id,
});
```

预置值仅在 `/start` 运行时用于填充缓存的对话记录内容，不会覆盖运行时身份。`/start` 返回的固定值具有权威性，会替换过时的预置值。

`opencode_session_id` 是 `runtime_session_id` 的已弃用名称。两者携带相同的值；仅按上述方式将旧名称作为回退读取。

不要接受来自不可信租户选择器的此值。不要在宿主中创建运行时会话。Beeuni 会创建并持久化根会话。运行时查询缓存和对话记录控制器的作用域限定于沙箱运行时，因此不同沙箱中相同的运行时会话 id 不会共享缓存条目。

切换会话时，待处理消息的重试会保留其原始沙箱 URL。`404` 或 `410` 消息响应会停止自动重试，但不会清除对话记录。显式协调可以重试读取，并在响应成功后恢复正常同步。

<a id="send-a-prompt"></a>

## 发送提示

```ts
s.setModel({ providerID, modelID }); // sticky for later send() calls
s.setAgent('build'); // sticky for later send() calls

await s.send('Refactor the auth module');
await s.send('One-off task', { model, agent }); // overrides for this call only
await s.abort(); // stop the current run
```

`send()` 将提示放入会话的持久化提示收件箱（`POST .../prompts`），这也是网页输入框和 CLI 使用的路径。它在提示存储完成时返回收件箱行（`prompt_id`、`state`、`message_id`），而不是在该轮结束时返回。从 `s.stream()` 或对话记录中读取回复。

对于 运行环境 REST 会话，句柄上的首次 `send()` 会读取 Beeuni 会话中持久化的模型和智能体。这可防止从快照继承的 运行环境 会话复用过时的快照默认值。

提示选项的优先级为：

1. `send()` 调用。
2. 句柄的 `setModel()` 或 `setAgent()` 值。
3. 持久化的 Beeuni 会话默认值。

`setModel` 仅选择下一次本地 `send` 所请求的内容，不会传出句柄。要在服务端为正在运行的会话**持久化**新模型，请使用 `changeModel`：

```ts
const { applied_live } = await s.changeModel('anthropic/claude-opus-4-8');
```

此更改通过重启运行时生效，因此正在进行的轮次会结束。
如果正在运行的会话已立即应用更改，`applied_live` 为 `true`；如果将在下次启动时应用，则为 `false`。只有会话所有者或具有项目管理者权限的调用者可以更改模型；其他人会收到 `403`。

`send()` 解析运行时，然后向其发送提示。`abort()` 停止当前运行，但不删除会话。

<a id="session-scope-and-cost"></a>

## 会话作用域与费用

读取已存储的密钥范围限制和已落实的连接绑定。
`secrets_allowlist: null` 表示应用智能体的密钥授权：

```ts
const scope = await s.scope();
scope.connector_bindings_configured; // false = inherits the project defaults
```

`connector_bindings` 是解析后的映射，因此，覆盖了连接器的会话与继承项目默认设置的会话在这里看起来相同。在渲染作用域或将其发回之前，读取 `connector_bindings_configured` 以区分两者。

替换一个或两个作用域字段：

```ts
await s.rescope({
  secrets: ['DATABASE_URL'],
  connector_bindings: {
    github: { connection_id: connectionId },
  },
});
```

每个提供的字段都会完整替换其先前的值。省略字段可保持其不变。连接更改将在下一次工具调用时生效。
移除密钥会停止后续传递，但无法从模型上下文或现有进程中移除已披露的值。

两个维度都有明确的方式恢复默认设置。这与空值不同：

```ts
await s.rescope({
  secrets: null, // inherit the agent's secret grant
  connector_bindings: null, // drop the override; inherit the project defaults
});
```

`secrets: []` 和 `connector_bindings: {}` 是相反的指令：明确表示“不使用项目密钥”和“完全不使用连接器”，包括项目默认设置。如果会话本意是发送 `null`，却发送了 `{}`，则所有未指明的别名都会按默认拒绝原则被禁用。

读取统一费用记录：

```ts
const cost = await s.cost();
```

该记录汇总了已结算的 LLM 费用、已计费的沙箱计算费用、模型用量、token 总数、计算时长和账本条目。`s.cost()` 不会调用 `ensureReady()`。

<a id="runtime-status-and-previews"></a>

## 运行时状态与预览

| 方法                      | 返回值                        | 用途                                          |
| --------------------------- | ------------------------------ | -------------------------------------------- |
| `s.health(init?)`           | `{ status, ok, health, body }` | 检查运行时是否存活           |
| `s.previewUrl(port, path?)` | `string`                       | 获取智能体暴露端口的代理 URL |
| `s.proxyUrl(url?)`          | `string \| undefined`          | 重写智能体输出的 localhost URL    |

```ts
const { ok, health } = await s.health();
const url = s.previewUrl(3000, '/docs');
```

`s.health()` 从不抛出异常。可随时调用，即使会话尚无运行时。`s.previewUrl()` 和 `s.proxyUrl()` 需要已解析的运行时——请先调用 `s.ensureReady()`，否则它们会抛出 `SessionNotReadyError`。参见[会话就绪错误](#session-readiness-errors)。

<a id="what-the-runtime-supports"></a>

### 运行时支持的功能

`health.capabilities` 列出运行时提供的会话功能。使用 `runtimeSupports(capabilities, capability)` 读取某项功能，在返回 `false` 时隐藏对应控件。运行环境 会话提供全部十项功能；pi 会话仅提供 `session.subagents`，其他功能均返回 `501 feature_not_supported`（配置文档返回 `404`）。[Harnesses](/docs/zh/work/harnesses) 提供完整的 运行环境 和 pi 功能矩阵。

| 能力 | 功能 |
| --- | --- |
| `session.rewind` | 回退到某条消息，并恢复它 |
| `session.compact` | 按需总结对话 |
| `session.commands` | 项目斜杠命令 |
| `session.fork` | 在某条消息处分叉会话 |
| `session.subagents` | 子智能体的子会话 |
| `session.mcp` | 运行时自行连接的 MCP 服务器 |
| `session.todo` | 运行时的待办列表 |
| `session.shell` | 作为一个轮次运行的 shell 命令 |
| `session.attach` | 附加 harness 自带的终端客户端 |
| `session.config` | 运行时配置文档（`/global/config`）；无论哪种情况，智能体和模型都来自项目 |

```ts
import { runtimeSupports } from '@beeuni/sdk';

const { health } = await s.health();
if (!runtimeSupports(health?.capabilities, 'session.compact')) hideCompact();
```

在健康检查响应到达之前，以及运行时未列出 `session.*` 能力时，`runtimeSupports` 返回 `true`：在能力机制出现之前构建的守护进程运行的是 运行环境，它提供所有功能。在 React 中，使用 [`useRuntimeSupports`](/docs/zh/sdk/react)。

`start` 响应也携带该列表：`SessionStartResult.capabilities` 通过 `stage: 'ready'` 设置。`useSession` 会记录它，因此在首次健康探测响应到达之前，能力门控就已准确。

<a id="status-words"></a>

## 状态用词

所有宿主使用同一套词汇，因此同一种状态在每个界面上都使用同一个词。

| 导出                                     | 用途                                                                                          |
| ------------------------------------------ | -------------------------------------------------------------------------------------------- |
| `sessionListStatus(session, reviewCount?)` | 列表中的会话：`needs-you`、`starting`、`running`、`done`、`stopped`、`failed`、`legacy` |
| `SESSION_LIST_STATUS[status]`              | 该状态的 `{ label, tone, description }`                                               |
| `sessionConnectionLabel(connection)`       | 会话计算机的简短标签；对于 `unknown` 和 `live`，则为 `null`                  |
| `SESSION_NOTICE`                           | 计算机尚未就绪时，消息编辑器显示的句子                                      |
| `turnRetryLabel(secondsLeft)`              | `Retrying in 5s`、`Retrying now`，或在倒计时未知时显示 `Waiting to retry`           |

色调包括 `actionable`、`live`、`progress`、`muted` 和 `danger`。绿色仅用于 `live` 和 `actionable`；已结束的会话为 `muted`。对于较新 API 发送、但当前构建无法识别的状态，显示 `stopped`，绝不会显示 `failed`。

<a id="streaming"></a>

## 流式传输

在脚本或服务器中使用 `s.stream()` 接收实时事件。在 React 应用中，改用 [`useSession`](/docs/zh/sdk/react)——它会为你管理整个会话生命周期。

`s.stream()` 是兼容 运行环境 REST 的事件流。Beeuni API 从沙箱代理该事件流。没有单独的 WebSocket 端点。传输方式为 `fetch`，带有流式响应体，通过 `ReadableStream` 和 `TextDecoderStream` 读取。SDK 负责重连、退避和心跳检查。

流式接收会话事件：

1. 先调用 `ensureReady()`。沙箱启动之前，运行时并不存在。
2. 在发送消息之前打开事件流，以免错过早期事件。
3. 发送消息。
4. 看到 `session.idle` 时关闭事件流。

```ts
const session = beeuni.session(projectId, sessionId);
const { runtimeSessionId } = await session.ensureReady();

const stream = await session.stream({
  onEvent: (event) => {
    if (event.type === 'session.idle' && event.properties.sessionID === runtimeSessionId) {
      onTurnDone();
      stream.close();
    }
  },
});

await session.send('Refactor the auth module');
```

事件流按批次传递事件，批次间隔为 16 ms。同一批次中，针对同一个 part 字段的连续 `message.part.delta` 事件会合并为一个事件。其 `properties.delta` 是按顺序拼接的文本。其 `id` 是最后一个事件的 id。其 `coalesced` 数组列出被替代的事件。像处理单个增量一样，将 `delta` 追加到该 part 的字段。

流式传输需要 `fetch`，以及真正的 `ReadableStream` 响应体和 `TextDecoderStream`。浏览器、Node 18 及更高版本、Bun 和 Cloudflare Workers 均支持。

如果宿主的 `fetch` 无法进行流式传输，则需提供自己的传输实现。SDK 每次连接调用它一次，并自行管理重连、`Last-Event-ID` 续传、退避和心跳：

```ts
import { configureBeeuni, notifyHostSignal, type RuntimeEventTransport } from '@beeuni/sdk';

const transport: RuntimeEventTransport = async function* ({ url, headers, signal }) {
  // Yield { data, id?, retry? } for each server-sent message of ONE connection.
  // Return when the server ends it. Throw (with `status` for a non-2xx answer)
  // when it fails.
};

configureBeeuni({ backendUrl, getToken, eventStreamTransport: transport });

// A host without `visibilitychange` / `online` events reports them itself.
notifyHostSignal('visible'); // the app is back in the foreground
notifyHostSignal('online');  // the network is back
notifyHostSignal('retry');   // a person asked to reconnect now
```

React Native 默认没有支持流式传输的 `fetch`。Beeuni 移动应用通过此扩展接口使用 `react-native-sse`。对于只需要有限历史记录和状态同步的宿主，仍可使用 `createHttpSessionSyncController`。

控制器首先加载最新的 10 条消息。`loadOlder()` 沿服务器游标继续加载。`loadHttpSessionHistory()` 会遍历所有游标，用于显式导出。

<a id="event-types"></a>

### 事件类型

每个事件都有一个 `type` 和一个存放其数据的 `properties` 对象，例如 `event.properties.sessionID`。

| `type`                                          | 触发时机                                       |
| ----------------------------------------------- | --------------------------------------------------- |
| `message.updated` / `message.removed`           | 消息发生更改或被删除。                   |
| `message.part.updated` / `message.part.removed` | 某个部分（文本、工具调用、文件）增加了内容或被移除。 |
| `session.status`                                | 会话的忙碌状态发生变化。                   |
| `session.idle`                                  | 本轮结束。                                  |
| `session.error`                                 | 本轮失败。事件携带错误信息。       |
| `question.asked`                                | 智能体请求输入。                          |
| `question.replied` / `question.rejected`        | 收到问题的回答。                   |

使用 `classifyTurn` 将原始消息及其各部分转换为可渲染的输出。参见
[SDK 参考](/docs/zh/sdk/reference)。

<a id="retry-on-a-cold-boot"></a>

## 冷启动时重试

`ensureReady()` 轮询会话的 `/start` 端点——每次调用最多长轮询
30 秒——直到运行时进入终止阶段 `ready`/`failed`/`stopped`，
或超过截止时间（`readyTimeoutMs`，默认约 180 秒）。对于已预热的会话，
首次轮询会立即以 `ready` 完成。冷启动时，只要沙箱报告
`retriable: true`，它就会持续轮询，因此启动缓慢只会增加等待时间，
而不会抛出异常。只有在截止时间到期时，它才会抛出带有 `code:
'RUNTIME_UNAVAILABLE'` if the runtime is still not `ready` 的 `ApiError`。

`ensureReady()` 是幂等的，因此针对同一会话的并发调用会共享
一个 `/start` 请求，而不是发送多个请求。下面的 `retryUntilReady` 辅助函数
现在是可选的——`ensureReady()` 已在内部重试——但如果你需要比默认
`readyTimeoutMs` 更长的总等待时间，它仍然有用。

```ts
async function retryUntilReady<T>(ensure: () => Promise<T>): Promise<T> {
  const deadline = Date.now() + 300_000;
  for (;;) {
    try {
      return await ensure();
    } catch (error) {
      const provisioning = error instanceof ApiError && error.code === 'RUNTIME_UNAVAILABLE';
      if (!provisioning || Date.now() > deadline) throw error;
      await new Promise((r) => setTimeout(r, 3_000));
    }
  }
}
```

完整的 `ApiError` 结构请参见[错误类](#error-classes)。在 React 中，
[`useSession`](/docs/zh/sdk/react) 会替你重试 `/start`，因此无需
采用此模式。

<a id="what-start-tells-you"></a>

## `/start` 告诉你什么

每个 `/start` 响应描述的都是**本次调用**，而非该行的累积
历史。这些信息由四个字段承载。

| 字段 | 含义 |
| --- | --- |
| `observed_at` | 整个响应共用一个时钟。 |
| `action` | 服务器执行的操作：`inspected`、`checked_provider`、`resumed`、`provisioned`、`restored`、`reconciled`、`awaited_wake`、`cooling_down`。 |
| `observation` | 服务器检查的内容。`known: false` 表示**本次调用未检查**，绝不表示“已检查但未发现任何内容”。 |
| `boot` | `phase`（`provisioning` / `resuming` / `booting` / `ready` / `parked` / `failed`）、`since` 和 `actively_starting`。 |

`boot.actively_starting` 回答的是“此会话当前是否有提供商操作正在
运行？”。带有 `actively_starting: false` 的 `starting` 载荷表示
服务器正在等待重试冷却期结束，而不是计算机正在启动。

```jsonc
{
  "stage": "starting",
  "retriable": true,
  "reason": "runtime_wake_cooldown",
  "observed_at": "2026-08-26T14:00:00.000Z",
  "action": "cooling_down",
  "boot": { "phase": "resuming", "since": "2026-08-26T13:58:00.000Z", "actively_starting": false },
  "observation": {
    "provider": { "known": false, "status": null, "checked_at": null },
    "runtime": { "known": false, "state": null, "boot_phase": null, "checked_at": null }
  },
  "failure": {
    "category": "sandbox-provider",
    "message": "The runtime did not start (attempt 2). Retrying automatically.",
    "retryable": true,
    "evidence": {
      "check": "provider_not_running",
      "observed_at": "2026-08-26T13:58:00.000Z",
      "error": null,
      "attempts": 2,
      "next_retry_at": "2026-08-26T14:03:00.000Z"
    }
  }
}
```

<a id="a-failed-start-is-retried-for-you"></a>

### 启动失败后会自动重试

启动失败会标记一个**冷却期**，而非永久判定。冷却期结束后的下一次
`/start` 会自行重新尝试唤醒。冷却期随连续失败次数
增加（2 分钟、5 分钟、10 分钟）。连续失败五次后，
`/start` 会返回 `stage: "failed"`，并在
`failure.message` 中包含尝试次数；该判定在最后一次失败 30 分钟后过期，
`POST …/restart` 则会立即清除它。

每次调用都会推导 `retriable`。服务器仍可重新尝试的状态
绝不会携带 `retriable: false`。

`failure.evidence` 指明产生否定结果的检查、检查执行时间以及
服务器重试时间。每个 `/start` 失败都会携带它。

<a id="prompt-attachments"></a>

## 提示词附件

用户将本地文件添加到输入框时上传这些文件。私有上传归属于
项目，无需正在运行的会话。

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

const uploads = usePromptAttachments(projectId);
// File picker, paste, and drop handlers call uploads.add(file) or uploads.addMany(files).
// Render uploads.attachments. Refuse Send only while an attachment is `error` or `aborted`.

async function submit() {
  const ids = uploads.attachments.map((item) => item.id);
  uploads.submit(ids); // Send never waits: the composer clears and uploads continue.
  paintMessage(text, ids);
  try {
    const parts = await uploads.whenReady(ids);
    await beeuni.session(projectId, sessionId).prompts.create({
      clientMessageId,
      messageId,
      parts: [{ type: 'text', text }, ...parts],
    });
    uploads.forget(ids);
  } catch {
    uploads.reclaim(ids);
  }
}
```

`whenReady(ids, { signal })` 在所有上传就绪后按 `ids` 顺序兑现。
如果某个上传失败、中止或被移除，或 `signal` 中止，它会拒绝。
`submit(ids)` 将条目交给本次发送：它们离开 `attachments`，不再
计入限额，并在输入框卸载后继续上传。
POST 成功后调用 `forget(ids)`；发送失败后调用 `reclaim(ids)`，将它们退回
输入框。
兑现后的文件部分包含 `attachment_id`、`filename` 和 `mime`。
它不包含 URL 或文件字节。服务器会绑定上传并将其实体化。
每个文件都会到达会话的计算机，包括图片和 PDF：在
提示词运行之前，服务器将文件写入 `/workspace/uploads/.beeuni-inbox/`，
提示词通过路径引用它。提示词本身不携带文件字节。开启
历史保存时，服务器还会为对话记录保留一份副本。复制失败
绝不会阻止文件到达计算机。如果服务器无法写入某个文件，
提示词会暂停并重试。
在平台会话创建、已预热会话启动或提示词收件箱
路由中使用这些部分。不要将私有句柄直接传给 运行环境 运行时 `sendParts`。
带有 `url` 的旧版平台文件部分仍然可用。

每个条目都有稳定的 `id`、原始 `file` 引用、`receivedBytes`，以及
一个状态：`pending`、`uploading`、`processing`、`ready`、`error` 或 `aborted`。
进度由已发送的字节数决定。控制器仅在进度变化达到整数百分比时发布进度快照，
每个上传每秒最多十次。`processing` 可能
一直保持在 100%，直到服务器完成上传。限额为每个
文件 50 MiB、每条消息 100 MiB，以及 20 个文件。文件不能为空。默认
同时上传两个文件。

`retry(id)` 使用同一句柄恢复上传。在 `attachment_size_mismatch` 或
`attachment_failed` 之后，服务器不再保留可用句柄，因此 `retry(id)` 会将
File 作为新附件重新上传。过期的上传无法重试：`retry(id)`
会抛出异常，条目错误会携带代码 `attachment_expired`。`abort(id)` 会取消
未完成的工作。
`remove(id)` 移除条目、中止其上传，并立即兑现。
服务器 DELETE 会尽力执行，且绝不会拒绝；无法删除的上传
会在 24 小时后过期。
`forget(ids)` 释放条目，但不删除服务器对象。
此 hook 会在卸载或项目变更时中止列表中的工作，并尽力删除其上传：
没有发送操作持有它们，草稿也不保留句柄。已移交的上传
会继续进行。

所选内容仅保存在内存中。切勿在草稿中持久化 File 对象、blob URL、签名
URL 或上传句柄。被放弃的上传会在 24 小时后过期。

不使用框架的宿主使用 `beeuni.project(projectId).attachments.createController()`，
方法相同。通过 `subscribe(listener)` 订阅并读取
`getSnapshot()`。输入框关闭时调用 `dispose()`。
直接调用方可以使用 `attachments.upload(file, { signal, onProgress, onUpload,
resume })` and `attachments.delete(attachmentId)`。
保留 `onUpload` 元数据，以便手动使用同一 ID 恢复上传。

服务器通过句柄的 `upload` 字段选择传输方式：

- `kind: 'direct'` 为默认值。SDK 会将整个文件作为一个 `PUT` 发送到
  `upload.url`，并携带 `upload.headers`，不带 Authorization 标头。使用
  `XMLHttpRequest`（浏览器、React Native）时，进度报告已发送的字节数。使用
  `fetch` 时，进度先报告 0，然后报告完整大小。过期的 URL，或 Storage 以 400、401
  或 403 拒绝的 URL，会针对相同的 `attachment_id` 重新签名一次。Storage 返回
  `409` 表示较早的尝试已经存储了文件。
- `kind: 'chunked'` 会依次发送经过身份验证的 `PUT`，每个包含 `upload.chunk_size`
  个字节。SDK 接受任何正数 `chunk_size`。只有边缘节点会丢弃大型请求正文的部署才会选择此模式。

完成操作会验证存储的大小和 SHA-256。如果完成操作返回 `409
attachment_not_uploaded`, `onUpload`，则会使用 `received_bytes: 0` 报告直接句柄，因此 `retry(id)` 或恢复操作会再次发送文件。
发起、上传和完成操作会针对超时、网络错误、429 和 5xx 使用带抖动的指数退避进行重试。预算从首次失败开始计算，为 60 秒，因此即使长时间上传在后期失败，也仍会重试。完成操作还会重试 `attachment_processing`，预算为五分钟。发起操作绝不会重试 402
（`BillingError`：账户无法运行）或 429 `attachment_budget_exceeded`（用户有 40 个未完成的上传，或有 500 MiB 未发送的上传；未使用的上传会在 24 小时内过期）。服务器会在 105 秒内响应或拒绝一次完成操作。每个完成请求允许 120 秒。调用方中止操作绝不会重试。

已发送附件的引用会在其提示发送后 1 小时释放，也会在其会话或项目被删除时释放。下一次维护扫描随后会删除文件，其 `attachment_id` 将无法再发送。

<a id="files"></a>

## 文件

`s.files` 读取和写入会话的沙盒：`list`、`read`、`readBlob`、
`status`、`findFiles`、`findText`、`upload`、`create`、`copy`、`remove`、
`mkdir`、`rename`。每次调用都会先解析运行时，并始终以该会话自己的沙盒为目标。请参阅 [SDK reference](/docs/zh/sdk/reference) 以获取完整的方法列表。

<a id="session-verbs"></a>

## 会话动词

句柄会代你读取运行时并作出响应。每个动词都会先配置会话，并以该会话自己的沙盒为目标，无论运行它的是哪种 harness。

```ts
// A page of the transcript, oldest first: { info, parts } messages (beeuni.transcript.v1).
const { messages, hasMore } = await s.messages({ limit: 50 });
// What the agent waits on.
const { statuses, permissions, questions } = await s.pending();
await s.answerPermission(permissions[0].id, 'once'); // 'once' | 'always' | 'reject'
await s.answerQuestion(questions[0].id, [['Yes']]); // null dismisses the question
// Summarize the conversation. Needs the `session.compact` capability (see health()).
await s.compact();
```

`s.runtime`（原始 REST 兼容客户端）已弃用。请使用上述动词以及 `send`、`abort`、`rewind` 和 `stream`。

<a id="warm-a-project-session"></a>

## 预热项目会话

当项目落地页需要在首次提示前准备好一个运行时时，请调用 `ensureWarm()`。

```ts
const project = beeuni.project(projectId);
const warm = await project.sessions.ensureWarm();

// An ORDINARY session. Prompt it like any other.
await beeuni.session(projectId, warm.session.session_id).send("Build me a widget");
```

`ensureWarm()` 会为当前用户创建或返回一个未使用的会话。它与创建 `sessions.create()` 运行的操作相同，并采用项目的默认设置：相同的计费门槛、相同的连接器要求。唯一的区别是 `metadata.warm`，它会在首次提示到达前将会话隐藏于 `sessions.list()`。

请将其视为推测性操作。`409 WARM_SESSION_UNAVAILABLE` 表示项目当前无法预热——请转而调用 `sessions.create()`，该操作会报告实际原因。

预热会话携带项目的 DEFAULT agent 和沙盒。如果用户选择了不同的选项，请放弃该会话并调用 `sessions.create()`：未使用的预热会话会被隐藏并自行回收。

:::warning
`claimWarm()` 已弃用。预热会话就是普通会话，因此无需认领——导航到该会话并向其发送提示即可。对于固定使用旧形态的使用者，此调用仍然有效，并将在下一个主版本中移除。
:::

<a id="handling-errors"></a>

## 处理错误

通过 `createBeeuni` 进行的每次调用都会拒绝并抛出一个带类型的 `Error` 子类，绝不会是普通对象。捕获错误，检查 `instanceof`，然后根据 `.status` 或 `.code` 分支处理。

```ts
import { ApiError, BillingError } from '@beeuni/sdk';

try {
  await beeuni.project(projectId).sessions.create();
} catch (err) {
  if (err instanceof BillingError) {
    // 402 — out of credits or over a plan limit
  } else if (err instanceof ApiError) {
    // any other failed request — err.status, err.code, err.detail
  } else {
    throw err;
  }
}
```

<a id="error-classes"></a>

### 错误类

| 类 | 继承自 | 抛出时机 | 关键字段 |
| ---------------------- | ---------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| `ApiError` | `Error` | 任何请求失败时的默认类：状态错误、网络故障、超时或中止 | `status`、`code`、`detail`、`response`、`url`、`endpoint`、`timeout` |
| `AuthError` | `ApiError` | `getToken` 返回了 `null`。Beeuni 从未发送请求 | `code` 始终为 `'NO_SESSION'` |
| `BillingError` | `ApiError` | HTTP `402`。唯一的计费错误类 | `status`（`402`）、`code`、`detail.message` |
| `RequestTooLargeError` | `ApiError` | HTTP `431`。通常表示单个请求中的文件过多 | `detail.suggestion` |
| `SessionNotReadyError` | `Error` | 运行时访问器在 `ensureReady()` 之前运行 | `name` 为 `'SessionNotReadyError'` |

`ApiError.name` 默认为 `'ApiError'`。以下两种情况会覆盖该值：

- `name: 'AbortError'`、`code: 'ABORTED'` —— 请求已取消，例如因导航而取消。这不是失败。请忽略它。
- `code: 'TIMEOUT'` —— 请求自身的超时时间已到。`url`、`endpoint` 和 `timeout` 会显示超时的内容。

对于任何其他失败，`status` 保存 HTTP 状态码。`code` 来自后端的 `error_code`，如果没有则回退为字符串形式的状态码。`message` 是 `ApiError` 上可枚举的自有属性，因此会在 `JSON.stringify` 和对象展开后保留。

当 Beeuni 收到 `401` 时，如果该令牌不同于它发送的令牌，Beeuni 会使用从 `getToken()` 获取的新令牌再次发送请求。带有一次性流正文的请求不会重新发送。

Beeuni 会在代码看到错误之前重试某些请求。如果 `GET` 或 `HEAD` 请求返回 `502`、`503` 或 `504`，Beeuni 会最多重试 2 次，延迟分别为 250 毫秒和 500 毫秒。`GET` 或 `HEAD` 上的瞬时传输故障——网络错误，而非状态码——也会以相同方式重试。成功的重试不会到达 `onError`。Beeuni 绝不会重试 `POST`、`PUT`、`PATCH` 或 `DELETE` 请求，也不会重试 `500` 响应。

当 `getToken()` 返回 `null` 时，Beeuni 会在发送请求之前于客户端抛出 `AuthError`。`AuthError` 继承自 `ApiError`，因此 `err instanceof ApiError` 仍然匹配。请检查 `err instanceof AuthError` 或 `err.code === 'NO_SESSION'`，将“未登录”与后端故障区分开来。

对于每个 HTTP `402` 响应，Beeuni 都会抛出 `BillingError`：积分不足、超出套餐限制或其他计费门槛。`detail.message` 保存后端提供的原因，`code` 保存后端的机器代码（例如 `app_account_unfunded`）。`BillingError` 继承自 `ApiError`，因此同时处理两者时请先检查 `BillingError`。

对于 HTTP `431`，Beeuni 会抛出 `RequestTooLargeError`。这通常表示请求携带了过多文件。`detail.suggestion` 保存可直接向用户展示的提示。

<a id="session-readiness-errors"></a>

### 会话就绪错误

有两个错误表示会话的沙盒尚未就绪。请分别处理它们。

在此会话句柄尚未解析出其沙箱之前，如果调用运行时访问器（`session.previewUrl()` 或 `session.proxyUrl()`），`SessionNotReadyError` 会同步抛出错误。会话句柄只会解析自己的沙箱，绝不会回退到其他会话的沙箱。

```ts
import { SessionNotReadyError } from '@beeuni/sdk';

const s = beeuni.session(projectId, sessionId);
try {
  const url = s.previewUrl(3000); // throws: not resolved yet
} catch (err) {
  if (err instanceof SessionNotReadyError) {
    await s.ensureReady();
  }
}
```

请先调用 `await session.ensureReady()`，或调用 `send()`，后者会在内部准备好会话。`session.health()` 是唯一不会抛出此错误的访问器，因此可以在会话启动前轮询它。

`RUNTIME_UNAVAILABLE` 是第二种错误，表示 `ensureReady()` 本身在等待冷启动时超时。完整模式请参阅[冷启动时重试](#retry-on-a-cold-boot)。在 React 中，`useSession` 会自动为你重试，并通过 `phase` 值暴露此错误，而不是抛出它。

<a id="helpers"></a>

### 辅助函数

| 辅助函数                           | 签名                           | 作用                                                                                                  |
| -------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `parseBillingError(error)`       | `(error) => Error`                  | 将 `402` 响应封装为 `BillingError`。其他错误原样返回                                  |
| `isBillingError(error)`          | `(error) => boolean`                | 返回 `error instanceof BillingError`                                                                       |
| `formatBillingErrorForUI(error)` | `(error) => BillingErrorUI \| null` | 对非计费错误返回 `null`。否则返回 `{ alertTitle, alertSubtitle }`，用于升级弹窗 |
| `isRuntimeNotReadyResponse(error)` | `(error) => boolean` | 当会话运行时尚无法接收请求时，对沙箱守护进程的 `503` 返回 true。请求未被转发，因此可以安全重试。在 运行环境 和 pi 上结果相同 |
| `isSandboxNotReadyError(error)` | `(error) => boolean` | 对上述情况以及已停止、正在启动或已停驻的沙箱返回 true。将其显示为“正在唤醒”，并继续轮询 |
| `isRuntimeStartingError(error)` | `(error) => boolean` | 对上述两种情况以及尚未固定的运行时 URL 返回 true。错误边界会据此重试，而不是显示崩溃 |

```ts
import { formatBillingErrorForUI } from '@beeuni/sdk';

try {
  await beeuni.session(projectId, sessionId).start();
} catch (err) {
  const ui = formatBillingErrorForUI(err);
  if (ui) showUpgradeModal(ui.alertTitle, ui.alertSubtitle);
}
```

<a id="in-beeunisdkreact"></a>

### 在 `@beeuni/sdk/react` 中

`@beeuni/sdk/react` 重新导出 `BillingError`、`RequestTooLargeError`、`parseBillingError`、`isBillingError` 和 `formatBillingErrorForUI`。它不重新导出 `ApiError` 或 `AuthError`，请从 `@beeuni/sdk` 导入它们。

`useSession` 将每个 `send`、`answerQuestion`、`answerPermission` 和 `rejectQuestion` 失败归类到一个 `sendError` 对象中，因此无需手动编写 `instanceof` 检查：

```ts
interface BeeuniSendError {
  kind: 'billing' | 'runtime-not-ready' | 'runtime-error';
  message: string;
  billing?: BillingError; // set when kind is 'billing'
  cause: unknown;
}
```

```tsx
const s = useSession(projectId, sessionId);

if (s.sendError?.kind === 'billing') {
  const ui = formatBillingErrorForUI(s.sendError.billing);
}
```

有关 `useSession` 的其余内容，请参阅 [React hooks](/docs/zh/sdk/react)。
