> ## Documentation Index
> Fetch the complete documentation index at: https://docs.meoo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 事件流与 Action

Agent Run 通过 Server-Sent Events（SSE）输出公开进度、回复文本、工具输入、积分结算、预览地址和交互请求。

## 订阅事件

```text theme={null}
GET /projects/{project_id}/agent/runs/{run_id}/events
```

所需权限：`agent.read`

```text theme={null}
curl --no-buffer \
  --url "${MEOO_BASE_URL}/open/v1/projects/${PROJECT_ID}/agent/runs/${RUN_ID}/events" \
  --header "Authorization: Bearer ${ACCESS_TOKEN_OR_API_KEY}" \
  --header "Accept: text/event-stream"
```

Token 必须放在 Authorization Header 中；不要放入 URL Query。

Write/Edit 正文随事件流默认发送，不需要额外 Query 参数或 Scope。Read 文件名、Skill 名称及工具正文均使用普通 `agent.read` 授权；客户端必须忽略未知事件及新增字段。

## SSE 帧

每个业务事件帧由 `id`、`event` 和 JSON `data` 组成：

```text theme={null}
id: 1
event: run.working
data: {"run_id":"run_01JEXAMPLE","status":"working"}
```

* `id` 只在本次连接内从 1 单调递增。
* 心跳使用 SSE 注释帧，不包含业务数据，也没有 `id`。
* 服务端不支持 `Last-Event-ID` 断点续传。
* 首个终态事件之后，服务端关闭连接。

## 事件语义

| 事件                    | 客户端处理                                                 |
| :-------------------- | :---------------------------------------------------- |
| `run.working`         | 标记 Run 正在处理；只包含 `run_id` 和 `status`                   |
| `tool.call`           | 按 `tool_call_id` 展示工具 `started` / `completed` 进度      |
| `tool.input.delta`    | 按 `tool_call_id` 和 `field` 追加 Write/Edit 白名单字段的文本增量   |
| `tool.input.snapshot` | 整体替换同一工具调用当前公开输入的全量状态                                 |
| `message.delta`       | 把 `delta` 追加到当前回复末尾                                   |
| `message.snapshot`    | 用 `content` 整段替换当前回复；存在 `tool_calls` 时同时整体替换当前轮公开工具状态 |
| `preview.ready`       | 使用短时 Preview Link 打开当前可用预览                            |
| `run.input_required`  | 展示 Action，收集输入后调用回复接口                                 |
| `run.usage`           | 按 `usage_id` 去重后，将本次结算的 `credits_used` 累加到 Run 总消耗    |
| `run.completed`       | 正常终态                                                  |
| `run.failed`          | 失败终态                                                  |
| `run.canceled`        | 取消终态                                                  |
| `run.interrupted`     | 中断终态                                                  |
| `run.superseded`      | 当前 Run 被取代；保留旧 Run 的真实终态，并切换到可选 `current_run_id`      |

`tool.call` 的已知 `phase` 为 `analyzing`、`building`、`validating`、`coordinating`、`finalizing`。未知 phase 仍按进行中展示。

`tool.call` 的基础字段包含安全的工具名、状态、阶段和展示文案，不包含原始参数、目录路径或原始执行结果。支持以下结构化扩展：

* Read 可携带 `file_name`，只含安全 basename，不含目录、读取内容、offset、limit 或 URL 查询信息。
* Skill 可携带 `skill`，其中 `skill_name` 为服务端解析的 canonical 名称，`display_name` 为可选展示名；展示时使用 `display_name ?? skill_name`。不公开技能正文、配置、凭证或加载结果。
* `status: "completed"` 可携带 `outcome`（`succeeded`、`failed`、`canceled`）。`completed` 仅表示调用结束，实际结果以 `outcome` 为准。

## 积分结算

每次 Agent 计量 END 成功并返回有效积分时，事件流会发送一条 `run.usage`：

```text theme={null}
id: 8
event: run.usage
data: {"run_id":"run_01JEXAMPLE","usage_id":"usage_0123456789abcdef0123456789abcdef","credits_used":"128"}
```

