Codex
OpenAI Codex 的 app-server 是一条 WebSocket 上的双向 JSON-RPC,服务端保存 thread、历史与执行状态。
这一页说明怎么接 Codex 的 app-server 协议:建连接、建会话、审批与追问卡片、会话级事件,以及这个协议要注意的地方。请求与事件的字段见 Codex 协议参考。
结论
- 适配器自带连接
createCodexConnection:app-server 要握手、按 thread 订阅,审批与追问是服务端反过来等客户端回答的请求,能力函数不用接入方写。地址与鉴权由连接选项 url 决定。
- 整个应用一个连接、一个适配器实例;ChatKit 的 sessionId 就是 Codex 的 threadId。
- 审批与追问挂在工具片段上,用工具渲染器画卡片,按钮调
connection.respond。
- 只支持请求流模式;不支持续接。
建连接
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' } },
}),
});
建连即握手(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 } },
};
状态依次是 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,其余字段不发。
输入怎么放进请求
输入里带了不支持的附件(非图片,或 resource 来源)时不发请求,这一轮以 UNSUPPORTED_ATTACHMENT 失败。
错误
已知限制
- 只支持请求流模式:同一 thread 里由其他客户端发起的轮次不实时显示,刷新后从历史出现。
- 不支持续接:断线后这一轮标记连接中断,由会话层经历史确认结果。
- 历史不保留审批记录、追问、被插话打断的那段回答、停止时已输出的部分回答。
- 插话的用户消息实时显示在回答之后,刷新后按历史排在回答之前。
- 一轮都没跑过的 thread 不落盘,断线后无法
thread/resume,listen 的重连会一直重试并报告错误。
- 文件附件、模型列表与切换、线程管理(改名、归档、fork)不在适配器里,经
connection.request 自行调用。
- app-server 协议仍在演进,追问依赖
experimentalApi;升级 Codex 后按协议参考重新核对。