PR #41 Codex Provider 审查报告

head dd2edd1 · base c9a97e6 · 审查人 claude · 2026-06-10 · 协议证据基准:openai/codex main app-server-protocol/src/protocol/v2/item.rs(直拉源码核验)
未通过2 Blocker + 9 Major。三条最贵的 wire 往返有真 bug,且被自写 fixture 的测试钉成绿。
骨架是对的closed 语义合规、resume 走契约正路、翻译层纯函数——这些都做对了,保持住。
范围与分工Blocker ×2Major ×9 Design / Nit测试问题已核验为正确修复路线

0 · 范围与分工(先读这段)

本报告只覆盖 adapter 内部providers/codex/* 及其测试)。

地基部分已单独沟通,不在本报告展开:registry、types.ts 接口改动、docs/lint 这些跨 provider 的事,按已合入的 PR #55(ProviderModule 自注册契约)处理—— rebase 到含 #55 的 main,adapter 挂成一个 ProviderModule 声明,别动抽象层。 具体三件事:

1 · Blocker——wire 正确性(必修)

B1结构化输入回传形状错——用户答案到不了 Codex protocol.ts:165-173

协议定义(item.rs:1469-1483,注意没有 #[serde(transparent)]):

pub struct ToolRequestUserInputAnswer   { pub answers: Vec<String> }
pub struct ToolRequestUserInputResponse { pub answers: HashMap<String, ToolRequestUserInputAnswer> }
✗ 现在发的
{ "answers": {
  "confirm": ["yes"]
} }
✓ 协议要的(每个答案多一层包裹)
{ "answers": {
  "confirm": { "answers": ["yes"] }
} }

扁平 map 会让 Codex 侧 deserialize 失败——respondToInputRequest 的全部意义就是这条往返。 单测(codex-protocol.test.ts “forwards answers”)把错误形状断言成了预期,修代码必须连测试一起修

B2问题字段名读错——secret 答案明文落库 + 广播 protocol.ts:618-637

协议 struct 带 #[serde(rename_all = "camelCase")]item.rs:1444-1456),实际 wire 字段 vs 代码读的:

协议 wire代码读的后果
isSecretsensitive永不置位 → redaction 整链失效,密码类答案明文持久化 + SSE 广播
isOtherallowOther自由文本标志丢失
(不存在)multiple读了协议里没有的字段
questionprompt ?? questionfallback 碰巧兜住
headerheader ?? title碰巧对
// 协议侧(item.rs:1444):#[serde(rename_all = "camelCase")] → wire 字段是 isSecret / isOther
// protocol.ts:618 normalizeInputQuestion 现状:
...optionalBoolean('sensitive',  question.sensitive),  // ✗ wire 里没有 sensitive → 永远 undefined
...optionalBoolean('allowOther', question.allowOther), // ✗ 应读 isOther
...optionalBoolean('multiple',   question.multiple),   // ✗ 协议根本没有 multiple 字段
// sensitive 置不上 → service 的 redaction 链整条失效:密码类答案明文写库 + SSE 广播

随手一起修:ToolRequestUserInputParams 只有 threadId / turnId / itemId / questions—— 不存在 prompt/title/message,所以合成兜底问题(protocol.ts:600-616)读的 params.prompt 是虚构 wire 形状(fixture 也按同一虚构造的数据); “questions 非空但全部解析失败 → 用一条合成问题顶替整组”应改为显式报错,不能静默顶替。

2 · Major——契约履约 + 状态正确性

M1未知 turn 状态默认 completed protocol.ts:696-700

缺失 / inProgress / 未来新枚举一律报成功完成;配合 getRecord 对 malformed 数据返回 {} 的风格,一条垃圾 turn/completed 也会落库成 Completed。未知一律 → failed,绝不默认成功。

// protocol.ts:696 —— 状态映射的兜底方向反了
function mapTurnStatus(status) {
  if (status === 'failed') return 'failed'
  if (status === 'interrupted') return 'interrupted'
  return 'completed'   // ✗ undefined / 'inProgress' / 未来新枚举 → 全部报成功
}
// 叠加 getRecord(malformed) → {}:一条垃圾 turn/completed 通知 = 一条成功落库的 run

M2会话死亡时对挂起提问回「空答案」 protocol.ts:176-185 · index.ts:610-613

dispose/崩溃时回 {answers:{}}——工具看到的是“用户没答”,不是“会话死了”。Codex 此处确实无 cancel wire 等价物,那就别回成功响应,回 JSON-RPC error 让对端感知。

// dispose/进程死亡时,对挂起的 requestUserInput(cancellationResponseFor :180):
{ answers: {} }   // ✗ 语义 = 「用户提交了空答案」,不是「会话死了」
// 工具按「没答」继续走分支 → 每次崩溃都产生一次错误的 agent 行为;应回 JSON-RPC error

M3usage 只填 2/7 个字段 protocol.ts:313-327

契约 usage.updatedcacheReadTokens / cacheCreationTokens / contextTokens / contextWindow / model,全丢;且读 tokenUsage.last(单轮)非 total,累计语义没定义。声明了 token-usage 能力却是半实现。

M4串行队列 + 处理路径里 await sink.emit index.ts:228-233

通知与 provider 请求共用一条串行链,慢 sink(DB 写 + SSE 广播)会 head-of-line 阻塞后续全部 Codex 流量;runControl 还会 await 整条队列。把持久化挪出 dispatch 路径,或请求/通知分队列。

// index.ts:228-233 —— 通知与 provider 请求共用一条串行 promise 链
peer.onNotification((msg) => {
  this.notificationQueue = this.notificationQueue.then(() => this.handleNotificationSafely(msg))
})
// 而 handleNotification 里:
await this.sink.emit(event)   // ✗ DB 写 + SSE 广播挂在队头 → 后面所有 Codex 消息排队等它

M5AgentInput.parts 被静默丢弃 index.ts:621-623

契约写明 parts 存在时是 authoritativetypes.ts:248-251)——图片轮次无声退化成派生文本。映射 parts → Codex input items(text/image),不支持的 part 显式 throw。

// 契约(types.ts:248):parts 存在时 authoritative,text 只是派生兜底
type AgentInput = { text: string; parts?: AgentInputPart[] }   // part: text | image

// index.ts:621 现状:
private turnInputItem(input: AgentInput) {
  return { type: 'text', text: input.text, text_elements: [] }  // ✗ 只读 text → 图片 part 无声蒸发
}

M6providerTurnId 提取了却不上报 protocol.ts:194-213

契约 run.startedproviderTurnId?,代码提取 turn.id 只做非空校验就丢了 → agent_runs.providerTurnId 永远 null。PR body 宣称的 “routes events through Codex provider turn IDs” 在 diff 里不存在。把 turnId 放上事件。

// protocol.ts:194 turnStartedToEvent —— turn.id 提取出来只做了非空校验
const turnId = getString(turn.id)
if (!threadId || !turnId) return { kind: 'none' }
return { kind: 'event', event: {
  type: 'run.started',
  providerSessionId: threadId,
  // ✗ providerTurnId 没放上事件(契约里有这个可选字段)→ agent_runs.providerTurnId 永远 null
  raw,
} }

M7能力声明与事件产出不对账 index.ts:145-147

发着 diff.updated(in-UI diff review 是 Eyrie 的招牌特性,这条事件直接喂它)却不声明 structured-diff → UI 按能力门控就永远看不到你产出的 diff;反之 token-usage 声明了却是 M3 的半残。规则:声明的必须做全,做全的必须声明。

M8reasoning 流永不收口 protocol.ts:96-98 · 380-419

reasoning delta 正常流出,但 item 完成被忽略 → 消费者的思考块永远等不到终结信号。至少按 itemId 配对发一条 completed(message.completed 无 role 是契约缺口,会另行处理;itemId 配对你这边就能做)。

M9配置面与声明 schema 脱节 + 会话级任意可执行文件隐患 index.ts:37-59 · 639-655

adapter 私收 10 个旋钮但 sessionConfig 声明为空;service 校验拒绝一切未声明 key → createSession 落地之日所有旋钮会话级不可达。更要紧:mergeRunnerConfig 允许 session config 覆盖 command/args/env——session config 一旦对用户开放,等于会话级注入任意可执行文件

// index.ts:639 mergeRunnerConfig —— session config 盖在 provider row 配置之上
providerConfig: {
  ...baseConfig,      // provider row(管理员面):command / args / env / ...
  ...sessionConfig,   // ✗ 会话配置同 key 直接覆盖 —— 包括 command/args/env
}
// 同时 getSessionConfig() 返回 [] → service 校验拒绝一切 key:
// 旋钮今天「不可达」,明天 schema 一补就变「会话级换可执行文件」—— 必须按 key 分级

3 · Design / Nit

Nit 杂项(顺手修)
  • handleExit 存 50 行 stderr 只 log 最后一行(index.ts:599);exit 后还往死进程 stdin 写取消响应
  • stderr 按 chunk 切行会拆半行;jsonrpc 行缓冲无最大长度护栏
  • runControlstatus 字段 duck-typing 判断 raw 响应(index.ts:735-741),撞名即误吞
  • skill.read 对本地 skill 只回路径不读内容,名不副实
  • PR body 列的 skill.inject/routes/persistence 大段不在 diff,请同步 body 与 diff

4 · 测试与 fixture:系统性问题

测试床 = 自写 fake-codex-app-server.mjs = 你对协议的信念。信念错了测试照样绿——B1/B2 就是这么固化的;fixture 连 initialize 校验都只校验 runner 自己硬编码的值(循环自证)。

// fake-codex-app-server.mjs —— fixture 里的 requestUserInput:
{ method: 'item/tool/requestUserInput',
  params: { itemId: 'question-1', prompt: 'Continue?', schema: { type: 'object' } } }
// ✗ 真协议(item.rs:1458)没有 prompt/schema 字段,questions[] 是必填 ——
//   fixture 按「信念」造数据,测试只覆盖了一条真实协议里不存在的路径;isSecret/isOther 零覆盖

行动建议:① 把 B1/B2 的 wire 断言(内层 {answers:[]}、isSecret redaction 端到端)补进 fixture + 测试;② 你留的 opt-in 真实 smoke(EYRIE_CODEX_SMOKE=1)方向很对——pin Codex 版本后跑起来,并在 smoke 里验证 turn/start 的响应时序(“立即返回 turn 描述符”目前只是 fixture 信念;若实际是 turn 结束才回,30s 默认超时会杀掉所有长 turn)。

5 · 已核验为正确(保持住,别被带偏)

6 · 修复路线(建议顺序)

  1. B1 + B2——含测试/fixture 同步修,补端到端 redaction 用例;providerRequestId 身份编码同路径顺手一起修
  2. M1 + M2——未知状态→failed;死会话回 error 而非空答案
  3. M5 + M6 + M7——parts 映射、providerTurnId 上报、能力对账(+structured-diff、token-usage 补全=M3)
  4. M9 + M4 + M8——配置 key 分级(session 不得覆盖 command/args/env)、队列解耦、reasoning 收口
  5. Design/Nit 按顺手修
  6. rebase 到含 #55 的 main——adapter 挂 ProviderModule,撤掉本 PR 全部地基改动(见第 0 节)

完整审查文档(含证据链与回应区):.review/2026-06-10-claude-codex-provider-pr41-review.md。请在该文档文末追加回应,逐条 ACK / 修复 commit 引用 / 或有依据的反驳。