OpenCode 协议参考

这一页列出 OpenCode server 适配器读写的请求与事件字段,以及它们怎么映射成 ChatKit 的消息、片段、交互请求与轮次结果。接入写法见 OpenCode;字段的完整定义以 OpenCode server 文档 与 server 的 GET /doc 为准。

传输

项目说明
请求REST,路径相对 OpenCode server 根,由 createOpenCodeConnection 发出
事件一个连接一条全局 SSE:GET /global/event,每条 data: 是 { directory, payload: { type, properties } };没有 payload 的帧按 { type, properties } 读
分发按 properties.sessionID,没有时取 properties.info.sessionID、properties.part.sessionID;三处都没有的事件不分发给任何监听者
发送时序先接好事件流(响应头到达即就绪),再 POST /session/{id}/prompt_async;开头的事件不丢
认定一轮ChatKit 的 runId 写进请求里第一个文本片段的 metadata['chatkit.runId'],没有文本片段时补一个 synthetic: true 的空文本片段。收到带这个 runId 的文本片段时认定用户消息,之前收到的内容先缓冲
不用 messageIDOpenCode 要求 messageID 以 msg 开头且应递增,不能拿 runId 充当;本适配器不发 messageID,由 OpenCode 自己分配
重连事件流断开后,有 listen 监听时自动重连(见「listen 断线重连」),否则下一次 runTurn 或 listen 时重新建立

连接选项:

选项作用
resolveUrl把相对路径(如 session/{id}/prompt_async、global/event)解析成完整地址,可带鉴权参数
fetch发 HTTP 请求的函数,缺省全局 fetch;要加请求头时在这里包一层
reconnectlisten 断线后自动重新订阅的退避间隔,见「listen 断线重连」;false 关闭
historyPageSize历史每页条数,缺省 100
onError连接内部异常(事件帧解析、监听回调、事件流)的报告,kind 为 frame / listener / stream / listen

