closed 语义合规、resume 走契约正路、翻译层纯函数——这些都做对了,保持住。本报告只覆盖 adapter 内部(providers/codex/* 及其测试)。
地基部分已单独沟通,不在本报告展开:registry、types.ts 接口改动、docs/lint 这些跨 provider 的事,按已合入的
PR #55(ProviderModule 自注册契约)处理——
rebase 到含 #55 的 main,adapter 挂成一个 ProviderModule 声明,别动抽象层。
具体三件事:
getModels() / getSessionConfig() 从 AgentProvider 接口撤掉 → 改放你的 ProviderModule.models / .sessionConfigDbAgentRegistry / ensureBuiltinAgentProviders / codex 专属 lint 规则从本 PR 移除(#55 已涵盖装配、seed、import 边界)docs/agent-layer.md 里的契约改动随之撤(其中补 #50 欠账的部分是功劳,单独留)协议定义(item.rs:1469-1483,注意没有 #[serde(transparent)]):
pub struct ToolRequestUserInputAnswer { pub answers: Vec<String> }
pub struct ToolRequestUserInputResponse { pub answers: HashMap<String, ToolRequestUserInputAnswer> }
扁平 map 会让 Codex 侧 deserialize 失败——respondToInputRequest 的全部意义就是这条往返。
单测(codex-protocol.test.ts “forwards answers”)把错误形状断言成了预期,修代码必须连测试一起修。
协议 struct 带 #[serde(rename_all = "camelCase")](item.rs:1444-1456),实际 wire 字段 vs 代码读的:
| 协议 wire | 代码读的 | 后果 |
|---|---|---|
isSecret | sensitive | 永不置位 → redaction 整链失效,密码类答案明文持久化 + SSE 广播 |
isOther | allowOther | 自由文本标志丢失 |
| (不存在) | multiple | 读了协议里没有的字段 |
question | prompt ?? question | fallback 碰巧兜住 |
header | header ?? 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 非空但全部解析失败 → 用一条合成问题顶替整组”应改为显式报错,不能静默顶替。
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
dispose/崩溃时回 {answers:{}}——工具看到的是“用户没答”,不是“会话死了”。Codex 此处确实无 cancel wire 等价物,那就别回成功响应,回 JSON-RPC error 让对端感知。
// dispose/进程死亡时,对挂起的 requestUserInput(cancellationResponseFor :180): { answers: {} } // ✗ 语义 = 「用户提交了空答案」,不是「会话死了」 // 工具按「没答」继续走分支 → 每次崩溃都产生一次错误的 agent 行为;应回 JSON-RPC error
契约 usage.updated 有 cacheReadTokens / cacheCreationTokens / contextTokens / contextWindow / model,全丢;且读 tokenUsage.last(单轮)非 total,累计语义没定义。声明了 token-usage 能力却是半实现。
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 消息排队等它
AgentInput.parts 被静默丢弃 index.ts:621-623契约写明 parts 存在时是 authoritative(types.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 无声蒸发 }
providerTurnId 提取了却不上报 protocol.ts:194-213契约 run.started 带 providerTurnId?,代码提取 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, } }
发着 diff.updated(in-UI diff review 是 Eyrie 的招牌特性,这条事件直接喂它)却不声明 structured-diff → UI 按能力门控就永远看不到你产出的 diff;反之 token-usage 声明了却是 M3 的半残。规则:声明的必须做全,做全的必须声明。
reasoning delta 正常流出,但 item 完成被忽略 → 消费者的思考块永远等不到终结信号。至少按 itemId 配对发一条 completed(message.completed 无 role 是契约缺口,会另行处理;itemId 配对你这边就能做)。
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 分级
command / args / env / codexHome → 钉死 provider row 级(不读 session blob)model / approvalPolicy / sandbox / requestTimeoutMs → 会话级候选,且要在 ProviderModule.sessionConfig 里如实声明agent-registry.test.ts 里直写 repo 绕过校验的用例一并改掉message.id 编进身份——重发的同一逻辑请求 id 必然不同,按 providerRequestId 去重从构造上不可能。身份只该由 method + threadId/turnId/itemId(/callId) 构成。配套:index.ts:529,551 每次 createId() 也不查重。request 处理了写错误,notify/respond/respondError 全丢——一条失败的 approval 响应会让 Codex 永久挂起。至少 respond/respondError 冒泡。parseError 无人订阅(jsonrpc.ts:166):malformed JSON 任何级别都不 log。加 onParseError + log;未映射的通知 method 建议 warn(现在是 debug)。mcp:server:tool 前缀约定(protocol.ts:341-348):工具名命名空间(MCP server/tool 归属)的编码约定会在契约层统一定,避免 provider 私有约定固化进 DB/UI。先标 TODO 引用之。network/fileSystem,未来出现新类别时用户点“同意”实际授空集且无提示。未知类别透传或显式拒绝。exitReason 无视 exit code(index.ts:701-708):干净的 code 0 退出被判 crashed;SIGINT → completed 特判没写 why。handleExit 存 50 行 stderr 只 log 最后一行(index.ts:599);exit 后还往死进程 stdin 写取消响应runControl 靠 status 字段 duck-typing 判断 raw 响应(index.ts:735-741),撞名即误吞skill.read 对本地 skill 只回路径不读内容,名不副实skill.inject/routes/persistence 大段不在 diff,请同步 body 与 diff测试床 = 自写 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 零覆盖
questions[](只有虚构的 prompt+schema)→ 只覆盖了合成兜底路径;真实 questions 解析(含 isSecret/isOther)零覆盖;runner 测试也不 assert fake server 实际收到的 answers 形状codex-jsonrpc.test.ts 只有 1 个 trivial 测试——id 关联/超时/半行缓冲/close-reject 这些你写对了的东西值得直接单测钉住行动建议:① 把 B1/B2 的 wire 断言(内层 {answers:[]}、isSecret redaction 端到端)补进 fixture + 测试;② 你留的 opt-in 真实 smoke(EYRIE_CODEX_SMOKE=1)方向很对——pin Codex 版本后跑起来,并在 smoke 里验证 turn/start 的响应时序(“立即返回 turn 描述符”目前只是 fixture 信念;若实际是 turn 结束才回,30s 默认超时会杀掉所有长 turn)。
closed 语义合规(index.ts:602),dispose→killed 区分正确——这是本 PR 最 load-bearing 的契约点,做对了,保持住respondToInputRequest 对 decline/cancel 老实 throw,符合契约 MUST-throw{decision:'cancel'} 是合法 modern decision 枚举值(approval 的取消路径是对的,M2 只针对 requestUserInput)Promise.race 无悬挂 rejection 问题describePersistence 走契约正路、「只有 registry 实例化 provider」原则正确structured-diff、token-usage 补全=M3)ProviderModule,撤掉本 PR 全部地基改动(见第 0 节)完整审查文档(含证据链与回应区):.review/2026-06-10-claude-codex-provider-pr41-review.md。请在该文档文末追加回应,逐条 ACK / 修复 commit 引用 / 或有依据的反驳。