Event Envelope:每个事件必须带的身份证
SessionEvent 是一个 discriminated union:{type, seq, time, data, surfaceOp?, sourceEventSeqs?, ignorable?}。seq 从0连续编号,type 通过 declare module 可扩展。ignorable 默认false——未知事件类型拒绝resume,宁错勿漏。
你收到一个事件流,里面有个 type: "plugin/telemetry-v2" 事件。你的代码不认识这个类型。怎么办?
很多框架的选择是:忽略它,继续处理。Harness 的选择是:默认拒绝。 除非那个事件明确标记了 ignorable: true,否则你遇到不认识的事件类型,必须拒绝恢复整个 session。宁可不恢复,也不要在一个被悄悄裁剪过的日志上继续运行——静默的数据损坏比崩溃更可怕。
这就是 Event Envelope 设计哲学的核心:refuse over silent corruption。
这一章表面在讲字段,骨子里只守一个原则:事件信封不是为了“多带点元数据”,而是为了让一条日志在未来仍然可验证、可恢复、可拒绝。
所以这些字段不是装饰品,各有分工:type 决定语义和 data 形状,seq 保证因果顺序不破,time 负责打时间戳,ignorable 告诉恢复器“未知时能不能跳过”,surfaceOp / sourceEventSeqs 只服务于模型可见投影。
把这点想清楚,后面那些规则——未知类型默认拒绝、只有三类事件能带 surface 元数据、seq 不连续直接报错——就不再像一堆零碎限制,而是同一套保守策略。
信封上的字段
每个进入日志的 SessionEvent 都有一个固定的结构。它不是一个随意的 {type, payload},而是一个带完整元数据的信封:
type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
type: K
seq: number // 从0连续,等于进入log时的log.length
time: number // Date.now(),append时自动加盖
data: SessionEventMap[K] // 每种type对应不同的data shape
ignorable?: true // 标记为纯信息事件,可安全跳过
} & (K extends SurfaceEventType ? {
sourceEventSeqs?: number[] // 引用的早期事件seq
surfaceOp?: SurfaceOp // 'append' | {op:'replace',start,end}
} : object)
}[T]
这是一个 discriminated union(可辨识联合),不是独立的 type/data 交叉类型。意思是 switch (event.type) 能自动窄化 event.data 的类型,不需要手动 as cast。TypeScript 编译器知道当 event.type === 'tool/call' 时,event.data 一定是 {turn, step, callId, name, arguments}。
seq 和 time 是 append 时自动赋值的,你不需要也不能传。seq = this.log.length,这保证了从 0 开始连续无缺。构造 seed 时也会校验 snapshot.seq !== index 直接抛错。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "seq: this.log.length" "$repo/packages/core/session/src/index.ts"
grep -n "time: Date.now" "$repo/packages/core/session/src/index.ts"
事件类型清单
SessionEventMap 定义了所有核心事件类型。按功能分组:
边界标记类(log-only,不入 surface):
turn/start /turn/end:回合边界,带 turn 编号和结束原因step/start /step/end:步骤边界,一次模型调用+工具执行session/end-seed:标记构造 seed 结束,区分”历史”和”本次进程写入”
模型交互类:
user/message:用户消息(surface事件,模型可见)assistant/chunk:流式 token 块(log-only,用于 replay fidelity,不入 surface)assistant/message:组装后的助手回复(surface事件,模型可见)tool/call:模型请求的工具调用(log-only,记录原始 arguments 字符串,不入 surface)tool/result:工具执行结果(surface事件,模型可见)
状态类(增量 fold):
request/header:请求头快照,latest-wins foldrequest/context:路由元数据,latest-wins foldtodo/write:Todo 列表全量快照,log-only UI 状态
关键区分:只有三类事件是 surface 事件——user/message、assistant/message、tool/result。只有这三类能携带 surfaceOp 和 sourceEventSeqs,也只有这三类会进入模型看到的消息历史。其他所有事件都是 log-only,用于 replay、调试、UI 或增量 fold,但不影响模型上下文。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "type SurfaceEventType" "$repo/packages/core/session/src/types.ts"
grep -n "SurfaceEventType =" "$repo/packages/core/session/src/types.ts" -A3
ignorable:宁错勿漏
`ignorable 字段是整个事件系统里最被低估的安全设计。
默认情况下(字段不存在即默认),事件是必需的。当一个 reader(比如恢复 session 的代码)遇到一个它不认识的 `type,它必须拒绝恢复,而不是跳过这个事件继续。为什么?
因为一个未被识别的必需事件可能改变后续所有事件的解释方式。假设未来版本加了一个 context/rewrite 事件,它的语义是”把之前所有 tool/result 的内容替换为加密版本”。老版本 reader 不认识这个事件,静默跳过,然后用明文 tool/result 继续跑——结果在错误的上下文上做了错误的决策。这叫 silent corruption(静默损坏),比直接崩溃危险得多。
标记 ignorable: true 的事件是纯信息性的。它们的丢失不影响状态重建。比如遥测事件、UI 装饰事件、性能统计——跳过它们,session 的语义完全不变。
默认值选”必需”而非”可忽略”,是因为:如果开发者忘记标记,结果是过度拒绝(resume 时报错,开发者发现并修复),而不是静默损坏(数据被悄悄丢弃,无人知晓直到出大问题)。
flowchart TD
A["Resume时读取事件"] --> B{"认识type?"}
B -->|是| C["正常处理"]
B -->|否| D{"ignorable === true?"}
D -->|是| E["安全跳过\n继续处理"]
D -->|否| F["REFUSE to resume\n抛出错误"]
style F fill:#8b0000,color:#fff
style E fill:#006400,color:#fff
事件类型是可扩展的。插件通过 TypeScript 的 declare module 合并到 SessionEventMap 里添加自己的事件类型。但扩展时必须想清楚:你的事件是影响重建语义的(不加ignorable,老版本无法恢复),还是纯信息性的(加ignorable,老版本安全跳过)。
request/header 和 request/context 的增量 fold
这两个事件类型有特殊语义:它们不是独立事件,而是状态的增量快照。
request/header 是”下一个请求的完整 header”。每次 step 开始、dispatch 之前,当前的 EpochHeader 全量快照被记录为一个 request/header 事件。恢复时不是合并所有 header,而是取最后一个——latest-wins fold。但为什么要记录每一次而不是只记录最终值?因为它要让你能回答”发第 N 个请求时用的 header 是什么”——这是调试和 replay 必须的。
request/context 同理,记录路由元数据的变化。只有当 route 或 capacity 改变时才记录。
assistant/chunk 和 tool/call 是 log-only 但很重要。Chunk 保留了 token 级的流式回放保真度——你可以从日志重建出打字机效果。Tool/call 保留了模型输出的原始 arguments JSON 字符串(未解析),这样即使工具参数解析逻辑变了,你也能看到模型当时到底输出了什么。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "request/header" "$repo/packages/core/session/src/types.ts" -A2
grep -n "assistant/chunk" "$repo/packages/core/session/src/types.ts" -A1
grep -n "tool/call" "$repo/packages/core/session/src/types.ts" -A1
容易踩的坑
坑一:自定义事件忘加 ignorable。 你写了个插件追加自定义事件类型,没有 ignorable: true。用户升级插件版本后,老版本的 session 恢复时遇到这个未知类型直接报错,用户以为数据丢了。纯遥测/UI事件必须标记 ignorable。
坑二:给 log-only 事件传 surfaceOp。 编译器层面就禁止了——append() 的类型签名用 T extends SurfaceEventType ? [opts: SurfaceIntent] : [] 条件类型,非 surface 事件传 opts 会报 TS 错误。但如果你用 any 绕过了编译器,运行时 surfaceManager 也会校验并抛错。
坑三:以为 tool/call 进入模型历史。 模型看到的是 tool/result,不是 tool/call。Tool/call 只在日志里记录原始调用信息。模型历史投影只包含 user/message、assistant/message(非空content)、tool/result。
坑四:sourceEventSeqs 必须是更早的 seq。 你不能引用一个还没发生的事件。校验逻辑要求所有 sourceEventSeqs < event.seq,而且不能重复。
这章讲完了,还没讲什么
你知道了事件信封长什么样,哪些事件进 surface,ignorable 怎么保护你。但 surface 本身是什么?三类事件进入 surface 之后怎么排列?surfaceOp: 'append' 和 {op:'replace',start,end} 有什么区别?为什么模型看到的历史和原始日志不是同一份?
下一章讲 Surface 投影——模型看到的 ≠ 你看到的。