事件流与统一事件包络
/api 下有四条事件流,各自的帧载荷由所属领域子合同定义。统一事件包络不改变这些载荷,而是在你显式要求时给每一帧套上一层同形的外壳,让不同领域的流可以用同一段代码判断「这是哪个领域、游标到哪了、有没有终态」。
| 操作 | 路径 | 领域 | 帧里有什么 |
|---|---|---|---|
session.events.observe |
GET /api/sessions/:id/events |
session |
内核事件(msg.*、tool.*、turn.*、session.*)与 server.* 控制帧 |
archive.events.observe |
GET /api/archive/bindings/:id/events |
archive |
archive.status、archive.records-available、material.request、material.status、binding.status |
terminal.executor.events.observe |
GET /api/terminal/executors/:id/events |
terminal |
operations-available、operation-cancelled、reconcile-required |
terminal.output.observe |
GET /api/terminal/sessions/:id/tool-output |
terminal |
output.block、output.status |
传输层与旧前缀相同:text/event-stream,首帧 retry:,注释帧 : hb 心跳,断线后以 Last-Event-ID 续流。会话流的帧形与重放细节见 v2 协议与 webhook。
- 请求头
tansr-event-envelope: unified-v1,大小写敏感、精确匹配。服务端在流响应上回响同名头,这是协商成立的唯一信号——没有回响就按未协商处理(@tansr/api-client/api抛envelope_not_negotiated),不要猜。 - 不带头:
/api的流与/v2、/v3/sdk2的流逐字节相同,便于你用旧的解析器先切前缀、再切包络。 - 带头访问非流路由(例如
GET /api/sessions/:id):头被忽略,不回响、不改体。你可以用同一份请求头配置访问同一会话的 JSON 与 SSE 端点。 - 值不是
unified-v1(含空串、重复头):访问任何/api路由都 400invalid_request(detail.header: tansr-event-envelope)。
协商成立后,每一帧的 data: 变成一个固定 7 键的对象;id:、event:、retry: 行与注释帧原位保留。
{ "contract": "unified-v1", "eventId": "42", "domain": "session", "type": "turn.completed", "cursorSet": { "eventCursor": "42", "archiveCoverage": null, "outputWatermark": null, "materialConsumed": null, "ackReceipt": null }, "terminalStatus": "completed", "raw": { "type": "turn.completed", "seq": 42, "sessionId": "0a1b2c3d", "turnId": "t-3" }}| 键 | 含义 |
|---|---|
contract |
恒 unified-v1 |
eventId |
本帧的 SSE id:;没有 id: 的帧(如 gap 帧)为 null,不合成、不继承上一帧 |
domain |
受理领域,与响应头 tansr-domain 同源 |
type |
事件类型:取 SSE event: 名,缺席时取 raw.type / raw.eventType,再缺席为 null(视为未知事件) |
cursorSet |
五个游标位,不适用的位为 null,见下节 |
terminalStatus |
null / accepted / completed / aborted / unknown,见下节 |
raw |
原 data: 对象原样放入——每一帧都有,包括服务端不认识的事件类型;内部结构仍由各领域子合同约束 |
包络不输出 payload 或 seq:载荷就在 raw,流位置由 cursorSet.eventCursor 表达。只有 data: 是 JSON 对象的帧会被包络;非 JSON 文本、数组、标量、空 data: 的帧原样透传。
| 游标 | 来源 | 推进它的帧 |
|---|---|---|
eventCursor |
本帧 SSE id:(会话流为 seq,档案流为 cursor,执行器流为 eventId) |
每一帧各自给出,不继承 |
archiveCoverage |
档案流 archive.status 的 payload.acknowledgedCoverage({ fromSequence, throughSequence, headDigest }) |
只有已确认覆盖推进它;archive.records-available 的 publishedThroughSequence 是发布水位,不填这里 |
outputWatermark |
工具输出流 output.block 的 block.seq |
用于以 afterSeq 续订输出 |
materialConsumed |
档案流 material.status 且 state === 'core-consumed' 时的 payload.materialRequestId |
其它材料状态不推进 |
ackReceipt |
事件流里恒为 null |
ACK 回执经 HTTP 响应或 archive.operation.query 返回,不走事件流 |
五个游标互不替代:续订会话流只用 eventCursor(放进 Last-Event-ID),续订输出流只用 outputWatermark,判断档案是否已确认到某处只看 archiveCoverage。拿一个去续另一个不是「近似正确」,而是合同违例。
terminalStatus 判定
Section titled “terminalStatus 判定”terminalStatus 只在原事件明确表达终态时非 null;SSE 连接结束、服务端 EOF、HTTP 202 都不产生终态。
| 流 | 事件 | terminalStatus |
|---|---|---|
| 会话 | turn.completed、session.ended |
completed |
| 会话 | turn.aborted;turn.error 且 recoverable === false |
aborted |
| 会话 | server.* 控制帧、msg.*、tool.*、未知类型 |
null |
| 档案 | material.status:received → accepted(只表示受理);core-consumed → completed;rejected → aborted;pending / verified → null |
如左 |
| 档案 | binding.status:closed → completed;active / backpressured / closing → null |
如左 |
| 档案 | archive.status、archive.records-available、material.request |
null |
| 执行器 | operation-cancelled → aborted;operations-available、reconcile-required → null(对账要求不是终态) |
如左 |
| 工具输出 | output.status:complete → completed;available / receiving / truncated / gap / unavailable → null;output.block → null |
如左 |
unknown 保留给无法判定结果的终态;收到它时按「结果未知」处理——查询状态,而不是重发。
续流、心跳与 gap
Section titled “续流、心跳与 gap”- 续流:重连时把最后消费帧的
cursorSet.eventCursor放进Last-Event-ID。续流的帧同样会被包络。 - 心跳:
: hb注释帧原样透传,不进包络;不要把它当作事件,也不要把长时间没有业务帧当作结束。 - gap:会话流的
server.replay.gap没有id:,包络后eventId与eventCursor都是null,type为server.replay.gap,raw里有requestedAfterSeq、oldestRetainedSeq、droppedEvents。收到它走 L2 重建视图,见 v2 协议与 webhook。 - 帧大小:
@tansr/api-client/api缺省单帧上限 2 MiB(maxEventFrameBytes),超限抛payload_too_large本地错误而不是静默截断。
用 @tansr/api-client/api 消费
Section titled “用 @tansr/api-client/api 消费”import { createApiClient, isTerminalEvent } from '@tansr/api-client/api';
const api = createApiClient({ baseUrl, token: () => loadEndUserToken(), eventEnvelope: 'unified-v1' });
let lastEventId: string | null = null; // resume cursor = eventCursor of the last consumed frameconst stream = api.events('session.events.observe', { params: { id: sessionId }, ...(lastEventId === null ? {} : { lastEventId }),});for await (const frame of stream) { const { envelope } = frame; if (typeof envelope.cursorSet.eventCursor === 'string') lastEventId = envelope.cursorSet.eventCursor; if (envelope.type === 'msg.text.delta') render(String(envelope.raw['text'] ?? '')); if (isTerminalEvent(envelope)) break;}events() 只在服务端回响了协商头时才产出 { id, event, envelope };流自然结束而没有终态帧时,循环会退出但 isTerminalEvent 从未为真——这正是「EOF 不等于完成」的代码表达,你的宿主应据此报告结果未知,而不是显示成功。
本文是否有帮助?
感谢反馈,我们会持续改进这篇文章。