Codex

OpenAI Codex 的 app-server 是一条 WebSocket 上的双向 JSON-RPC,服务端保存 thread、历史与执行状态。

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

结论

  • 适配器自带连接 createCodexConnection:app-server 要握手、按 thread 订阅,审批与追问是服务端反过来等客户端回答的请求,能力函数不用接入方写。地址与鉴权由连接选项 url 决定。
  • 整个应用一个连接、一个适配器实例;ChatKit 的 sessionId 就是 Codex 的 threadId。
  • 审批与追问挂在工具片段上,用工具渲染器画卡片,按钮调 connection.respond。
  • 只支持请求流模式;不支持续接。
能力接入方式说明
发送内置turn/start,clientUserMessageId 用 ChatKit 的 runId
停止内置turn/interrupt,带后端的 turnId;断线后也能停止(先在新连接上 thread/resume)
插话内置turn/steer(expectedTurnId),插话的用户消息出现在同一轮
历史内置thread/turns/list,从新到旧分页,与实时同一套消息、片段编号
审批卡片自画命令与文件改动;回答取后端给的 decisions 之一
追问卡片自画request_user_input,需要 Plan 协作模式;回答每个问题选中的选项
续接不支持断线后这一轮标记连接中断,由会话层经历史确认结果
会话目录应用维护创建经 connection.request('thread/start', …),列表、改名、归档也经 connection.request

建连接

codex-connection.ts
import { createCodexAdapter, createCodexConnection } from '@volcengine/chatkit/adapters/codex';

// 整个应用一个连接、一个适配器实例;会话 id(Codex 的 threadId)在每次调用时带上
export const connection = createCodexConnection({
  // app-server 的 WebSocket 地址(含鉴权参数);每次建连时取,可以是异步函数,便于刷新凭据
  url: () => 'wss://example.com/codex/app-server',
  // 历史每页的轮次数,缺省 20
  historyPageSize: 20,
});

// 每一轮的额外参数加到 turn/start 里;返回完整请求体。插话(steer)只用 input,其余字段不发
export const adapter = createCodexAdapter({
  connection,
  transformInput: (_input, { defaultInput }) => ({
    ...defaultInput,
    effort: 'medium',
    // 追问(request_user_input)需要 Plan 协作模式;settings.model 必填
    collaborationMode: { mode: 'plan', settings: { model: 'gpt-5-codex' } },
  }),
});
选项作用
urlapp-server 的 WebSocket 地址(含鉴权参数);可以是函数(含异步),每次建连时取,便于刷新凭据
WebSocket替换 WebSocket 实现:要加请求头、走自己的代理时传
clientInfoinitialize 里的客户端信息,缺省 { name: 'chatkit', version: '0' }
historyPageSize历史每页的轮次数,缺省 20
reconnectlisten 断线后自动重新订阅的退避间隔,缺省 1s 起、翻倍、最多 30s;false 关闭
onError连接内部异常的报告(报文解析、监听回调、重新订阅失败、拒绝的服务端请求),没给时打到控制台;不带地址

建连即握手(initialize → initialized),之后才发其它请求。浏览器直连时 app-server 要允许 WebSocket;生产环境建议走自己的服务转发并校验登录态,凭据放在 url 返回的地址上。

会话与界面

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

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

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

// 会话就是 Codex 的 thread:由应用创建,id 存进自己的会话目录
export async function createSession(cwd: string): Promise<string> {
  const { thread } = await connection.request<{ thread: { id: string } }>('thread/start', {
    cwd,
    approvalPolicy: 'on-request',
    sandbox: 'workspace-write',
  });
  return thread.id;
}

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>
  );
}

会话就是 Codex 的 thread:thread/start 后把 thread.id 存进自己的目录,交给 SessionList 与 useChatSessions。切到一个会话时,useChatSessions 经适配器取历史;往上翻时按页取更早的轮次。连接在使用未加载的 thread 前自动 thread/resume,接入方不用管订阅。

一轮都没跑过的 thread 不落盘:连接断开后对它 thread/resume 会失败。建会话后尽快发第一条消息,或在第一轮之前不要把它当成已保存的会话。

审批与追问

codex-interaction.tsx
import { type ChatkitProps, type ChatkitToolViewProps, useCurrentSession } from '@volcengine/chatkit';
import {
  type CodexInteraction,
  type CodexRespondRequest,
  getCodexInteraction,
} from '@volcengine/chatkit/adapters/codex';

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

const DECISION_LABELS: Record<string, string> = {
  accept: '允许一次',
  acceptForSession: '本会话内都允许',
  decline: '拒绝',
  cancel: '拒绝并停止',
};

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

function InteractionCard({ interaction }: { interaction: CodexInteraction }) {
  const sessionId = useCurrentSession().session?.id;
  // 只有 pending 时能回答(此时一定有 requestId);answering 是已经点了、等后端确认
  const { requestId } = interaction;
  const disabled = interaction.status !== 'pending' || !sessionId || !requestId;
  const answer = (response: CodexRespondRequest['response']) => {
    if (!sessionId || !requestId) return;
    void connection
      .respond({ sessionId, requestId, response })
      // 没送达时状态会退回 pending,可以再点一次
      .catch(error => console.error(error));
  };

  if (interaction.kind === 'approval')
    return (
      <div className="approval-card">
        <p>{interaction.reason ?? interaction.command ?? `允许执行 ${interaction.toolName}?`}</p>
        {interaction.status === 'answered' ? (
          <p>已回答:{interaction.decision}</p>
        ) : (
          // 可选回答由后端给出,按 decisions 画按钮
          interaction.decisions.map(decision => (
            <button key={decision} disabled={disabled} onClick={() => answer(decision)}>
              {DECISION_LABELS[decision] ?? decision}
            </button>
          ))
        )}
      </div>
    );

  return (
    <div className="question-card">
      {interaction.questions.map(question => (
        <div key={question.id}>
          <p>{question.question}</p>
          {question.options?.map(option => (
            <button
              key={option.label}
              disabled={disabled}
              onClick={() => answer({ answers: [{ id: question.id, selected: [option.label] }] })}
            >
              {option.label}
            </button>
          ))}
        </div>
      ))}
    </div>
  );
}