* `credits_used` 是这一次结算的积分，不是整个 Run 的累计值；它使用非负十进制整数字符串，`"0"` 是有效值。
* 同一个 Run 在阻塞后恢复或发生 Session 轮换时可能有多次结算，因此可能收到多条 `run.usage`。
* 重连时缓存窗口内的结算事件可能重放。`usage_id` 对同一次结算保持稳定，客户端必须先按它去重，再对 `credits_used` 求和。
* 计量 END 失败或没有返回有效积分时不会伪造 `run.usage`。
  未收到 `run.usage` 不代表零消耗；事件用于本次运行的结算展示，不应把缺失事件伪装为零值。
* 最终一条 `run.usage`（若有）会先于 `run.completed`、`run.failed`、`run.canceled` 或 `run.interrupted`；这些正常终态是该连接最后一个业务事件。

一个简单的累计器可以写成：

```text theme={null}
const seenUsageIds = new Set<string>();
let totalCreditsUsed = 0n;

function applyUsage(data: { usage_id: string; credits_used: string }): void {
  if (seenUsageIds.has(data.usage_id)) return;
  seenUsageIds.add(data.usage_id);
  totalCreditsUsed += BigInt(data.credits_used);
}
```

## Write/Edit 输入流

默认会收到两类工具输入事件。`tool.input.delta` 使用追加语义：Write 只产生 `field: "content"`；Edit 只产生 `field: "old_string"` 或 `field: "new_string"`。

```text theme={null}
id: 3
event: tool.input.delta
data: {"run_id":"run_01JEXAMPLE","tool_call_id":"toolu_01","tool_name":"Edit","field":"old_string","delta":"const retries = 1;"}
```

`tool.input.snapshot` 是该 `tool_call_id` 的权威全量状态，必须替换本地工具输入，而不是继续追加：

```text theme={null}
id: 4
event: tool.input.snapshot
data: {"run_id":"run_01JEXAMPLE","tool_call_id":"toolu_01","tool_name":"Edit","file_name":"config.ts","input":{"old_string":"const retries = 1;","new_string":"const retries = 3;","replace_all":false},"complete":true}
```

* Write 的 `input` 只使用 `content`；Edit 使用 `old_string`、`new_string`，最终快照还可能带 `replace_all`。
* Edit 删除内容时 `new_string` 可以是空字符串，且可能没有对应 delta；以最终 snapshot 为准。
* `file_name` 只含 basename，可能在无法安全解析时省略；同名文件仍必须用 `tool_call_id` 区分。
* `complete: false` 表示参数仍在生成；`complete: true` 只表示参数生成完成，不表示工具执行成功，随后应读取 `tool.call.outcome`。
* `truncated: true` 表示公开投影达到大小或并发限制；Agent 内部仍使用完整参数执行。
* 不保证 `file_name` 先于内容 delta 到达；客户端必须容忍字段顺序和事件交错。

客户端按 `tool_call_id` 分别维护状态：delta 追加到对应 `field`，snapshot 整体替换 `file_name`、`input`、`complete` 和 `truncated`。断线重连时缓存窗口内可能先收到 snapshot，用它覆盖重连前的本地累计值。

## 最终与重连工具快照

服务端从持久化消息生成的 `message.snapshot` 会附带 `tool_calls`，它是当前轮 Read、Write、Edit、Skill 公开状态的权威全量快照。实时文本更新产生的瞬态 `message.snapshot` 可能省略该字段：字段存在时整体替换本地工具列表，字段缺省时保留已有工具列表。

