首页
/ opencode 的 @opencode-ai/schema 包设计指南:线协议契约、命名规范与事件契约治理

opencode 的 @opencode-ai/schema 包设计指南:线协议契约、命名规范与事件契约治理

2026-09-06 13:47:55作者:霍妲思

本文基于仓库内 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.jsonpackages/core/package.json 中可以看到 "@opencode-ai/schema": "workspace:*" 的 workspace 依赖,印证了这一单向依赖结构:schema 处于最底层,不反向依赖任何上层包。

两个值得注意的边界决策:

  1. 领域包可以为 SDK 生成保留最小线契约。文档给出的现行例子是 plugin.added:Schema 只拥有最小浏览器安全事件载荷,插件的运行时行为留在 Schema 之外。对照 packages/schema/src/plugin.ts 源码,该模块全部实现只有 13 行——一个带品牌类型的 ID 和一个 define({ type: "plugin.added", schema: { id: ID } }),确实是一个“最小载荷”的范例。
  2. 根桶文件导出规范契约,专项模块走直连入口点packages/schema/package.json 的 exports 配置为 "." 指向 ./src/index.ts,同时 "./*" 指向 ./src/*.ts,意味着任何 src 下的模块都可以被直连导入(如 @opencode-ai/schema/session-event),而不必成为一级根导出。packages/schema/src/index.ts 只重导出了 SessionPermissionQuestionPty 等规范领域命名空间,以及 packages/schema/src/schema.ts 中的公共组合子(optionalstatics 等),与文档“专用事件模块、manifest、基础设施模块和 V1 契约使用直连入口点”的说明一致。

二、Current 与 V1:双版本契约的命名与隔离策略

opencode 正处于会话模型的代际迁移中,AGENTS.md 用一整节规定了两个世代契约的命名规则:

规则 说明
当前契约不加版本号 直接用 SessionPermissionQuestion 这样的名字,标识符形如 Permission.Request
遗留契约显式带 V1 为兼容、持久化或迁移而保留的契约用 SessionV1PermissionV1,标识符形如 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 的遗留契约(MessageIDPartID 等)。

这条隔离规则不是口头约定,而是由 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 行):

  • idevt_ 前缀的事件 ID;
  • typeSchema.Literal 固定的事件类型字符串;
  • durable:可选的持久化元信息 { aggregateID, seq, version },用于跨版本的事件持久化;
  • location:可选的 Location.Ref,把事件绑定到项目/工作区上下文;
  • metadataRecord<string, unknown> 的透传通道(注意这里用的是 Schema.Unknown 而非 Schema.Any,见第六节);
  • data:调用方传入的领域字段结构体。

同时 define() 会调用 .annotate({ identifier: input.type }) 给每个事件打上稳定标识符,并通过 statics 静态方法把 typedurabledata 挂在 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.versionversionedType() 拼出的 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 要求新增事件进入公共清单前,先按协议角色分类:currentshared transitionalV1-only,并强调“被 V1 发出”不足以让事件进入 Protocol / SDK Next。文档点名 message.updatedmessage.part.* 这类明确的 V1-only 事件应留在当前 Protocol / SDK Next 事件面之外,只服务于既有 App/TUI/CLI 兼容面。测试中的断言印证了这一点:

  • EventManifest.Latest.has("ide.installed") 必须为 falseevent-manifest.test.ts 第 43 行),即 IDE 事件只在专项模块中存在、不进入公共最新面;
  • EventManifest.Durable.get("session.next.step.ended.2") 必须精确等于 SessionEvent.Step.Ended第 51 行),验证 durable 版本键的解析结果;
  • Session.EventSessionEvent 必须是同一引用(第 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.tsexport * as Event from "./event"。消费方因此总可以写成 Plugin.IDSessionTodo.Info 这种“命名空间成员”形式,从结构上避免了 PluginIDPtyInfo 这类文档明令禁止的桥接别名。

4.2 核心可以合成门面,但必须转发规范值

文档允许 core 包把 Schema 契约与运行时行为组合成领域门面(facade),但前提是门面必须重新导出完全相同的规范 Schema 值,不得创造第二个 schema 身份。这同样有测试背书:event-manifest.test.tstoBe(引用相等)断言门面投影与规范模块是同一对象。

4.3 ID 模块的取舍

独立的 ID 模块(如 packages/schema/src/session-id.tspackages/schema/src/workspace-id.ts)只在“能防止真实循环依赖或重依赖边”时保留;无循环时,一次性 ID 应内联进所属契约模块。这与事件定义中直接 import SessionID 的做法一致。

4.4 大小写与组合子命名

对象 命名规则 源码实例
导出的 schema 值、命名空间对象 PascalCase SessionSessionTodoEvent
Schema 构造函数与组合子 camelCase defineinventorylatest
静态方法组合子 固定为 statics(...) packages/schema/src/schema.ts#L20-L23
描述性基础 schema 保留语义化名字 PositiveIntNonNegativeIntAbsolutePathRelativePathDateTimeUtcFromMillis

最后四项都能在 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 键

文档对可选性给了三级策略:

  1. 对象属性(含嵌套结构体与事件载荷)一律用包内 optional(...) 辅助函数,使编码后的对象省略 undefined 键;
  2. 原生 Schema.optional(...) 仅当“显式保留 undefined 作为编码属性”是有意且被记录的行为时使用
  3. 对外便捷默认值通常用 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 中的 savemetadatasource 字段(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 是标准范例,且注意其 statuspriority 字段用的是 Schema.Stringdescription 注释说明建议取值(pending/in_progress/completed/cancelledhigh/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.UnknownSchema.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 有五条规则,逐条对照源码:

  1. 当前 ID 构造器暴露 create()contract-hygiene.test.ts 第 31-34 行 验证 Question.ID.create()que_ 开头、Pty.ID.create()pty_ 开头。
  2. ascending()/descending() 方向构造器仅保留在“排序语义属于公共契约”或“兼容要求旧方法”的场景packages/schema/src/v1/session.ts 中遗留的 MessageIDPartID 仍用 ascending() 构造器,属于兼容保留;而当前契约如 event.ts 中的事件 ID 则只暴露 create()
  3. 新生成的 ID schema 必须精确校验自身发出的前缀(含下划线)。事件 ID 即 Schema.String.check(Schema.isStartsWith("evt_"))event.ts 第 9 行)。
  4. 不得在未做显式兼容与迁移决策前收紧遗留宽松校验器。V1 中的 MessageID 只校验 isStartsWith("msg")PermissionV1 系只校验 perpermission.ts 第 10 行 当前契约亦如此),因为既有调用方与测试可能依赖这些宽松校验接受的非规范 ID。
  5. 可复用的公共 schema 获得稳定的、带领域限定的标识符,如 Model.RefAgent.Color;公共标识符与品牌必须唯一且稳定,私有一次性嵌套 schema 可以匿名。唯一性由 contract-hygiene.test.ts 第 36-54 行 保证:提取 Agent.ColorModel.RefModel.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.tsencodeSync 断言
当前契约中不出现 Schema.Any 同上文件的源码扫描断言(第 56-66 行
公共标识符稳定且唯一 同上文件的 ast.annotations?.identifier 唯一性断言
门面与 Schema 身份完全一致 event-manifest.test.tstoBe 引用相等断言、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.tspermission.tsquestion.tslegacy-event.ts)已存在且被 v1-isolation.test.ts 校验,说明该隔离已落地,V1 子树按规范仍属计划删除的临时兼容层
  • 事件清单的数量断言(55/85/32)是当前快照的事实,新增事件后需要按文档流程同步调整;
  • 本文所有路径均相对仓库根目录,可直接在仓库中定位对应源码与测试继续深入。
登录后查看全文
热门项目推荐
相关项目推荐