OpenCode

OpenCode 是一个编程 Agent,opencode serve 起的 server 在服务端保存会话、历史与执行状态。

这一页说明怎么接 OpenCode server:建连接、建会话、审批与追问卡片、会话级事件,以及这个协议要注意的地方。请求与事件的字段见 OpenCode 协议参考。

结论

  • 适配器自带连接 createOpenCodeConnection:OpenCode server 是 REST 加一条全局 SSE(/global/event),发送立刻返回、回答从事件流来,审批与追问也经事件流到达,能力函数不用接入方写。地址与鉴权由连接选项决定。
  • 整个应用一个连接、一个适配器实例;ChatKit 的 sessionId 就是 OpenCode 的会话 id。
  • 审批与追问挂在工具片段上,用工具渲染器画卡片,按钮调 connection.respond。
  • 只支持请求流模式;不支持插话与续接。
能力接入方式说明
发送内置POST /session/{id}/prompt_async,ChatKit 的 runId 写进第一个文本片段的 metadata['chatkit.runId']
停止内置POST /session/{id}/abort,会话级;结论仍从事件里来
插话不支持OpenCode 没有往进行中的一轮里追加输入的接口
历史内置GET /session/{id}/message,limit + before 从新到旧分页,片段 id 与实时相同
审批卡片自画回答 once / always / reject
追问卡片自画回答每个问题一组选中的标签,按问题顺序
续接不支持断线后这一轮标记连接中断,由会话层经历史确认结果
会话目录应用维护创建经 connection.createSession,列表、改名、删除经 connection.request

建连接

opencode-connection.ts
import { createOpenCodeAdapter, createOpenCodeConnection } from '@volcengine/chatkit/adapters/opencode';

// OpenCode server 的地址(可以带路径前缀,如走自己的服务转发);协议路径(`session/{id}/prompt_async`、`global/event`)由连接追加
const BASE = 'https://example.com/opencode';
// `opencode serve` 设了密码时用 Basic 鉴权;走自己的服务转发时换成它要的请求头
const AUTHORIZATION = `Basic ${btoa('opencode:<password>')}`;

// 整个应用一个连接、一个适配器实例;会话 id 在每次调用时带上
export const connection = createOpenCodeConnection({
  resolveUrl: path => `${BASE}/${path}`,
  // 请求头经 fetch 注入:REST 请求与全局事件流都走它
  fetch: (input, init) =>
    fetch(input, { ...init, headers: { ...(init?.headers as Record<string, string>), authorization: AUTHORIZATION } }),
  // 历史每页的消息数(一步一条回答消息),缺省 100
  historyPageSize: 100,
});

export const adapter = createOpenCodeAdapter({
  connection,
  // 每一轮的模型与 agent 加到 prompt_async 的请求体里;不给时用 OpenCode 的默认配置
  transformInput: (_input, { defaultInput }) => ({
    ...defaultInput,
    agent: 'build',
    model: { providerID: 'my-provider', modelID: 'my-model' },
  }),
});
选项作用
resolveUrl把 server 根下的路径(如 session/{id}/prompt_async、global/event)解析成完整地址,可带鉴权参数
fetch替换实现:要加请求头(opencode serve 设了密码时的 Basic 鉴权、自己服务要的头)时包一层;事件流也走它
historyPageSize历史每页的消息数,缺省 100;一步一条回答消息
reconnectlisten 断线后自动重新订阅的退避间隔,缺省 1s 起、翻倍、最多 30s;false 关闭
onError连接内部异常的报告(事件帧解析、监听回调、事件流、重新订阅失败),没给时打到控制台;错误消息不带地址