```text theme={null}
{
  "run_id": "run_01JEXAMPLE",
  "content": "应用已经更新完成",
  "tool_calls": [
    {
      "tool_call_id": "toolu_write",
      "tool_name": "Write",
      "status": "completed",
      "file_name": "config.ts",
      "input": { "content": "export const retries = 1;" },
      "outcome": "succeeded"
    },
    {
      "tool_call_id": "toolu_edit",
      "tool_name": "Edit",
      "status": "completed",
      "file_name": "config.ts",
      "input": {
        "old_string": "export const retries = 1;",
        "new_string": "export const retries = 3;",
        "replace_all": false
      },
      "outcome": "succeeded"
    },
    {
      "tool_call_id": "toolu_read",
      "tool_name": "Read",
      "status": "completed",
      "file_name": "config.ts",
      "outcome": "succeeded"
    },
    {
      "tool_call_id": "toolu_skill",
      "tool_name": "Skill",
      "status": "completed",
      "skill": { "skill_name": "react-design", "display_name": "React 设计" },
      "outcome": "succeeded"
    }
  ]
}
```

* 快照只包含当前轮，即最近一条公开用户消息之后的工具调用，并按调用顺序排列。
* Read 仍然只有 basename 文件名；Write/Edit 使用与实时事件完全相同的白名单正文；Skill 名称仍由服务端目录解析。
* `status: "completed"` 表示找到了配对的持久化工具结果，`outcome` 是安全归一化状态。原始工具结果不会进入快照。
* 最多返回 64 个工具调用，Write/Edit 正文继续受实时投影相同的字段和 Run 总大小限制；达到限制时可能带 `truncated: true`。
* 如果消息存储暂不可用，服务端退化为实时事件或纯文本快照，不会阻塞 Run 终态。

## 文本 reducer

客户端维护每个 Run 的单一文本缓冲区：

```text theme={null}
type AgentTextEvent =
  | { event: 'message.delta'; data: { delta: string } }
  | { event: 'message.snapshot'; data: { content: string; tool_calls?: unknown[] } };

function reduceAgentText(current: string, item: AgentTextEvent): string {
  if (item.event === 'message.snapshot') {
    return item.data.content;
  }
  return current + item.data.delta;
}
```

`message.snapshot` 可能在首次连接、重连和终态前出现。`content` 始终是当前完整文本；`tool_calls` 存在时是当前轮完整公开工具状态。两者使用替换语义；缺省的 `tool_calls` 不应清空已有工具列表。

## 预览事件

```text theme={null}
id: 4
event: preview.ready
data: {"run_id":"run_01JEXAMPLE","url":"https://meoo.com/open/preview/opaque-preview-ticket","expires_at":1787652600000}
```

只有内部 dev server 状态精确变为 `running`，并且短时 Preview Link 签发成功时，服务端才发送 `preview.ready`。`starting`、无效内部地址或签发失败都不发送 `preview.ready`，也绝不会向 Open API 客户端暴露 raw Sandbox URL。

`url` 是调用方应打开的短时 opaque Preview Link，不要依赖 host 或 path 形状；`expires_at` 是必填的 Unix 毫秒过期时间。

链接过期或需要续期时，第三方后端使用受保护的 `POST /projects/{project_id}/agent/preview-links` 重新签发；恢复仍在进行时接口返回 `202`，按 `Retry-After` 重试。不要等待 SSE 一定再次产生预览事件，也不要把 opaque Ticket 写入日志或长期存储。公开的 `GET /open/preview/{preview_ticket}` 只是 HTML 壳页面，不接受 API 凭证，也不会恢复 Sandbox。

## 断线重连

网络断开时：

1. 保留当前 `run_id`、已展示文本和 UI 状态。
2. 使用有限次数、带抖动的指数退避重新 GET 同一事件端点。
3. 不发送 `Last-Event-ID`，也不使用重复 start 代替重连。
4. 收到 `message.snapshot` 后整段替换已有文本；若存在 `tool_calls`，同时整体替换当前轮工具状态，缺省则保留已有工具状态。
5. 对重放的 `run.usage` 按 `usage_id` 去重，再累计字符串形式的 `credits_used`，不要重复计费展示。
6. 收到终态后停止重连。

该端点当前使用 60 秒固定限频窗口。收到 `429` 时，`Retry-After: 60` 是固定窗口的安全等待上界，不是剩余 TTL；等待后增加少量随机抖动再重连。

