opencode 的 @opencode-ai/schema 包设计指南:线协议契约、命名规范与事件契约治理
本文基于仓库内 packages/schema/AGENTS.md 这份 Schema 包设计指南展开,系统讲解 @opencode-ai/schema 包的职责边界、Current/V1 双版本契约策略、事件契约清单(Event Manifest)机制,以及可选字段、命名、ID 生成与测试等方面的工程规范,并结合 packages/schema/src/ 下的实际源码与 packages/schema/test/ 下的测试用例,说明这些规范是如何被落地和强制校验的。读完后你将掌握在 opencode 这类 Effect Schema 驱动的 TypeScript 单体仓库中,如何组织一份“浏览器安全、可序列化、可再生成 SDK”的共享契约包。
一、Schema 包是什么:浏览器安全的共享契约层
AGENTS.md 对 @opencode-ai/schema 的定义只有一句话,但信息量很大:
它拥有被 protocol、server、core 以及生成的 SDK 共享的、浏览器安全的线协议(wire)与存储(storage)契约;运行时行为、服务层、副作用以及宿主本地实现细节,应保留在拥有它们的领域包中。
也就是说,这个包只放“契约”(可序列化的合同定义),不放“服务实现”。其依赖方向被明确规定为:
@opencode-ai/schema <- @opencode-ai/protocol <- @opencode-ai/server
从 packages/protocol/package.json 与 packages/core/package.json 中可以看到 "@opencode-ai/schema": "workspace:*" 的 workspace 依赖,印证了这一单向依赖结构:schema 处于最底层,不反向依赖任何上层包。
两个值得注意的边界决策:
- 领域包可以为 SDK 生成保留最小线契约。文档给出的现行例子是
plugin.added:Schema 只拥有最小浏览器安全事件载荷,插件的运行时行为留在 Schema 之外。对照 packages/schema/src/plugin.ts 源码,该模块全部实现只有 13 行——一个带品牌类型的ID和一个define({ type: "plugin.added", schema: { id: ID } }),确实是一个“最小载荷”的范例。 - 根桶文件导出规范契约,专项模块走直连入口点。packages/schema/package.json 的 exports 配置为
"."指向./src/index.ts,同时"./*"指向./src/*.ts,意味着任何src下的模块都可以被直连导入(如@opencode-ai/schema/session-event),而不必成为一级根导出。packages/schema/src/index.ts 只重导出了Session、Permission、Question、Pty等规范领域命名空间,以及 packages/schema/src/schema.ts 中的公共组合子(optional、statics等),与文档“专用事件模块、manifest、基础设施模块和 V1 契约使用直连入口点”的说明一致。
二、Current 与 V1:双版本契约的命名与隔离策略
opencode 正处于会话模型的代际迁移中,AGENTS.md 用一整节规定了两个世代契约的命名规则:
| 规则 | 说明 |
|---|---|
| 当前契约不加版本号 | 直接用 Session、Permission、Question 这样的名字,标识符形如 Permission.Request |
遗留契约显式带 V1 |
为兼容、持久化或迁移而保留的契约用 SessionV1、PermissionV1,标识符形如 PermissionV1.Request |
不把 V2 作为永久名字 |
当前代码中残留的 V2 命名空间、brand 和标识符应在契约归一化时移除 |
V1 集中到 src/v1/ 子树 |
一旦 V1 隔离 PR 落地,遗留契约应移入专属子树,新代码不得依赖该子树 |
| V1 共存是暂时的 | 只在迁移需要处保留兼容入口点,遗留运行时退役时删除整个 V1 子树 |
源码中可以直接观察到迁移的过渡状态:packages/schema/src/permission.ts 中当前契约的品牌名仍是 PermissionV2.ID,事件类型也是 permission.v2.asked——这正是文档所说“正在从当前命名空间中移除 V2”的进行时证据;而 packages/schema/src/v1/session.ts 则保留了 SessionV1 的遗留契约(MessageID、PartID 等)。
这条隔离规则不是口头约定,而是由 packages/schema/test/v1-isolation.test.ts 以两个断言强制执行的:
- 兼容入口点必须保持身份一致:
expect(PermissionV1).toBe(IsolatedPermissionV1)等断言(第 11-16 行)要求src/permission-v1.ts这类兼容入口点与src/v1/permission.ts隔离实现是同一个对象引用,杜绝“第二个 schema 身份”。 - 当前代码不得直连 V1 子树:测试扫描
src/下所有文件,白名单外的任何文件都不得包含from "./v1/"导入(第 18-28 行)。
此外文档还明确了当前 /api/... 接口的归属:@opencode-ai/protocol 与 @opencode-ai/sdk-next 才是当前 API 面,Schema 只提供它们消费的数据契约。
三、事件契约:定义、清单与“单一规范定义”原则
3.1 事件定义的标准结构
Schema 包内所有事件都通过 packages/schema/src/event.ts 中的 define() 组合子构造。以 packages/schema/src/permission.ts 为例:
const Asked = define({ type: "permission.v2.asked", schema: Request.fields })
const Replied = define({
type: "permission.v2.replied",
schema: {
sessionID: SessionID,
requestID: ID,
reply: Reply,
},
})
export const Event = { Asked, Replied, Definitions: inventory(Asked, Replied) }
define()(event.ts 第 42-70 行)把调用方提供的字段表包进一个标准载荷结构,即 Payload 类型(第 29-40 行):
id:evt_前缀的事件 ID;type:Schema.Literal固定的事件类型字符串;durable:可选的持久化元信息{ aggregateID, seq, version },用于跨版本的事件持久化;location:可选的Location.Ref,把事件绑定到项目/工作区上下文;metadata:Record<string, unknown>的透传通道(注意这里用的是Schema.Unknown而非Schema.Any,见第六节);data:调用方传入的领域字段结构体。
同时 define() 会调用 .annotate({ identifier: input.type }) 给每个事件打上稳定标识符,并通过 statics 静态方法把 type、durable、data 挂在 schema 对象上,供清单机制读取——这就是文档“模块形状”一节要求“每个契约一个规范导出值”的底层支撑。
3.2 清单机制:inventory、latest 与 durable
event.ts 还提供了三个清单函数:
inventory(...definitions)(第 72-74 行):把事件定义数组冻结为不可变清单;latest(definitions)(第 76-92 行):按事件类型归并,durable 事件取版本号最高者为“最新”定义,若发现两个不同对象声明同一类型则直接抛Duplicate latest event definition错误;durable(definitions)(第 98-108 行):以type.version(versionedType()拼出的session.next.step.ended.2这类键)索引持久化事件,键重复即抛错。
这些函数在 packages/schema/src/event-manifest.ts 中被汇总成包的公共事件面:ServerDefinitions(第 57-61 行)只包含“foundation + feature + todo”三层核心事件,而完整的 Definitions(第 63-82 行)还额外纳入 LSP、MCP、TUI、VCS、遗留事件等周边事件。packages/schema/test/event-manifest.test.ts 用固定数量断言锁定了这一面:当前 ServerDefinitions 为 55 个、完整 Definitions 为 85 个、Latest 大小 85、Durable 大小 32——任何事件的新增、删除或重复都会让测试立刻失败。
3.3 事件的协议角色分类
AGENTS.md 要求新增事件进入公共清单前,先按协议角色分类:current、shared transitional 或 V1-only,并强调“被 V1 发出”不足以让事件进入 Protocol / SDK Next。文档点名 message.updated 与 message.part.* 这类明确的 V1-only 事件应留在当前 Protocol / SDK Next 事件面之外,只服务于既有 App/TUI/CLI 兼容面。测试中的断言印证了这一点:
EventManifest.Latest.has("ide.installed")必须为false(event-manifest.test.ts 第 43 行),即 IDE 事件只在专项模块中存在、不进入公共最新面;EventManifest.Durable.get("session.next.step.ended.2")必须精确等于SessionEvent.Step.Ended(第 51 行),验证 durable 版本键的解析结果;Session.Event与SessionEvent必须是同一引用(第 31-34 行),即“保留单一规范事件定义,不为生成便利复制定义”的测试化表达。
四、模块形状与命名规范
4.1 命名空间投影,而非扁平导出
文档要求“用扁平顶层导出加上包既有的命名空间投影模式”,例如 export * as SessionMessage from "./session-message"。这个模式在源码中随处可见:packages/schema/src/session-todo.ts 第一行即 export * as SessionTodo from "./session-todo",packages/schema/src/event.ts 是 export * as Event from "./event"。消费方因此总可以写成 Plugin.ID、SessionTodo.Info 这种“命名空间成员”形式,从结构上避免了 PluginID、PtyInfo 这类文档明令禁止的桥接别名。
4.2 核心可以合成门面,但必须转发规范值
文档允许 core 包把 Schema 契约与运行时行为组合成领域门面(facade),但前提是门面必须重新导出完全相同的规范 Schema 值,不得创造第二个 schema 身份。这同样有测试背书:event-manifest.test.ts 用 toBe(引用相等)断言门面投影与规范模块是同一对象。
4.3 ID 模块的取舍
独立的 ID 模块(如 packages/schema/src/session-id.ts、packages/schema/src/workspace-id.ts)只在“能防止真实循环依赖或重依赖边”时保留;无循环时,一次性 ID 应内联进所属契约模块。这与事件定义中直接 import SessionID 的做法一致。
4.4 大小写与组合子命名
| 对象 | 命名规则 | 源码实例 |
|---|---|---|
| 导出的 schema 值、命名空间对象 | PascalCase |
Session、SessionTodo、Event |
| Schema 构造函数与组合子 | camelCase |
define、inventory、latest |
| 静态方法组合子 | 固定为 statics(...) |
packages/schema/src/schema.ts#L20-L23 |
| 描述性基础 schema | 保留语义化名字 | PositiveInt、NonNegativeInt、AbsolutePath、RelativePath、DateTimeUtcFromMillis |
最后四项都能在 packages/schema/src/schema.ts 中找到:PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))、RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) 等。statics() 的实现则是把一个“schema → 方法表”的函数挂到 schema 对象本身(Object.assign),这正是事件 ID 上出现 .create() 方法的机制。
五、可选字段与默认值:编码时省略 undefined 键
文档对可选性给了三级策略:
- 对象属性(含嵌套结构体与事件载荷)一律用包内
optional(...)辅助函数,使编码后的对象省略undefined键; - 原生
Schema.optional(...)仅当“显式保留 undefined 作为编码属性”是有意且被记录的行为时使用; - 对外便捷默认值通常用
Schema.withDecodingDefault(...)做 decode-only 处理;构造器默认值只在领域值本身需要构造期归一化时添加。
optional() 的实现(packages/schema/src/schema.ts#L12-L18)值得细看:
export const optional = <S extends Schema.Top>(schema: S) =>
Schema.optionalKey(schema).pipe(
Schema.decodeTo(Schema.optional(Schema.toType(schema)), {
decode: SchemaGetter.passthrough({ strict: false }),
encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
}),
)
解码侧透传(保留 schema 自身的类型转换),编码侧则把 undefined 过滤掉、从而把该键从输出对象中移除。packages/schema/test/contract-hygiene.test.ts 用三个断言验证了这一行为:
const Value = Schema.Struct({ value: optional(Schema.FiniteFromString) })
expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) // 解码保留转换
expect(Schema.encodeSync(Value)({ value: 1 })).toEqual({ value: "1" }) // 编码保留转换
expect(Schema.encodeSync(Value)({ value: undefined })).toEqual({}) // undefined 键被省略
这条规则对线协议至关重要:省略键而不是发送 undefined/null,保证 JSON 负载跨语言消费时干净可解析。Permission.Request 中的 save、metadata、source 字段(permission.ts 第 25-32 行)就是该辅助函数的典型用法。
六、公共类型、未知值与可变性三条红线
6.1 同名接口模式
公共 Schema.Struct 记录必须配一个同名接口:
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({ ... })
packages/schema/src/session-todo.ts#L7-L16 是标准范例,且注意其 status、priority 字段用的是 Schema.String 加 description 注释说明建议取值(pending/in_progress/completed/cancelled、high/medium/low)——这正对应文档“若任意字符串都合法,就把字段记为 arbitrary,而不是列一个封闭集合”的规则,并有测试 contract-hygiene.test.ts 第 22-29 行 验证解码接受任意 status/priority 字符串。而封闭集合则用 Schema.Literals,例如 Permission.Reply = Schema.Literals(["once", "always", "reject"])(permission.ts 第 40 行)和 Permission.Effect = Schema.Literals(["allow", "deny", "ask"])。union、标量、数组、带品牌的标量与事件载荷辅助类型则统一用类型别名。
6.2 未知值:Any 是红线,Json 与 Unknown 是替代品
文档规定:当前公共契约避免 Schema.Any;必须 JSON 可序列化的值用 Schema.Json;真正不透明、需要消费端自行收窄的值用 Schema.Unknown;Schema.Any 只允许出现在“有记录理由的显式不安全兼容边界”。测试把这条红线固化成了源码扫描(contract-hygiene.test.ts 第 56-66 行):读取 src/ 下所有非 -v1.ts 文件,断言拼接后的源码不包含 Schema.Any 字符串。Event.define() 中 metadata 字段选择 Schema.Record(Schema.String, Schema.Unknown) 而非 Schema.Any,是同一原则在事件层的应用。
6.3 可变性:契约只读,变更发生在边界
公共 Schema 契约默认只读,禁止为了运行时便利在公共契约中使用 Schema.mutable(...)——这条同样由上面的源码扫描断言 not.toContain("Schema.mutable") 强制。运行时确实需要变更的代码,应在边界处显式选择 Types.DeepMutable、专用 draft 类型或其他显式可变 API。这与 v1/session.ts 顶部 import 了 Types 但仅用于类型层的做法吻合:可变性处理留在类型工具,而不是写进契约本身。
七、ID 与标识符:前缀、构造器与稳定性
AGENTS.md 对 ID 有五条规则,逐条对照源码:
- 当前 ID 构造器暴露
create()。contract-hygiene.test.ts 第 31-34 行 验证Question.ID.create()以que_开头、Pty.ID.create()以pty_开头。 ascending()/descending()方向构造器仅保留在“排序语义属于公共契约”或“兼容要求旧方法”的场景。packages/schema/src/v1/session.ts 中遗留的MessageID、PartID仍用ascending()构造器,属于兼容保留;而当前契约如 event.ts 中的事件 ID 则只暴露create()。- 新生成的 ID schema 必须精确校验自身发出的前缀(含下划线)。事件 ID 即
Schema.String.check(Schema.isStartsWith("evt_"))(event.ts 第 9 行)。 - 不得在未做显式兼容与迁移决策前收紧遗留宽松校验器。V1 中的
MessageID只校验isStartsWith("msg")、PermissionV1系只校验per(permission.ts 第 10 行 当前契约亦如此),因为既有调用方与测试可能依赖这些宽松校验接受的非规范 ID。 - 可复用的公共 schema 获得稳定的、带领域限定的标识符,如
Model.Ref、Agent.Color;公共标识符与品牌必须唯一且稳定,私有一次性嵌套 schema 可以匿名。唯一性由 contract-hygiene.test.ts 第 36-54 行 保证:提取Agent.Color、Model.Ref、Model.Capabilities等 schema 的ast.annotations?.identifier,断言全部为字符串且new Set(...).size等于总数。
ID 生成算法:时间前缀 + 随机后缀
packages/schema/src/identifier.ts 实现了这套 ID 的底层生成(共 26 个字符):
- 以当前毫秒时间戳(
BigInt(timestamp) * 0x1000n + counter)取高 48 位渲染为 12 个十六进制字符,作为时间前缀,因此同一毫秒内的 ID 按计数器递增——这就是ascending()的来源; descending()通过位取反(~current)翻转该数值,使同一毫秒内越晚生成的 ID 排序越靠前;- 剩余 14 个字符由
crypto.getRandomValues填充 62 进制随机字符,保证同一毫秒内的 ID 互不碰撞; - 各契约模块再把领域前缀(
evt_、que_、pty_、per_等)拼在最前面,与“精确校验前缀含下划线”的规则闭环。
八、契约变更的测试清单
AGENTS.md 最后一节要求:修改契约行为或生成面时增加聚焦测试,并列出五个必查点。这五个检查点在 packages/schema/test/ 目录中有直接落点:
| 文档要求的检查点 | 仓库中的对应测试 |
|---|---|
可选属性省略 undefined |
contract-hygiene.test.ts 的 encodeSync 断言 |
当前契约中不出现 Schema.Any |
同上文件的源码扫描断言(第 56-66 行) |
| 公共标识符稳定且唯一 | 同上文件的 ast.annotations?.identifier 唯一性断言 |
| 门面与 Schema 身份完全一致 | event-manifest.test.ts 的 toBe 引用相等断言、v1-isolation.test.ts 的兼容入口身份断言 |
| 当前 Protocol 清单排除 V1-only 事件 | event-manifest.test.ts 的清单数量与 Latest.has(...) 断言 |
其余测试文件分工明确:event.test.ts 覆盖事件定义机制本身,legacy-event.test.ts 覆盖遗留事件面,compatibility.test.ts 覆盖兼容行为。
九、适用前提小结
以上规范与实现均针对当前仓库快照中的 @opencode-ai/schema 包(private: true,唯一运行时依赖是 effect,构建类型检查使用 tsgo --noEmit)。需要注意三点适用前提:
- 文档中“V1 契约移入
src/v1/子树”表述为“once the V1 isolation PR runs”,而当前源码中src/v1/子树(session.ts、permission.ts、question.ts、legacy-event.ts)已存在且被 v1-isolation.test.ts 校验,说明该隔离已落地,V1 子树按规范仍属计划删除的临时兼容层; - 事件清单的数量断言(55/85/32)是当前快照的事实,新增事件后需要按文档流程同步调整;
- 本文所有路径均相对仓库根目录,可直接在仓库中定位对应源码与测试继续深入。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00