用的是 legacy 路由(/session/*、/permission/*、/question/*、/global/event),不用 v2 的 /api/*。浏览器直连时,OpenCode server 要允许跨域;生产环境建议走自己的服务转发并校验登录态,地址前缀写进 BASE。

会话与界面

opencode-app.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, SessionList, type SessionListProps, useChatSessions } from '@volcengine/chatkit';

import { adapter, connection } from './opencode-connection';
import { interactionRenderers } from './opencode-interaction';

// 会话由应用创建,id 存进自己的会话目录;这里让 bash 每次都先审批
export async function createSession(title: string): Promise<string> {
  const { sessionId } = await connection.createSession({
    title,
    permission: [{ permission: 'bash', pattern: '*', action: 'ask' }],
  });
  return sessionId;
}

export function App({
  sessions: items,
  activeSessionId,
  onSelect,
}: Pick<SessionListProps, 'sessions' | 'activeSessionId' | 'onSelect'>) {
  const sessions = useChatSessions({ activeSessionId, adapter });
  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      <SessionList sessions={items} activeSessionId={activeSessionId} onSelect={onSelect} style={{ width: 260 }} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <Chatkit sessions={sessions} renderers={interactionRenderers} />
      </div>
    </div>
  );
}

会话目录由应用维护:connection.createSession(body) 把请求体原样发给 POST /session,返回 { sessionId, session };把 sessionId 存进自己的目录,交给 SessionList 与 useChatSessions。切到一个会话时,useChatSessions 经适配器取历史;往上翻时按页取更早的轮次。

建会话时可给作用
title会话标题
permission这个会话的权限规则:[{ permission, pattern, action }],action 为 allow / deny / ask

只有权限为 ask 时才会有审批;要审批哪些工具,在这里或 OpenCode 的配置里定。

审批与追问

opencode-interaction.tsx
import { type ChatkitProps, type ChatkitToolViewProps, useCurrentSession } from '@volcengine/chatkit';
import {
  getOpenCodeInteraction,
  type OpenCodeInteraction,
  type OpenCodeRespondRequest,
} from '@volcengine/chatkit/adapters/opencode';
import { useState } from 'react';

import { connection } from './opencode-connection';

/** 默认的工具行照常显示;工具上挂着审批或追问时,在下面加一张卡片。 */
function ToolWithInteraction({ metadata, defaultNode }: ChatkitToolViewProps) {
  const interaction = getOpenCodeInteraction({ metadata });
  return (
    <>
      {defaultNode}
      {interaction && <InteractionCard interaction={interaction} />}
    </>
  );
}

const DECISION_LABELS = { once: '允许一次', always: '总是允许', reject: '拒绝' } as const;

function InteractionCard({ interaction }: { interaction: OpenCodeInteraction }) {
  const sessionId = useCurrentSession().session?.id;
  // 每个问题选中的标签,按问题顺序
  const [selected, setSelected] = useState<string[][]>([]);
  // 只有 pending 时能回答;answering 是已经点了、等后端确认
  const disabled = interaction.status !== 'pending' || !interaction.requestId || !sessionId;
  const answer = (response: OpenCodeRespondRequest['response']) => {
    if (!sessionId || !interaction.requestId) return;
    void connection
      .respond({ sessionId, requestId: interaction.requestId, response })
      // 没送达时状态会退回 pending,可以再点一次
      .catch(error => console.error(error));
  };

  if (interaction.kind === 'approval')
    return (
      <div className="approval-card">
        <p>
          允许 {interaction.permission}:{interaction.patterns.join('、')}?
        </p>
        {interaction.status === 'answered' ? (
          <p>已回答:{interaction.decision ? DECISION_LABELS[interaction.decision] : '已处理'}</p>
        ) : (
          interaction.decisions.map(decision => (
            <button key={decision} disabled={disabled} onClick={() => answer(decision)}>
              {DECISION_LABELS[decision]}
            </button>
          ))
        )}
      </div>
    );

  // 已回答的追问(含刷新后从 question 工具还原的)直接显示答案
  if (interaction.status === 'answered')
    return (
      <div className="question-card">
        {interaction.questions.map((question, index) => (
          <p key={index}>
            {question.question}:{interaction.answers?.[index]?.join('、')}
          </p>
        ))}
      </div>
    );

  const toggle = (index: number, label: string, multiple?: boolean) =>
    setSelected(current => {
      const next = [...current];
      const labels = next[index] ?? [];
      next[index] = multiple
        ? labels.includes(label)
          ? labels.filter(item => item !== label)
          : [...labels, label]
        : [label];
      return next;
    });
  const complete = interaction.questions.every((_, index) => selected[index]?.length);

  return (
    <div className="question-card">
      {interaction.questions.map((question, index) => (
        <div key={index}>
          <p>{question.question}</p>
          {question.options.map(option => (
            <button
              key={option.label}
              disabled={disabled}
              aria-pressed={selected[index]?.includes(option.label) ?? false}
              onClick={() => toggle(index, option.label, question.multiple)}
            >
              {option.label}
            </button>
          ))}
        </div>
      ))}
      {/* 一次回答全部问题:每个问题一组标签 */}
      <button
        disabled={disabled || !complete}
        onClick={() => answer(interaction.questions.map((_, index) => selected[index] ?? []))}
      >
        提交
      </button>
    </div>
  );
}

// 审批挂在被审批的那个工具上(bash、edit…),追问挂在 question 工具上:用 match 接住全部工具
export const interactionRenderers: ChatkitProps['renderers'] = {
  tools: { interactions: { match: () => true, render: ToolWithInteraction } },
};
交互挂在哪回答
审批被审批的那个工具(bash、edit…)response: 'once'、'always' 或 'reject'
追问question 工具response: [['A'], ['B', 'C']],每个问题一组选中的标签,按问题顺序,一次答全

状态依次是 pending(等待回答)→ answering(已调用 respond,等后端确认)→ answered。回答没送达时 respond 抛错、状态退回 pending,可以重试;按钮在非 pending 时置灰即可。等待回答时这一轮的阶段是 awaiting-external。这一轮结束时仍在等待的请求标为 cancelled。

  • 工具渲染器的参数里没有会话 id,卡片里用 useCurrentSession().session?.id 取。
  • requestId 只在 pending / answering 时有;没有时不能回答。
  • 审批里 patterns 是这次要放行的模式,always 是选「总是允许」时会放行的模式。
  • 刷新后,已回答的追问从 question 工具的输入与结果还原(没有 requestId);审批不进历史,刷新后工具只有最终状态。

会话级事件

标题、待办、忙闲这类内容不在消息里。connection.listen(sessionId, listener) 收到这个会话的全部原始内容,用它驱动插槽:

opencode-listen.tsx
import { useEffect, useState } from 'react';

import { connection } from './opencode-connection';

interface Todo {
  content: string;
  status: string;
  priority: string;
}

/** 会话的待办:先取一次,之后跟着 todo.updated 事件更新;断线重连后再取一次,补上断线期间的变化。 */
export function useOpenCodeTodos(sessionId: string | undefined) {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [online, setOnline] = useState(true);
  useEffect(() => {
    if (!sessionId) return;
    let active = true;
    const refetch = () =>
      connection
        .request<Todo[]>('GET', `session/${encodeURIComponent(sessionId)}/todo`)
        .then(result => active && setTodos(result ?? []))
        .catch(() => undefined);
    void refetch();
    const off = connection.listen(sessionId, item => {
      if (item.kind === 'connection') {
        setOnline(item.status === 'reconnected');
        if (item.status === 'reconnected') void refetch();
        return;
      }
      if (item.kind === 'event' && item.event.type === 'todo.updated')
        setTodos((item.event.properties as { todos: Todo[] }).todos);
    });
    return () => {
      active = false;
      off();
    };
  }, [sessionId]);
  return { todos, online };
}

连接断开时监听先收到 { kind: 'connection', status: 'disconnected' },连接自动重新订阅后收到 reconnected。断线期间的事件不补发,像上面这样在 reconnected 时补拉一次状态。

业务内容

本轮内的事件也可以在 transformOutput 里转成 data 片段显示在回答里。刷新加载历史时,适配器把历史回放成同形的会话事件逐条交给它,同一段逻辑在刷新后也能重建业务内容;增量与审批、追问的信令只在实时里有。哪些事件属于某一轮、怎么拿,见协议参考的没有内置处理的事件。

每一轮的额外参数(model、agent、system…)用 transformInput 加到 prompt_async 的请求体里,返回完整请求体,写法见上面的建连接。

输入怎么放进请求

输入里的放在哪
正文parts 里的文本片段
引用转成文本合并进正文
附件只接受图片(OPENCODE_ACCEPTED_FILE_TYPES),内联转成 data URL 或 http 地址,作为文件片段按原位置放

输入里带了不支持的附件(不是图片,或需要后端取回的来源)时不发请求,这一轮以 UNSUPPORTED_ATTACHMENT 失败。

错误

情况错误
请求或事件流返回非成功状态OpenCodeRequestError(status、body 前 500 字符),消息只带方法与路由,不带地址,会话与请求 id 打码
事件流断开或连接已关闭OpenCodeConnectionClosedError;正在读的轮次标记连接中断,不认定结束
回答消息或 session.error 带错误错误码取 error.name(缺省 OPENCODE_ERROR),说明取 error.data.message;MessageAbortedError 记为中断

已知限制

  • 只支持请求流模式:同一会话里由其他客户端发起的轮次不实时显示,刷新后从历史出现。
  • 不支持续接:断线后这一轮标记连接中断,由会话层经历史确认结果。
  • 不支持插话:OpenCode 没有往进行中的一轮里追加输入的接口。
  • 历史不保留审批记录:刷新后工具只有最终状态。
  • 历史里的用户消息只还原文本,图片附件不还原。