页面刷新且不知道当前 Run 时，先调用 `GET .../agent/runs/current`，再决定是否订阅，详见 [会话与恢复](https://alidocs.dingtalk.com/i/nodes/EpGBa2Lm8aZxe5myCz6RNDa0WgN7R35y)。

## `run.input_required`

可回复的交互事件包含 `action`：

```text theme={null}
id: 5
event: run.input_required
data: {"run_id":"run_01JEXAMPLE","status":"input_required","action":{"action_id":"signed-action-token","kind":"answers","prompt":"请选择目标平台","input_schema":{"type":"object","required":["answers"]},"expires_at":1785900000000}}
```

| 字段             | 说明                           |
| :------------- | :--------------------------- |
| `action_id`    | 短期签名 Token；原样回传，不得解析或修改      |
| `kind`         | `answers` 或 `confirmation`   |
| `prompt`       | 向用户展示的问题或确认说明                |
| `input_schema` | `response` 必须满足的 JSON Schema |
| `expires_at`   | Action 过期时间，Unix 毫秒          |

如果事件没有 `action`，而是 `detail: "unsupported_action"`，表示当前阻塞工具不支持通过 OpenAPI 回复。客户端应停止自动提交，并引导用户到 Meoo Web 处理。

只要阻塞工具仍有效，每次建连或重连都会签发新的 `action_id` 和 `expires_at`；长连接存续时，服务端会在当前 Token 半 TTL 时续签。客户端必须用最新事件替换旧 Token。

## 回复 Action

```text theme={null}
POST /projects/{project_id}/agent/action-responses
```

所需权限：`agent.run`

`action_id` 与项目和 Run 绑定，且只能在有效期和正确状态下使用。

### 问答回复

`kind=answers` 时，`answers` 包含 1～4 项；键长 1～1000，值为 1～10000 字符的非空字符串。服务端会先 trim 每个 key 和 value，再校验并传给 Agent；trim 后为空或产生重复 key 会返回 `400`。

```text theme={null}
curl --request POST \
  --url "${MEOO_BASE_URL}/open/v1/projects/${PROJECT_ID}/agent/action-responses" \
  --header "Authorization: Bearer ${ACCESS_TOKEN_OR_API_KEY}" \
  --header "Content-Type: application/json" \
  --data "$(jq -n --arg action_id "${ACTION_ID}" '{
    action_id: $action_id,
    response: {
      answers: {
        target_platform: "Web"
      }
    }
  }')"
```

### 确认回复

`kind=confirmation` 时，`decision` 为 `approve` 或 `reject`：

```text theme={null}
{
  "action_id": "signed-action-token",
  "response": {
    "decision": "approve"
  }
}
```

只有 `action.input_schema` 声明允许 `comment` 时才能附带非空 `comment`：

```text theme={null}
{
  "action_id": "signed-action-token",
  "response": {
    "decision": "reject",
    "comment": "请先移除高风险操作"
  }
}
```

成功响应是恢复后的 Run 对象。继续消费原 Run 的 SSE；如果原连接已关闭，则重新订阅该 `run_id`。

## Action 错误处理

| HTTP                           | 场景                     | 处理                         |
| :----------------------------- | :--------------------- | :------------------------- |
| `400`                          | Token 或 response 形状不合法 | 按 `input_schema` 修正输入      |
| `404`                          | Action 与项目不匹配，或资源不可访问  | 不枚举项目/Run，刷新当前状态           |
| `409 action_expired`           | Action Token 已过期       | 重新订阅 Run，等待新的 Action Token |
| `409 action_already_responded` | Action 已回复             | 恢复 Run 状态，不重复提交            |
| `409 run_conflict`             | Run generation 或状态已变化  | 查询 current 或重新订阅           |

Action 回复没有通用 `Idempotency-Key`。请求结果不确定时，先恢复 Run 状态；不要用新的 start 请求模拟 Action 回复。