用的是 legacy 路由:/session/*、/permission/*、/question/*、/global/event,不用 v2 的 /api/*。

核验版本与稳定性

字段按 OpenCode 1.19 的 /doc OpenAPI 与实测事件序列核对。OpenCode 的协议仍在演进,升级后以包内 src/wire.types.ts 为准重新核对。

发出的请求

用途方法与路径谁发出
建会话POST /sessionconnection.createSession(body),由应用调用
发送POST /session/{id}/prompt_asyncrun
停止POST /session/{id}/abortabort,请求体 {}
历史GET /session/{id}/messagehistory
回答审批POST /permission/{requestId}/replyconnection.respond
回答追问POST /question/{requestId}/replyconnection.respond
事件流GET /global/event,accept: text/event-stream连接自己

路径里的 id 都经 encodeURIComponent。其他接口(会话列表、改名、删除、fork、模型列表、/question/{id}/reject…)不在适配器里,用 connection.request(method, path, body?) 自行调用。

POST /session

请求体原样转发 createSession 的参数(如 { title });读取响应的 id 作为 ChatKit 的 sessionId,整个响应放在返回值的 session 里。

POST /session/{id}/prompt_async

受理即返回 204,回答从事件流来。默认请求体:

字段含义来源
parts[](type: text)正文输入的正文与引用(引用转成文本)按顺序合并,相邻文本拼成一段
parts[](type: file)图片:mime、url、filename输入里的图片附件,按原位置放;url 是内联数据转成的 data URL 或 http 地址
parts[0..].metadata['chatkit.runId']认定这一轮的标记连接在 transformInput 之后写入第一个文本片段
model、agent、system 等模型、agent、系统提示默认不发;用 transformInput 加

transformInput 收到默认编码后的请求体(defaultInput),必须返回完整请求体,可以改 parts,也可以加 model: { providerID, modelID }、agent、system 等 OpenCode 认的字段。runId 标记在它之后由连接补上,删不掉。

GET /session/{id}/message

查询参数含义来源
limit每页条数historyPageSize,缺省 100
before取这条消息之前的上一页返回的 cursor

回答

接口请求体来源
POST /permission/{requestId}/reply{ reply }response 为字符串:once / always / reject
POST /question/{requestId}/reply{ answers }response 为数组:每个问题一组选中的标签,与 questions 同序

读取的事件

认定这一轮之后,连接只把属于它的事件交给解码与 transformOutput:

事件读取的字段映射成 ChatKit 的什么
message.updatedinfo.role、info.id、info.parentID、info.finish、info.time.completed、info.errorparentID 指向这轮用户消息的回答消息归入这一轮;结束条件见下
message.part.updatedpart.type、part.id、part.messageID 及各类型字段(见下表)片段新建或整体替换
message.part.deltamessageID、partID、field、deltafield 为 text 且片段已知时追加到正文 / 思考片段
permission.asked见审批与追问挂到工具片段的交互请求,阶段 awaiting-external
permission.repliedrequestID、reply交互请求 answered,decision 取 reply
question.asked见审批与追问挂到工具片段的交互请求,阶段 awaiting-external
question.repliedrequestID、answers交互请求 answered,带 answers
question.rejectedrequestID交互请求 cancelled
session.errorerror.name、error.data.message结束这一轮:MessageAbortedError 或没有 error → 中断,其它 → 失败

归属规则:回答消息看 parentID;片段与增量看 messageID 是否属于这一轮的回答消息;交互请求看 tool.messageID(没有 tool 的也归入);回复看 requestID 是否是这一轮的请求;session.error 只要分发到这个会话就归入。

片段:

part.type读取的字段ChatKit 片段
textid、text、synthetic、ignored正文 text;synthetic 或 ignored 为真时不显示
reasoningid、text思考 reasoning
toolcallID、tool、state工具调用 tool-call,见下

工具片段按 state.status:

state.status状态其它字段阶段
pendingrunningargs 取 state.input(为空时保留上一次的)tool
runningrunning同上,加 titletool
completedcompletedresult 取 state.output,titlesampling
errorfailedresult 与 error.message 取 state.error,error.code 为 OPENCODE_TOOL_ERRORtool

一轮的结束:回答消息 info.error 存在,或 info.time.completed 存在且 info.finish 有值、不是 tool-calls。finish 为 tool-calls 的回答只是一步,后面还有下一条回答消息。所有步骤拼成一条 ChatKit 回答消息。

没有内置处理的事件

这些事件或片段不变成聊天内容。能拿到它们的途径取决于是否归入这一轮:

途径收到什么
transformOutput只有上一节归属规则认定属于这一轮的事件;认定之前缓冲的内容在认定后按同一规则筛选
connection.listen(sessionId, listener)分发到这个会话的全部事件,包括不属于任何一轮的,以及本地的 request-answering / request-answer-failed
都收不到事件里没有 sessionID(properties、info、part 三处)的,连接不分发
上游事件 / 片段含义是否属于某一轮怎么拿
message.part.updated 里的 step-start、step-finish一步的开始与结束(用量、快照)是transformOutput、listen
message.part.updated 里的 snapshot、patch、agent、subtask、file、retry、compaction快照、文件补丁、agent、子任务、文件、重试、压缩是(片段在这轮的回答消息上时)transformOutput、listen
text 片段的 synthetic / ignored合成或被忽略的正文是transformOutput、listen
message.updated 里未读的字段(cost、tokens、modelID…)一步的用量与模型是transformOutput、listen
用户消息的 message.updated 与它的片段本轮的用户消息否(界面上的用户消息由输入投影)listen
message.removed、message.part.removed删除消息或片段否listen;已显示的内容不会删
session.status、session.idle会话忙 / 空闲 / 重试否(不区分哪一轮)listen
session.created、session.updated、session.deleted、session.compacted会话信息(标题等)变化否listen
session.diff会话的文件改动否listen
todo.updated待办列表否listen
command.executed执行了命令否listen
permission.v2.*、question.v2.*v2 的审批与追问否listen
session.next.*新一代会话事件否listen
没有 sessionID 的 session.error会话错误否收不到
server.connected、server.heartbeat、global.disposed、server.instance.disposed连接与实例状态否收不到
sync(payload 里是 syncEvent,没有 properties)同步事件否收不到
plugin.added、catalog.updated、reference.updated、integration.*、project.*、file.*、lsp.updated、mcp.*、pty.*、vcs.branch.updated、installation.*、workspace.*、worktree.*、models-dev.refreshed、tui.*实例级状态否收不到;自行订阅 /global/event 或用 connection.request 查

驱动插槽(如会话忙闲):

const off = connection.listen(sessionId, item => {
  if (item.kind !== 'event' || item.event.type !== 'session.status') return;
  const status = item.event.properties.status as { type: string };
  setBusy(status.type !== 'idle');
});

把一步的用量放进回答消息(实时与刷新后走同一段逻辑,见下面的历史回放):

import type { OpenCodePart } from '@volcengine/chatkit/adapters/opencode';

createOpenCodeAdapter({
  connection,
  transformOutput: (item, { messageId, defaultChanges }) => {
    if (item.kind !== 'event' || item.event.type !== 'message.part.updated') return undefined;
    const part = item.event.properties.part as OpenCodePart;
    if (part.type !== 'step-finish' || !messageId) return undefined;
    return [
      ...defaultChanges,
      {
        type: 'set-part',
        messageId,
        part: { id: `step:${part.id}`, type: 'data', kind: 'acme.opencode-step', data: part },
      },
    ];
  },
});

刷新加载历史时,适配器把历史回放成与实时同形的事件逐条交给 transformOutput(context.source 为 history,带这一轮的 runId 与 messageId):每个片段一条 message.part.updated(片段的最终状态),每条回答消息一条 message.updated。增量、审批与追问的信令只在实时里有。业务内容从回放里有的事件生成,刷新后就能还原。

审批与追问

读取的字段:

事件字段
permission.askedid、permission(如 bash、edit)、patterns、always、tool.messageID、tool.callID
question.askedid、questions[](question、header、options[].label/description、multiple、custom)、tool.messageID、tool.callID

请求挂在 tool.callID 对应的工具片段上;没有 tool 时挂在合成的工具片段上(toolCallId 为 permission:{id} / question:{id},追问的工具名为 question)。写在 metadata['chatkit.interaction'],用 getOpenCodeInteraction(part) 读:

字段审批 kind: 'approval'追问 kind: 'question'
requestIdOpenCode 的请求 id;只在 pending / answering 时有同左
statuspending / answering / answered / cancelled同左
callIdtool.callID,有 tool 时才有同左
其它permission、patterns、always、decisions(固定 ['once', 'always', 'reject'])、decision?questions、answers?

生命周期:

变化触发
→ pendingpermission.asked / question.asked;阶段 awaiting-external
pending → answering本客户端调用 connection.respond
answering → pending回答没送达,respond 抛错,可以重试
→ answeredpermission.replied(带 decision)/ question.replied(带 answers);审批等待中而工具已结束(不带 decision);question 工具 completed
→ cancelledquestion.rejected;这一轮结束时仍在等待

全部请求都有结论后阶段回到 tool。

回答:

// 审批 → POST /permission/{requestId}/reply  { "reply": "once" }
await connection.respond({ sessionId, requestId: interaction.requestId!, response: 'once' });
// 追问 → POST /question/{requestId}/reply  { "answers": [["A"]] }
await connection.respond({ sessionId, requestId: interaction.requestId!, response: [['A']] });

question 工具 state.status 为 completed 时,以 state.input.questions 与 state.metadata.answers 补出 answered 的追问交互(不带 requestId)。历史里只有这个,所以刷新后追问仍显示问题与答案。

历史

GET /session/{id}/message?limit=&before= 返回 [{ info, parts }],从旧到新。

步骤规则
截页丢掉页首第一条用户消息之前的回答消息(留给更早一页);整页没有用户消息时返回空页
游标本页返回条数不少于 limit 时给出 cursor,下一页 before 用它:取页首用户消息的 info.id;整页都是同一轮的回答步骤(一轮的步骤数超过 limit)时取这一页最早一条的 info.id,这一轮的这些步骤不显示
分组一轮 = 一条用户消息 + info.parentID 指向它的回答消息
轮次编号用户消息里第一个非合成、非忽略的文本片段的 metadata['chatkit.runId'];没有时 opencode-message:{用户消息 id}
用户消息只还原非合成、非忽略的文本片段
回答消息各步骤的片段依次经与实时相同的组装,片段 id 与实时一致;createdAt 取第一步的 info.time.created
轮次结果最后一条回答满足结束条件时给 runSnapshots:无 error → completed,MessageAbortedError → interrupted,其它 → failed;finishedAt 取 info.time.completed
进行中只给消息,不给结果,不给 activeRun

不保留:

内容说明
审批记录历史里没有交互信令,工具只有最终状态
用户图片附件用户消息只还原文本

编号

ChatKit 编号来源
sessionIdOpenCode 会话 id(POST /session 响应的 id)
runIdChatKit 生成,经 metadata['chatkit.runId'] 往返;历史缺失时 opencode-message:{用户消息 id}
用户消息 iduser:{runId}
回答消息 idassistant:{runId}
正文 / 思考片段 idOpenCode 片段 id
工具片段 id / toolCallIdtool:{callID} / callID;无 tool 的交互为 tool:permission:{id}、tool:question:{id}
交互 requestIdpermission.asked / question.asked 的 id
事件 eventId本地序号 local:{n},每轮重新计数
历史 cursor页首用户消息的 info.id(整页没有用户消息时取最早一条)

结束与错误

情况结果
回答消息 finish 不是 tool-calls 且已完成completed
回答消息 error.name 为 MessageAbortedErrorinterrupted
回答消息带其它 errorfailed:code 取 error.name(缺省 OPENCODE_ERROR),message 取 error.data.message(缺省「Agent 没能完成这一轮」)
session.error同上规则;没有 error 时 interrupted(如排队中被停止)
结束前仍在等待的交互请求标为 cancelled;非 completed 时仍在运行的工具标为 cancelled

停止是会话级的:abort 停掉这个会话正在进行的一轮,结论仍从事件里来。不以 session.idle 收口。

错误何时内容
OpenCodeRequestError任一请求或事件流返回非成功状态status、body(前 500 字符);消息为 方法 路径,不带地址,会话、审批、追问的 id 打码为 {id},路由名(如 prompt_async)保留
OpenCodeConnectionClosedError事件流断开,或 close() 之后正在读的轮次收到它,由 Core 标记连接中断,不认定执行结束
UNSUPPORTED_ATTACHMENT附件不是图片,或是需要后端取回的 resource 来源编码时抛出(cause.code),不发请求

断线后不续接:这一轮标记连接中断,由会话层经历史确认终态。listen 的监听按下文自动重连。

listen 断线重连

连接断开(或这个会话的订阅单独结束)时,有 listen 监听的会话:

  1. 监听先收到 { kind: 'connection', status: 'disconnected' };
  2. 连接按退避自动重连并重新订阅(重新打开 global/event):第一次等 reconnect.minDelayMs(缺省 1000),之后翻倍,最多等 reconnect.maxDelayMs(缺省 30000);
  3. 订阅恢复后收到 { kind: 'connection', status: 'reconnected' }。断线期间的事件不会补发,需要时自行补拉状态(如 GET /session/{id}、GET /session/{id}/todo)。

每次重新订阅失败经 onError('listen', error) 报告,之后继续重试。取消全部监听或调用 close() 后停止;连接选项 reconnect: false 关闭自动重连(仍会收到 disconnected)。正在进行的一轮不续接,仍由会话层经历史确认终态。

示例

一轮的事件(片段节选,directory 省略):

{ "payload": { "type": "message.part.updated", "properties": { "sessionID": "ses_example",
  "part": { "id": "prt_u1", "messageID": "msg_u1", "sessionID": "ses_example", "type": "text",
            "text": "查一下", "metadata": { "chatkit.runId": "run-1" } } } } }
{ "payload": { "type": "message.updated", "properties": { "sessionID": "ses_example",
  "info": { "id": "msg_a1", "sessionID": "ses_example", "role": "assistant", "parentID": "msg_u1",
            "time": { "created": 1700000000500 } } } } }
{ "payload": { "type": "message.part.updated", "properties": { "sessionID": "ses_example",
  "part": { "id": "prt_a1", "messageID": "msg_a1", "sessionID": "ses_example", "type": "text", "text": "" } } } }
{ "payload": { "type": "message.part.delta", "properties": { "sessionID": "ses_example",
  "messageID": "msg_a1", "partID": "prt_a1", "field": "text", "delta": "查到了" } } }
{ "payload": { "type": "message.updated", "properties": { "sessionID": "ses_example",
  "info": { "id": "msg_a1", "sessionID": "ses_example", "role": "assistant", "parentID": "msg_u1",
            "time": { "created": 1700000000500, "completed": 1700000000600 }, "finish": "stop" } } } }

审批请求:

{
  "payload": {
    "type": "permission.asked",
    "properties": {
      "id": "per_example",
      "sessionID": "ses_example",
      "permission": "bash",
      "patterns": ["printf ok"],
      "metadata": {},
      "always": ["printf *"],
      "tool": { "messageID": "msg_a1", "callID": "call_example" }
    }
  }
}

对应工具片段 tool:call_example 上的 metadata['chatkit.interaction']:

{
  "kind": "approval",
  "requestId": "per_example",
  "status": "pending",
  "callId": "call_example",
  "permission": "bash",
  "patterns": ["printf ok"],
  "always": ["printf *"],
  "decisions": ["once", "always", "reject"]
}