// 审批挂在被审批的那个工具上(shell、apply_patch),追问挂在合成的 request_user_input 上:用 match 接住全部工具
export const interactionRenderers: ChatkitProps['renderers'] = {
  tools: { interactions: { match: () => true, render: ToolWithInteraction } },
};
交互挂在哪回答
审批被审批的那个工具(命令 shell、文件改动 apply_patch)interaction.decisions 之一:accept、acceptForSession、decline、cancel 中后端给的若干个
追问合成的 request_user_input 工具(追问没有对应的条目)response: { answers: [{ id, selected: [...], custom? }] },每个问题一项;自由填写放 custom

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

  • 工具渲染器的参数里没有会话 id,卡片里用 useCurrentSession().session?.id 取。
  • 追问需要 Plan 协作模式:用 transformInput 在 turn/start 里带上 collaborationMode,见建连接的示例。
  • 问题可能标了 secret(敏感输入)。适配器不做过滤,回答原样记在交互请求的 answers 上,保存消息时自行处理。
  • 其余服务端请求(动态工具调用、MCP 表单、权限申请、凭据刷新…)连接立刻回错误,不让这一轮干等,经 onError 报告。
  • Codex 的历史不保留审批与追问:刷新后工具只有最终状态,追问卡片不再出现。

会话级事件

标题、用量、计划这类内容不在消息里。connection.listen(sessionId, listener) 收到这个 thread 的全部通知与请求,用它驱动插槽:

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

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

/** 会话标题:先 thread/read,之后跟着 thread/name/updated 通知更新;断线重连后再读一次,补上断线期间的变化。 */
export function useCodexThreadName(sessionId: string | undefined) {
  const [name, setName] = useState<string>();
  const [online, setOnline] = useState(true);
  useEffect(() => {
    if (!sessionId) return;
    let active = true;
    const refetch = () =>
      connection
        .request<{ thread: { name?: string | null } }>('thread/read', { threadId: sessionId })
        .then(result => active && setName(result.thread.name ?? undefined))
        .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 === 'notification' && item.method === 'thread/name/updated')
        setName((item.params as { threadName?: string | null }).threadName ?? undefined);
    });
    return () => {
      active = false;
      off();
    };
  }, [sessionId]);
  return { name, online };
}

连接断开时监听先收到 { kind: 'connection', status: 'disconnected' },连接自动重新订阅后收到 reconnected。断线期间的事件不补发,像上面这样在 reconnected 时用 thread/read 补拉一次状态。不带 threadId 的通知(账号、限流等)不按会话分发,listen 收不到。

业务内容

本轮内的通知也可以在 transformOutput 里转成 data 片段显示在回答里。刷新加载历史时,适配器把历史回放成同形的通知(每个条目一条 item/completed,结束的轮次一条 turn/completed)逐条交给它,同一段逻辑在刷新后也能重建业务内容;增量、服务端请求和其它通知只在实时里有。哪些通知属于某一轮、怎么拿,见协议参考的没有内置处理的事件。

每一轮的额外参数(model、effort、approvalPolicy、collaborationMode…)用 transformInput 加到 turn/start 的请求里,返回完整请求体。插话(turn/steer)只取返回的 input,其余字段不发。

输入怎么放进请求

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

输入里带了不支持的附件(非图片,或 resource 来源)时不发请求,这一轮以 UNSUPPORTED_ATTACHMENT 失败。

错误

情况错误
请求收到 JSON-RPC 错误CodexRpcError(code、data),消息取后端的 message
连接没能建立、断开或已 close()CodexConnectionClosedError;正在读的轮次标记连接中断,不认定结束;消息不带地址
停止、插话时还不知道后端的 turnIdError,不发请求
这一轮以失败结束(turn/completed)错误码 CODEX_TURN_FAILED,说明取后端给的 turn.error.message
命令退出码非零、MCP 工具出错工具片段的错误 CODEX_COMMAND_FAILED、CODEX_MCP_ERROR,只影响片段,不影响这一轮

已知限制

  • 只支持请求流模式:同一 thread 里由其他客户端发起的轮次不实时显示,刷新后从历史出现。
  • 不支持续接:断线后这一轮标记连接中断,由会话层经历史确认结果。
  • 历史不保留审批记录、追问、被插话打断的那段回答、停止时已输出的部分回答。
  • 插话的用户消息实时显示在回答之后,刷新后按历史排在回答之前。
  • 一轮都没跑过的 thread 不落盘,断线后无法 thread/resume,listen 的重连会一直重试并报告错误。
  • 文件附件、模型列表与切换、线程管理(改名、归档、fork)不在适配器里,经 connection.request 自行调用。
  • app-server 协议仍在演进,追问依赖 experimentalApi;升级 Codex 后按协议参考重新核对。