From db34fa11c47e99c7191f5de1d1bc517826eeaf08 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Wed, 20 May 2026 19:56:29 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(adapters):=20apply=5Fpatch=20diff=20UI?= =?UTF-8?q?=20=E5=9C=A8=20chat-completions=20provider=20=E4=B8=8A=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 用户用 App Transfer + DeepSeek (或 Kimi / MiMo) 接 Codex Desktop 时,所有 API 返回 200,但 apply_patch 工具调用稳定 aborted,Codex Desktop 前端不出 +/- diff 卡片,文件编辑功能彻底坏掉。shell_command / fetch 正常。 ## 根因 (对照 openai/codex @ 000bf5c 上游源码验证) Codex CLI 把 apply_patch 作为 freeform 工具注册: - `codex-rs/protocol/src/openai_models.rs:202-206` — `ApplyPatchToolType` enum 当前**只有 `Freeform`** 一个变体(社区提议 #14046 加 Function 变体未合并) - `codex-rs/core/src/tools/handlers/apply_patch_spec.rs` — wire 形态是 `ToolSpec::Freeform { format: { type:"grammar", syntax:"lark" } }` - `codex-rs/core/src/tools/router.rs:90-134` — 响应侧按 wire item type 路由: `ResponseItem::FunctionCall` → `ToolPayload::Function { arguments }`, `ResponseItem::CustomToolCall` → `ToolPayload::Custom { input }` - `codex-rs/core/src/tools/handlers/apply_patch.rs:324` — apply_patch handler 硬要求 `ToolPayload::Custom`,收 Function 直接返回 `"apply_patch handler received unsupported payload"` → abort 本仓 adapter 在响应侧把 DeepSeek 等 chat 上游的 `tool_calls[]` 一律渲染成 `function_call` wire,Codex CLI router 立刻 mismatch → abort。同时请求侧把 custom tool 降级成 function 时,upstream "do not wrap the patch in JSON" 的 description 在 chat 路径上反而误导模型;且没有 V4A 格式样例。 ## 修复 (方案 B - adapter 双向桥接) ### 请求侧 `responses/request/tools.rs` 对 `name == "apply_patch"` 特判,把 custom → function 降级时: - 替换 outer description 为 chat 路径准确的 V4A 指引(`*** Begin Patch`,文件 操作头,hunk 标记,relative path,JSON 字符串里写 `\n` 转义换行) - input 参数 description 镜像 V4A 关键约束 ### 响应侧 `responses/converter.rs` 对 `name == "apply_patch"` 特判,emit Responses `custom_tool_call` wire 而非 `function_call`: - `output_item.added` 用 `type:"custom_tool_call"`(empty `input`) - 中间 args delta **不** emit(避免对 JSON 累积字符串做流式 input 提取) - close 时一次性 emit `response.custom_tool_call_input.delta` + `.done` + `output_item.done`(`type:"custom_tool_call"`) - 提取 input:`{"input":""}` JSON 解出;非 JSON 或缺 input 字段时整段 原样透传(让 Codex CLI parse_patch 给出可读错误而非静默 abort) - envelope `output[]` 终态用同一 input 字符串(cached 到 PendingToolCall, 防 close 与 envelope build 之间 drift) - interrupted (无 finish_reason 且非 [DONE] 收尾) 时 emit `status:"incomplete"` 并 **skip** `input.done`,防止严格客户端在 stream 半截断时执行 partial patch (destructive tool 安全防线) - `call_id` 在 `output_item.added` emit 后 freeze,不再被后续 chunk 覆盖 (避免同一 item 暴露两个不同 call_id) - 加 tracing telemetry:positive shim 触发 (info)、晚到 name (warn)、 空 args (warn)、JSON parse 失败分流 (debug 裸 V4A / warn 真坏) ### 请求侧多轮回放 `responses/request.rs` (BLOCKER) turn N+1 时 Codex CLI 把上一轮 `ResponseItem::CustomToolCall` / `CustomToolCallOutput` 通过 `input[]` 回放给我们。原 `input_item_to_messages` 只处理 `function_call` / `function_call_output`,这两类静默落入 `_ =>` 兜底被 丢弃 → 多轮上下文丢失。本提交补两个分支: - `custom_tool_call` → `role:assistant` + `tool_calls[]` (function-call 形态, arguments 包成 `{"input":""}` JSON 字符串,与首轮 lowering 形态一致) - `custom_tool_call_output` → `role:tool` + `tool_call_id` + content ## 测试 新增 8 个回归测试 (响应侧 6 + 请求侧 2): - chat tool_calls(apply_patch) → custom_tool_call wire - JSON args / 裸 V4A 兜底 / 缺 input 字段 - interrupted stream → status=incomplete + skip input.done - streaming output_item.done.input == envelope.output[].input - custom_tool_call input → assistant.tool_calls (多轮回放) - custom_tool_call_output → role:tool (多轮回放) - request 侧 V4A 描述注入 (apply_patch vs 普通 custom 工具) `cargo test --workspace`: 全套通过 (506 adapter unit + 12+10+3 集成,跟原仓 一致;唯一偶发并发 flake `gemini_oauth::cancel_slot_epoch_*` 与本提交无关, serial 跑全过)。 ## 注意 不影响 Codex / GPT 官方登录路径 (那条走原生 Responses API,不经 chat adapter 转换)。本修复 strictly 针对 chat completions provider 转 Responses 的方向。 Refs #235 --- README.en.md | 1 + README.md | 1 + crates/adapters/src/responses/converter.rs | 574 ++++++++++++++++-- crates/adapters/src/responses/request.rs | 64 ++ .../adapters/src/responses/request/tests.rs | 126 ++++ .../adapters/src/responses/request/tools.rs | 72 ++- 6 files changed, 800 insertions(+), 38 deletions(-) diff --git a/README.en.md b/README.en.md index fde25872..0217d372 100644 --- a/README.en.md +++ b/README.en.md @@ -41,6 +41,7 @@ With any provider enabled, Codex CLI's model picker shows ` / , +} + +/// Codex CLI 把 `apply_patch` 作为 freeform 工具注册 +/// (`codex-rs/core/src/tools/handlers/apply_patch_spec.rs` — +/// `ToolSpec::Freeform { name: "apply_patch", ... }`),响应侧 router +/// (`codex-rs/core/src/tools/router.rs:92-130`)按 wire item type 路由: +/// `ResponseItem::FunctionCall` → `ToolPayload::Function { arguments }`, +/// `ResponseItem::CustomToolCall` → `ToolPayload::Custom { input }`,而 +/// apply_patch handler 硬要求 `ToolPayload::Custom`,收 Function 直接返回 +/// `"apply_patch handler received unsupported payload"` → abort +/// (`codex-rs/core/src/tools/handlers/apply_patch.rs:324`)。本 adapter +/// 把 chat completions provider(DeepSeek / Kimi / MiMo 等)回来的 +/// `tool_calls[]` 默认渲染成 `function_call` wire,所以必须对 apply_patch +/// 特判 — 用 `custom_tool_call` wire 给 Codex CLI 才不 abort。 +/// +/// 名字以常量集中是为了和 `request/tools.rs::APPLY_PATCH_TOOL_NAME` 对齐 +/// 字符串一致性(请求侧的特判描述 / 响应侧的 wire 重打包必须按同一 name 触发)。 +fn is_apply_patch_tool_name(name: &str) -> bool { + name == "apply_patch" } #[derive(Debug)] @@ -489,6 +524,13 @@ impl ChatToResponsesConverter { .clone() .unwrap_or_else(|| format!("call_{}_{}", self.fc_id_seed, openai_index)); let name = tc.function.name.clone().unwrap_or_default(); + // **取舍**:wire 形态(function_call vs custom_tool_call)在 open + // 时一次性根据**首帧 name** 决定,后续帧补全 name 不改 wire。 + // 实测 DeepSeek / Kimi / MiMo 都在首帧带 name。极端情况下首帧 + // name 为空、后续才补 apply_patch,会 fallback 到 function_call + // wire(同当前行为,Codex CLI 仍会 abort apply_patch 一次),不 + // 比修复前差。 + let is_apply_patch = is_apply_patch_tool_name(&name); self.tool_calls.insert( openai_index, PendingToolCall { @@ -498,35 +540,79 @@ impl ChatToResponsesConverter { name: name.clone(), args_acc: String::new(), closed: false, + is_apply_patch, + output_item_added_emitted: false, + apply_patch_input: None, }, ); - // 如果 function name 来自 namespace 包(从 original_request.tools - // 反查表查到),给 item 加 `namespace` 字段 — Codex.app 客户端 - // dispatch namespace 工具时这是必要字段(strings 实证 binary 含 - // `dynamic tool namespace must not be empty for` 校验,缺字段会 - // 报 `unsupported call: `)。 - let namespace = self.lookup_namespace_for(&name).map(str::to_owned); - let mut item = json!({ - "type": "function_call", - "id": fc_id, - "call_id": call_id, - "name": name, - "arguments": "", - "status": "in_progress", - }); - if let Some(ns) = namespace.as_ref() { - item["namespace"] = Value::String(ns.clone()); + // apply_patch:wire 必须是 `custom_tool_call`(裸 `input` 字段)。 + // 中间增量 delta **不 emit** — chat 上游给的 args 是 JSON 字符串 + // 增量(`{"input": "*** Begin Patch\n..."`),从 JSON 字符串拼接 + // 过程中流式提取 `input` 字段值需要专门的 streaming JSON state + // machine,本提交不引入。退而求其次:close 时一次性解 args 再 + // emit input.delta + output_item.done,代价是客户端看不到逐字 + // 流出的 diff(一次性出现整段 patch)。对一个长期完全不工作的 + // 功能,这是合理的第一步;后续可优化为真流式。 + if is_apply_patch { + tracing::info!( + target = "adapters::apply_patch", + call_id = %call_id, + "apply_patch shim engaged: rewriting chat function_call wire to Responses custom_tool_call", + ); + let item = json!({ + "type": "custom_tool_call", + "id": fc_id, + "call_id": call_id, + "name": name, + "input": "", + "status": "in_progress", + }); + emit_event( + out, + &mut self.sequence_number, + "response.output_item.added", + json!({ + "type": "response.output_item.added", + "output_index": output_index, + "item": item, + }), + ); + } else { + // 如果 function name 来自 namespace 包(从 original_request.tools + // 反查表查到),给 item 加 `namespace` 字段 — Codex.app 客户端 + // dispatch namespace 工具时这是必要字段(strings 实证 binary 含 + // `dynamic tool namespace must not be empty for` 校验,缺字段会 + // 报 `unsupported call: `)。 + let namespace = self.lookup_namespace_for(&name).map(str::to_owned); + let mut item = json!({ + "type": "function_call", + "id": fc_id, + "call_id": call_id, + "name": name, + "arguments": "", + "status": "in_progress", + }); + if let Some(ns) = namespace.as_ref() { + item["namespace"] = Value::String(ns.clone()); + } + emit_event( + out, + &mut self.sequence_number, + "response.output_item.added", + json!({ + "type": "response.output_item.added", + "output_index": output_index, + "item": item, + }), + ); + } + // output_item.added 已 emit。后续帧 backfill `id` 不应再换 call_id + // (否则 `output_item.added` 与后续 `input.delta` / `output_item.done` + // 用不同 call_id,严格客户端会两次解读为不同 item)。同样地, + // apply_patch 的 `is_apply_patch` 决策也已固定。 + if let Some(pending) = self.tool_calls.get_mut(&openai_index) { + pending.output_item_added_emitted = true; } - emit_event( - out, - &mut self.sequence_number, - "response.output_item.added", - json!({ - "type": "response.output_item.added", - "output_index": output_index, - "item": item, - }), - ); } // 后续帧可能补全 name(罕见但兼容) @@ -535,16 +621,31 @@ impl ChatToResponsesConverter { if let Some(pending) = self.tool_calls.get_mut(&openai_index) { if pending.name.is_empty() { pending.name = name.to_owned(); + if is_apply_patch_tool_name(name) && !pending.is_apply_patch { + // 罕见极端:首帧 name 为空,后续才补 apply_patch。 + // `output_item.added` 已经 emit `function_call` wire, + // 不能回退。这一调用 Codex CLI 仍会 abort,但起码我们 + // 在日志里能看到根因。 + tracing::warn!( + target = "adapters::apply_patch", + call_id = %pending.call_id, + "apply_patch tool name arrived AFTER first frame; wire stays function_call and Codex CLI will reject. Investigate upstream provider chunking.", + ); + } } } } } - // call_id 也可能在后续帧才出现 + // call_id 也可能在后续帧才出现 — 但只在 `output_item.added` 还没 emit + // 时才允许替换。已 emit 后再换会让客户端看到同一 item 用两个不同 + // call_id。 if let Some(id) = tc.id.as_deref() { if !id.is_empty() { if let Some(pending) = self.tool_calls.get_mut(&openai_index) { - // 只在首次给出 id 时覆盖(避免相同 index 不同 id 的混乱) - if pending.call_id.starts_with("call_") && pending.call_id.contains('_') { + if pending.output_item_added_emitted { + // 不再覆盖 — 同 item 已经对外暴露 call_id。 + } else if pending.call_id.starts_with("call_") && pending.call_id.contains('_') + { // 兜底生成的 call_id 形如 `call__`,真 id 来了就替换 if !pending.call_id.starts_with(id) && pending.call_id != id { pending.call_id = id.to_owned(); @@ -554,11 +655,16 @@ impl ChatToResponsesConverter { } } - // arguments delta(增量字符串) + // arguments delta(增量字符串)。apply_patch 路径**只**累积不 emit + // (理由见上文 open 处注释);非 apply_patch 仍逐 chunk emit + // `function_call_arguments.delta` 让客户端看到逐字流。 if let Some(args) = tc.function.arguments.as_deref() { if !args.is_empty() { if let Some(pending) = self.tool_calls.get_mut(&openai_index) { pending.args_acc.push_str(args); + if pending.is_apply_patch { + return; + } let item_id = pending.fc_id.clone(); let output_index = pending.output_index; emit_event( @@ -577,10 +683,10 @@ impl ChatToResponsesConverter { } } - fn close_tool_call(&mut self, openai_index: u32, out: &mut Vec) { + fn close_tool_call(&mut self, openai_index: u32, interrupted: bool, out: &mut Vec) { // 先把所有需要的字段 clone 出来,避免 mutable borrow 跟 // self.lookup_namespace_for 的 immutable borrow 冲突 - let (fc_id, call_id, name, args_acc, output_index, already_closed) = { + let (fc_id, call_id, name, args_acc, output_index, already_closed, is_apply_patch) = { let Some(pending) = self.tool_calls.get(&openai_index) else { return; }; @@ -591,11 +697,132 @@ impl ChatToResponsesConverter { pending.args_acc.clone(), pending.output_index, pending.closed, + pending.is_apply_patch, ) }; if already_closed { return; } + + if is_apply_patch { + // 从累积的 chat function args(标准形态 `{"input":""}`) + // 提取裸 V4A 文本。降级:模型可能直接吐裸 V4A(不包 JSON)— 历史 + // 上 freeform 工具的输出就是这个形态,某些 chat 上游可能没把它 + // 重新包成 JSON。fallback 把 args_acc 整段当 input,让上游能看到 + // 解析失败的具体内容(对调试 + 让 apply_patch parser 给出可读 + // 错误而不是静默 abort 都有用)。 + if args_acc.trim().is_empty() { + tracing::warn!( + target = "adapters::apply_patch", + call_id = %call_id, + "apply_patch tool was called with empty arguments — model likely misbehaving or provider stripped args", + ); + } + let input = extract_apply_patch_input(&args_acc); + // 缓存 input 到 pending,供 `tool_call_item_completed`(envelope + // output[] 终态)读,避免重复 parse 与潜在 drift。 + if let Some(pending) = self.tool_calls.get_mut(&openai_index) { + pending.apply_patch_input = Some(input.clone()); + } + // interrupted 中断时,patch 文本可能 mid-stream 被截断 — emit + // `status="incomplete"` 让 Codex CLI 看到 apply_patch handler 不 + // 应该执行 partial patch(apply_patch destructive,partial 执行 + // 可能在意外目标上写入意外内容)。同时 skip `input.done`(很多 + // 严格客户端在 `.done` 才触发执行)。 + if interrupted { + tracing::warn!( + target = "adapters::apply_patch", + call_id = %call_id, + args_len = args_acc.len(), + "apply_patch tool call cut off mid-stream (no finish_reason and not from [DONE]). Emitting output_item with status=incomplete; skipping input.done to prevent partial patch execution.", + ); + let item = json!({ + "type": "custom_tool_call", + "id": fc_id, + "call_id": call_id, + "name": name, + "input": input, + "status": "incomplete", + }); + emit_event( + out, + &mut self.sequence_number, + "response.output_item.done", + json!({ + "type": "response.output_item.done", + "output_index": output_index, + "item": item, + }), + ); + // 不存 cache(下一轮如果引用此 call_id 重建会拿到 incomplete + // 上下文,反而误导;让 orphan repair 路径补占位)。 + if let Some(pending) = self.tool_calls.get_mut(&openai_index) { + pending.closed = true; + } + return; + } + // open 阶段 emit 了空 input 的 output_item.added;这里一次性补 + // input.delta + output_item.done,让 Codex CLI 的 streaming + // parser(`StreamingPatchParser`)拿到完整 patch 文本后 finish。 + emit_event( + out, + &mut self.sequence_number, + "response.custom_tool_call_input.delta", + json!({ + "type": "response.custom_tool_call_input.delta", + "item_id": fc_id, + "output_index": output_index, + "call_id": call_id, + "delta": input, + }), + ); + emit_event( + out, + &mut self.sequence_number, + "response.custom_tool_call_input.done", + json!({ + "type": "response.custom_tool_call_input.done", + "item_id": fc_id, + "output_index": output_index, + "call_id": call_id, + "input": input, + }), + ); + let item = json!({ + "type": "custom_tool_call", + "id": fc_id, + "call_id": call_id, + "name": name, + "input": input, + "status": "completed", + }); + emit_event( + out, + &mut self.sequence_number, + "response.output_item.done", + json!({ + "type": "response.output_item.done", + "output_index": output_index, + "item": item, + }), + ); + // ToolCallCache 用于下一轮 Codex CLI 发 tool output 时重建工具 + // 调用上下文。回灌走 chat completions(messages.tool_calls.function + // .arguments 是 JSON 字符串),所以这里仍存原始 args_acc(JSON 形态) + // 而不是 input 裸文本,与 `assistant_message` 的 tool_calls 形态对齐。 + global_tool_call_cache().save( + &call_id, + ToolCallEntry { + name: name.clone(), + arguments: args_acc.clone(), + }, + ); + if let Some(pending) = self.tool_calls.get_mut(&openai_index) { + pending.closed = true; + } + return; + } + emit_event( out, &mut self.sequence_number, @@ -811,6 +1038,28 @@ impl ChatToResponsesConverter { } fn tool_call_item_completed(&self, pending: &PendingToolCall) -> Value { + if pending.is_apply_patch { + // envelope.output[] 终态必须和流式 `response.output_item.done` + // 的 item 一致(见 close_tool_call apply_patch 分支),否则严格 + // 客户端会两次解读为不同 item。读 close 时缓存好的 input, + // 不重新 parse args_acc — 万一 args_acc 在 close 与 envelope + // 构造之间发生意外变化(目前看不会,但防御性写法),两侧仍一致。 + // 缓存缺失时(理论上 close 一定先于 envelope build 跑,不应触发) + // fallback 到 raw args_acc,而不是再次 parse,避免重复 emit + // 任何 telemetry。 + let input = pending + .apply_patch_input + .clone() + .unwrap_or_else(|| pending.args_acc.clone()); + return json!({ + "type": "custom_tool_call", + "id": pending.fc_id, + "call_id": pending.call_id, + "name": pending.name, + "input": input, + "status": "completed", + }); + } let mut item = json!({ "type": "function_call", "id": pending.fc_id, @@ -1054,10 +1303,16 @@ impl ChatToResponsesConverter { if self.message_open && !self.message_closed { self.close_message(out); } - // tool_calls 按 OpenAI index 顺序闭合(BTreeMap 自然有序) + // tool_calls 按 OpenAI index 顺序闭合(BTreeMap 自然有序)。 + // `interrupted` = 没有 finish_reason **且**不是因 `[DONE]` 自然结束。 + // 这是用于让 apply_patch 在 close_tool_call 里 emit + // `status="incomplete"` 而不是 `completed`,防止严格客户端在 stream + // 半截断时仍把 partial patch 当成完整 tool 调用执行 + // (apply_patch 是 destructive,partial 执行风险高)。 + let interrupted = self.finish_reason.is_none() && !from_done; let tc_indices: Vec = self.tool_calls.keys().copied().collect(); for idx in tc_indices { - self.close_tool_call(idx, out); + self.close_tool_call(idx, interrupted, out); } // finish_reason → status / incomplete_details 映射。保留现有 5 路径 @@ -1252,6 +1507,60 @@ fn emit_event(out: &mut Vec, seq: &mut u64, event_name: &str, payload: Value emit_sse_event(out, seq, event_name, payload); } +/// 从 chat function args(标准形态 `{"input": ""}`)提取裸 V4A +/// 文本,供 `custom_tool_call.input` 字段使用。 +/// +/// 降级路径分两类,通过 tracing 区分以便事后定位: +/// +/// 1. **JSON parse 失败 + 看起来像裸 V4A**(以 `*** Begin Patch` 开头): +/// debug 级,预期 happy path —— 上游 chat provider 把 freeform 工具 +/// 输出原样透传未包 JSON。 +/// 2. **JSON parse 失败 + 不像 V4A**:warn 级,通常是 stream 截断 / UTF-8 +/// 损坏 / 上游加 markdown fence。把整段透传给 Codex CLI 至少能让用户 +/// 看到 `apply_patch verification failed: ` 而不是 abort +/// 无线索。 +/// 3. **JSON valid 但缺 `input` 字段**:warn 级,通常是 schema drift +/// (模型用了 `patch` 而不是 `input` 等)。整段透传暴露真坏。 +/// +/// 借鉴上游 `codex-rs/apply-patch/apply_patch_tool_instructions.md` 的 +/// V4A 格式约束。不做 V4A 语法校验 — 留给 Codex CLI 端的 `parse_patch`。 +fn extract_apply_patch_input(args_acc: &str) -> String { + let trimmed = args_acc.trim(); + if trimmed.is_empty() { + return String::new(); + } + match serde_json::from_str::(trimmed) { + Ok(parsed) => match parsed.get("input").and_then(Value::as_str) { + Some(s) => s.to_owned(), + None => { + tracing::warn!( + target = "adapters::apply_patch", + args_preview = %args_acc.chars().take(120).collect::(), + "apply_patch args parsed as JSON but missing `input` string field; passing raw args to Codex CLI", + ); + args_acc.to_owned() + } + }, + Err(err) => { + if trimmed.starts_with("*** Begin Patch") { + tracing::debug!( + target = "adapters::apply_patch", + "apply_patch args are bare V4A (no JSON wrapper); passthrough", + ); + } else { + tracing::warn!( + target = "adapters::apply_patch", + error = %err, + args_len = args_acc.len(), + args_preview = %args_acc.chars().take(120).collect::(), + "apply_patch args failed JSON parse and don't look like bare V4A; falling back to raw passthrough — likely truncation or schema drift", + ); + } + args_acc.to_owned() + } + } +} + fn drain_one_frame(buf: &mut BytesMut) -> Option { let pos = find_double_newline(buf)?; Some(buf.split_to(pos + 2).freeze()) @@ -2501,6 +2810,203 @@ data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]} assert_eq!(done.1["arguments"], "{\"a\":1}"); } + #[test] + fn apply_patch_tool_call_emits_custom_tool_call_wire_not_function_call() { + // 回归保护(issue #235):chat 上游(DeepSeek 等)用 function call 返回 + // apply_patch 时,adapter 必须把 wire 重打包成 Codex CLI 期望的 + // `custom_tool_call` 形态(上游 router 按 wire type 路由,apply_patch + // handler 硬要求 `ToolPayload::Custom { input }`)。 + // patch 文本走标准 JSON 转义:在 args.arguments 字符串里,V4A 原文的 + // `\n` 被双重转义成 `\\n`(JSON 字符串里写 `\n`)。 + let mut c = fixed(); + // 真实 chat 上游 wire 中,tool_call.arguments 是 JSON 字符串字面值, + // patch 里的换行必须双重转义(SSE outer JSON 的 string value 里写 + // `\\n`,解码后是 `\n` 字面;`arguments` 值再被 client 当 JSON 解一次 + // 得到 `*** Begin Patch\n...` 真换行的 V4A patch)。 + let chunks = concat!( + r#"data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_ap","type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\\n*** Update File: foo.py\\n@@\\n-old\\n+new\\n*** End Patch\\n\"}"}}]}}]}"#, + "\n\n", + r#"data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}"#, + "\n\n", + "data: [DONE]\n\n", + ); + let out = c.feed(chunks.as_bytes()); + let events = parse_emitted(&out); + let kinds = names(&events); + // open 必须用 custom_tool_call 而不是 function_call + let added = events + .iter() + .find(|(n, _)| n == "response.output_item.added") + .expect("应当 emit output_item.added"); + assert_eq!( + added.1["item"]["type"], "custom_tool_call", + "apply_patch wire 必须是 custom_tool_call,实际 events: {kinds:?}" + ); + assert_eq!(added.1["item"]["name"], "apply_patch"); + // 中间不应有 function_call_arguments.delta(apply_patch 路径 close 时 + // 一次性 emit custom_tool_call_input.delta) + assert!( + !kinds.contains(&"response.function_call_arguments.delta"), + "apply_patch 路径不应 emit function_call_arguments.delta,events: {kinds:?}" + ); + // close 必须 emit custom_tool_call_input.delta + .done + let input_delta = events + .iter() + .find(|(n, _)| n == "response.custom_tool_call_input.delta") + .expect("应当 emit custom_tool_call_input.delta"); + let expected_v4a = + "*** Begin Patch\n*** Update File: foo.py\n@@\n-old\n+new\n*** End Patch\n"; + assert_eq!(input_delta.1["delta"], expected_v4a); + assert_eq!(input_delta.1["call_id"], "call_ap"); + // envelope.output[] 终态也必须是 custom_tool_call + let completed = events + .iter() + .rev() + .find(|(n, _)| n == "response.completed") + .unwrap(); + let output = &completed.1["response"]["output"][0]; + assert_eq!(output["type"], "custom_tool_call"); + assert_eq!(output["input"], expected_v4a); + assert_eq!(output["call_id"], "call_ap"); + } + + #[test] + fn apply_patch_falls_back_to_raw_args_when_not_json() { + // 模型直接吐裸 V4A 而不包 JSON(某些 chat 上游可能这样转译 freeform)。 + // adapter 必须把整段 args_acc 当 input 而不是空字符串,让 Codex CLI + // 至少能看到 patch 内容并尝试解析。 + let raw_v4a = "*** Begin Patch\n*** Add File: a.md\n+hi\n*** End Patch\n"; + // serde_json::to_string 自动产生合法 JSON 字符串 escape(`\n` → `\\n` + // 字面、引号转义、反斜杠转义),比手工 replace 链可靠且贴近真实 wire。 + let args_json_string = serde_json::to_string(raw_v4a).unwrap(); + let mut c = fixed(); + let frame = format!( + "data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"call_ap\",\"type\":\"function\",\"function\":{{\"name\":\"apply_patch\",\"arguments\":{args_json_string}}}}}]}}}}]}}\n\ndata: {{\"choices\":[{{\"delta\":{{}},\"finish_reason\":\"tool_calls\"}}]}}\n\ndata: [DONE]\n\n", + args_json_string = args_json_string, + ); + let out = c.feed(frame.as_bytes()); + let events = parse_emitted(&out); + let delta = events + .iter() + .find(|(n, _)| n == "response.custom_tool_call_input.delta") + .expect("custom_tool_call_input.delta 应当 emit"); + assert_eq!( + delta.1["delta"], raw_v4a, + "非 JSON args 应整段当 input(裸 V4A 兜底)" + ); + } + + #[test] + fn apply_patch_interrupted_stream_emits_incomplete_status_skips_input_done() { + // 回归保护:apply_patch 是 destructive 工具,stream 中途断开 → close + // 必须 emit `status="incomplete"` 且 skip `custom_tool_call_input.done`, + // 让 Codex CLI 看到不完整状态而不是执行 partial patch。 + let partial_v4a = "*** Begin Patch\n*** Update File: foo.py\n@@\n-old\n"; // 截断在 @@ 之后 + let inner = serde_json::to_string(&json!({ "input": partial_v4a })).unwrap(); + let args_json_string = serde_json::to_string(&inner).unwrap(); + let mut c = fixed(); + // 仅 emit tool_call 增量与 lifecycle 开头,不 emit finish_reason / [DONE], + // 模拟 upstream EOF 中断。 + let frame = format!( + "data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"call_ap\",\"type\":\"function\",\"function\":{{\"name\":\"apply_patch\",\"arguments\":{args_json_string}}}}}]}}}}]}}\n\n", + args_json_string = args_json_string, + ); + let _ = c.feed(frame.as_bytes()); + let out = c.finish(); + let events = parse_emitted(&out); + let kinds = names(&events); + // 必须 NOT 出现 .delta 或 .done 的 custom_tool_call_input(防止 client + // 在 .done 时触发执行 partial patch) + assert!( + !kinds.contains(&"response.custom_tool_call_input.done"), + "interrupted 时禁止 emit custom_tool_call_input.done,events: {kinds:?}" + ); + assert!( + !kinds.contains(&"response.custom_tool_call_input.delta"), + "interrupted 时禁止 emit custom_tool_call_input.delta(避免提前触发执行),events: {kinds:?}" + ); + // output_item.done item 必须含 status=incomplete + let done = events + .iter() + .find(|(n, _)| n == "response.output_item.done") + .expect("interrupted 仍应 emit output_item.done"); + assert_eq!(done.1["item"]["type"], "custom_tool_call"); + assert_eq!( + done.1["item"]["status"], "incomplete", + "interrupted apply_patch 必须 status=incomplete" + ); + // envelope 也是 incomplete + interrupted + let completed = events + .iter() + .rev() + .find(|(n, _)| n == "response.completed") + .unwrap(); + assert_eq!(completed.1["response"]["status"], "incomplete"); + assert_eq!( + completed.1["response"]["incomplete_details"]["reason"], + "interrupted" + ); + } + + #[test] + fn apply_patch_streaming_input_matches_envelope_output() { + // 防御性回归:`response.output_item.done` 的 `item.input` 必须跟 + // `response.completed.output[].input` 完全一致,避免两次 emit 路径 + // 在未来重构时 drift。 + let patch = "*** Begin Patch\n*** Update File: x.txt\n@@\n-a\n+b\n*** End Patch\n"; + // chat wire 里 `arguments` 是 JSON-string(双重编码):先把 V4A 包成 + // `{"input": ""}` JSON 文本,再 JSON-quote 一次作为字符串值。 + let inner = serde_json::to_string(&json!({ "input": patch })).unwrap(); + let args_json_string = serde_json::to_string(&inner).unwrap(); + let mut c = fixed(); + let frame = format!( + "data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"call_match\",\"type\":\"function\",\"function\":{{\"name\":\"apply_patch\",\"arguments\":{args_json_string}}}}}]}}}}]}}\n\ndata: {{\"choices\":[{{\"delta\":{{}},\"finish_reason\":\"tool_calls\"}}]}}\n\ndata: [DONE]\n\n", + args_json_string = args_json_string, + ); + let out = c.feed(frame.as_bytes()); + let events = parse_emitted(&out); + let done = events + .iter() + .find(|(n, v)| { + n == "response.output_item.done" && v["item"]["type"] == "custom_tool_call" + }) + .expect("应当有 custom_tool_call output_item.done"); + let streamed_input = done.1["item"]["input"].as_str().unwrap().to_owned(); + let completed = events + .iter() + .rev() + .find(|(n, _)| n == "response.completed") + .unwrap(); + let envelope_input = completed.1["response"]["output"][0]["input"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!( + streamed_input, envelope_input, + "streamed output_item.done.input 必须跟 envelope.output[].input 完全一致" + ); + assert_eq!(streamed_input, patch); + } + + #[test] + fn extract_apply_patch_input_extracts_or_falls_back() { + // happy path:`{input: string}` 提出 string 字段 + assert_eq!( + extract_apply_patch_input(r#"{"input":"*** Begin Patch\nfoo"}"#), + "*** Begin Patch\nfoo" + ); + // 非 JSON:整段透传 + let raw = "*** Begin Patch\nfoo\n*** End Patch\n"; + assert_eq!(extract_apply_patch_input(raw), raw); + // JSON 但无 input 字段:整段透传 + assert_eq!( + extract_apply_patch_input(r#"{"other":"x"}"#), + r#"{"other":"x"}"# + ); + // 空字符串:返回空 + assert_eq!(extract_apply_patch_input(""), ""); + } + #[test] fn message_then_tool_call_keeps_output_index_order() { let mut c = fixed(); diff --git a/crates/adapters/src/responses/request.rs b/crates/adapters/src/responses/request.rs index 621e4cfa..76a16cbd 100644 --- a/crates/adapters/src/responses/request.rs +++ b/crates/adapters/src/responses/request.rs @@ -518,6 +518,70 @@ fn input_item_to_messages(item: &serde_json::Map) -> Vec { "content": output_str, })] } + "custom_tool_call" => { + // Codex CLI 把 freeform apply_patch 的回放 wire 包成 + // `ResponseItem::CustomToolCall { name, input, call_id, ... }` + // (`codex-rs/protocol/src/models.rs:824-832`)。我们在 turn N 通过 + // `converter.rs::close_tool_call` apply_patch 分支 emit 了它; + // Codex CLI 在 turn N+1 把同一 item 通过 `input[]` 回放给我们。 + // 转下游 chat completions 时必须重新打包成 `assistant.tool_calls` + // 的 `type:"function"` 形态(chat 端不认 custom_tool_call),且 + // `function.arguments` 必须是 JSON 字符串 `{"input":""}` + // (与首轮在 `tools.rs::convert_responses_tool_to_chat_tool` 的 + // `"custom" =>` 分支 lowering 形态保持一致)—— 模型才不会因 + // wire 形态变化失忆。 + let call_id = item + .get("call_id") + .and_then(|v| v.as_str()) + .or_else(|| item.get("id").and_then(|v| v.as_str())) + .unwrap_or("") + .to_owned(); + let name = item.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let input_text = item.get("input").and_then(|v| v.as_str()).unwrap_or(""); + // arguments 必须是 chat function-call 的标准 JSON 字符串形态。 + // serde_json::to_string 自动处理换行 / 引号 / 反斜杠等所有转义。 + let arguments_json = serde_json::to_string(&json!({ "input": input_text })) + .unwrap_or_else(|_| { + // to_string 在 input 是 valid UTF-8 string 时不会失败;若 + // 真发生,fallback 到空对象保持下游 chat schema 合法。 + "{}".to_owned() + }); + let arguments = sanitize_tool_arguments_json_string(&arguments_json); + vec![json!({ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": if call_id.is_empty() { "call_unknown".to_owned() } else { call_id }, + "type": "function", + "function": { "name": name, "arguments": arguments }, + }], + })] + } + "custom_tool_call_output" => { + // `ResponseItem::CustomToolCallOutput { call_id, output, ... }` + // (`codex-rs/protocol/src/models.rs:839-847`)使用与 function_call_output + // 相同的 `output` payload encoding(string 或 content_items array)。 + // 转 chat 时只需把 wire item type 对齐到普通 `role:"tool"` message, + // tool_call_id 来源仍按 call_id / tool_call_id / id 三级兜底。 + let call_id = item + .get("call_id") + .and_then(|v| v.as_str()) + .or_else(|| item.get("tool_call_id").and_then(|v| v.as_str())) + .or_else(|| item.get("id").and_then(|v| v.as_str())) + .unwrap_or("") + .to_owned(); + let output_value = item + .get("output") + .cloned() + .unwrap_or(Value::String(String::new())); + let output_str = + normalize_tool_output_for_context(Some(call_id.as_str()), output_value); + vec![json!({ + "role": "tool", + "tool_call_id": call_id, + "content": output_str, + })] + } "input_image" => { let image_url = item .get("image_url") diff --git a/crates/adapters/src/responses/request/tests.rs b/crates/adapters/src/responses/request/tests.rs index 0bb2d70e..5d9470f4 100644 --- a/crates/adapters/src/responses/request/tests.rs +++ b/crates/adapters/src/responses/request/tests.rs @@ -1952,6 +1952,81 @@ fn function_call_output_becomes_tool_message_with_placeholder_assistant() { assert_eq!(messages[1]["content"], "sunny"); } +#[test] +fn custom_tool_call_input_item_lowered_to_assistant_tool_calls() { + // 回归保护(issue #235):turn N+1 Codex CLI 回放上一轮的 + // `ResponseItem::CustomToolCall { name, input, call_id }`,我们必须把它 + // 转成 chat completions 的 `assistant.tool_calls` 形态(function-call), + // 否则模型完全看不到上一轮 apply_patch 调用 → 多轮上下文丢失。 + // arguments 必须是 JSON 字符串 `{"input":""}`,与首轮在请求侧 + // lowering 的形态保持一致,模型才不失忆。 + let patch_text = "*** Begin Patch\n*** Update File: a.py\n@@\n-x\n+y\n*** End Patch\n"; + let out = convert(json!({ + "input": [{ + "type": "custom_tool_call", + "id": "ctc_1", + "call_id": "call_ap_1", + "name": "apply_patch", + "input": patch_text, + "status": "completed", + }] + })); + let messages = out["messages"].as_array().unwrap(); + let assistant = messages + .iter() + .find(|m| m["role"] == "assistant" && m["tool_calls"].is_array()) + .expect("custom_tool_call 应当映射成 assistant.tool_calls"); + let tc = &assistant["tool_calls"][0]; + assert_eq!(tc["type"], "function"); + assert_eq!(tc["id"], "call_ap_1"); + assert_eq!(tc["function"]["name"], "apply_patch"); + // arguments 是 JSON 字符串值。serde_json 解一次得到 {input: }, + // 再 V4A 的换行已被正常 JSON-escape(`\n` 字面值)。 + let args_str = tc["function"]["arguments"].as_str().unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(args_str).expect("arguments 必须是合法 JSON"); + assert_eq!(parsed["input"], patch_text); +} + +#[test] +fn custom_tool_call_output_input_item_lowered_to_role_tool() { + // 回归保护(issue #235):`ResponseItem::CustomToolCallOutput { call_id, output }` + // 回放时必须转成 chat 端的 `role:"tool"` message,tool_call_id 跟前面的 + // assistant.tool_calls.id 配对,否则 chat 上游会因 orphan tool message 400。 + let out = convert(json!({ + "input": [ + { + "type": "custom_tool_call", + "call_id": "call_ap_2", + "name": "apply_patch", + "input": "*** Begin Patch\n*** Add File: b.md\n+hi\n*** End Patch\n", + }, + { + "type": "custom_tool_call_output", + "call_id": "call_ap_2", + "output": "Patch applied successfully", + } + ] + })); + let messages = out["messages"].as_array().unwrap(); + let tool_msg = messages + .iter() + .find(|m| m["role"] == "tool") + .expect("custom_tool_call_output 应当映射成 role:tool"); + assert_eq!(tool_msg["tool_call_id"], "call_ap_2"); + assert_eq!(tool_msg["content"], "Patch applied successfully"); + // 同 PR 还要保证 assistant 在 tool 前(orphan repair 不会插占位) + let assistant_idx = messages + .iter() + .position(|m| m["role"] == "assistant") + .unwrap(); + let tool_idx = messages.iter().position(|m| m["role"] == "tool").unwrap(); + assert!( + assistant_idx < tool_idx, + "assistant.tool_calls 必须在 role:tool 之前出现" + ); +} + #[test] fn function_call_output_non_string_is_json_serialized() { // 走完整 convert 路径(global cache 在生产里就这条路); @@ -2644,6 +2719,57 @@ fn tools_custom_type_is_lowered_to_function_with_input() { "string" ); assert_eq!(tool["function"]["parameters"]["required"][0], "input"); + // 非 apply_patch 的 custom 工具仍透传 outer description,input 用泛指 + // 兜底描述,不注入 V4A 提示。 + assert_eq!(tool["function"]["description"], "anything"); + assert!( + tool["function"]["parameters"]["properties"]["input"]["description"] + .as_str() + .unwrap_or_default() + .contains("verbatim"), + "非 apply_patch 应保留泛指 input 描述,实际:{}", + tool["function"]["parameters"]["properties"]["input"]["description"] + ); +} + +#[test] +fn tools_custom_apply_patch_injects_v4a_format_hint() { + // 回归保护(issue #235):chat 上游(DeepSeek 等)拿到 freeform apply_patch + // 时,上游的 "do not wrap in JSON" 描述会误导模型;且原始描述里没有 V4A + // 格式样例。adapter 必须替换描述为 chat 路径准确的 V4A 指引,模型才能 + // 正确填充 `input` 字段。 + let out = convert(json!({ + "input": "hi", + "tools": [{ + "type": "custom", + "name": "apply_patch", + "description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON." + }] + })); + let tool = &out["tools"][0]; + assert_eq!(tool["type"], "function"); + assert_eq!(tool["function"]["name"], "apply_patch"); + + // outer description 必须替换(不能保留误导性的 "do not wrap" 文本) + let outer = tool["function"]["description"].as_str().unwrap_or_default(); + assert!(!outer.contains("do not wrap"), "误导性原描述未替换:{outer}"); + assert!( + outer.contains("V4A"), + "outer description 应当包含 V4A 关键字:{outer}" + ); + assert!( + outer.contains("*** Begin Patch"), + "outer description 应当含 V4A 边界标记:{outer}" + ); + + // input 参数描述必须含 V4A 格式约束(provider 可能更看 parameter desc) + let input_desc = tool["function"]["parameters"]["properties"]["input"]["description"] + .as_str() + .unwrap_or_default(); + assert!( + input_desc.contains("V4A") && input_desc.contains("*** Begin Patch"), + "input description 应含 V4A 与边界标记:{input_desc}" + ); } #[test] diff --git a/crates/adapters/src/responses/request/tools.rs b/crates/adapters/src/responses/request/tools.rs index 040c01fe..58264e6b 100644 --- a/crates/adapters/src/responses/request/tools.rs +++ b/crates/adapters/src/responses/request/tools.rs @@ -3,6 +3,42 @@ use serde_json::{json, Value}; use super::provider_looks_like; +/// Codex freeform tool name we special-case. See the `"custom" =>` arm in +/// `convert_responses_tool_to_chat_tool` below for the request-side rewrite +/// rationale, and `converter.rs::close_tool_call` for the response-side +/// wire re-shape — they must trigger on the exact same tool name. +pub(crate) const APPLY_PATCH_TOOL_NAME: &str = "apply_patch"; + +/// Chat-path replacement for Codex CLI's freeform `apply_patch` description. +/// Original upstream text says "do not wrap the patch in JSON" because the +/// Responses API freeform/lark grammar accepts raw text — but on the +/// chat-completions path the model MUST emit a function call whose `input` +/// argument is a JSON string containing the V4A patch. We rewrite the +/// description so the model sees instructions consistent with the wire +/// format it has to produce. +pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( + "Edit files using the apply_patch tool. ", + "Call this function with a single `input` string containing a V4A patch. ", + "The patch must start with `*** Begin Patch` and end with `*** End Patch`. ", + "Each file operation header is one of `*** Add File: `, ", + "`*** Update File: ` (optionally followed by `*** Move to: `), ", + "or `*** Delete File: `. ", + "Within Update hunks, use `@@ @@` markers, prefix unchanged lines ", + "with a single space, removed lines with `-`, and added lines with `+`. ", + "Use relative paths only (never absolute). ", + "Embed real newlines as `\\n` inside the JSON string value for `input`." +); + +/// Chat-path replacement for the freeform `input` parameter description. +/// Mirrors `APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT` but at the parameter level, +/// so the model sees the format constraint regardless of whether providers +/// surface tool-level or parameter-level descriptions more prominently. +pub(crate) const APPLY_PATCH_INPUT_DESCRIPTION_FOR_CHAT: &str = concat!( + "A V4A patch starting with `*** Begin Patch` and ending with `*** End Patch`. ", + "Use `*** Add File:`, `*** Update File:`, or `*** Delete File:` headers and ", + "`@@ ... @@` hunks with ` `/`+`/`-` line prefixes. Relative paths only." +); + /// Responses tool 定义 → Chat tool 定义. /// 把单个 Responses API tool 转成零或多个 Chat Completions tool。 /// @@ -53,23 +89,51 @@ pub fn convert_responses_tool_to_chat_tool( })] } "custom" => { - // Custom tool(无 JSON schema)降级为接受单字符串 input 的 function + // Custom tool(Responses API freeform tool,无 JSON schema)降级为 + // 接受单字符串 input 的 function tool — chat completions 不认 + // `type:"custom"`,DeepSeek / Kimi / MiMo 等 chat 上游必须走 function。 + // + // **apply_patch 特判**:Codex CLI 把 apply_patch 作为 freeform 工具 + // 注册,wire description 是 "Use the `apply_patch` tool to edit files. + // This is a FREEFORM tool, so do not wrap the patch in JSON." + // (上游 `codex-rs/core/src/tools/handlers/apply_patch_spec.rs` 实证)。 + // 经 chat function-call 反而**必须**把 patch 包进 JSON 字符串值 —— + // 上游的 "do not wrap in JSON" 指令在 chat 路径下会误导模型, + // 且原 description 没给 V4A 格式样例。这里替换成对 chat 路径准确 + // 的指引,把 V4A 关键字 / 文件操作头 / hunk 标记列清楚,让 DeepSeek + // 之类的模型知道 input 字段该填什么。 + // 响应侧(converter.rs::close_tool_call)对 name==apply_patch 特判, + // 把模型回来的 function_call 重新打包成 custom_tool_call wire, + // 让 Codex CLI router (`ResponseItem::CustomToolCall`) 正确路由到 + // apply_patch handler(handler 硬要求 `ToolPayload::Custom { input }`, + // 见 `codex-rs/core/src/tools/handlers/apply_patch.rs:324`)。 let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let description = obj + let original_description = obj .get("description") .and_then(|v| v.as_str()) .unwrap_or(""); + let (tool_description, input_description) = if name == APPLY_PATCH_TOOL_NAME { + ( + APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT.to_owned(), + APPLY_PATCH_INPUT_DESCRIPTION_FOR_CHAT.to_owned(), + ) + } else { + ( + original_description.to_owned(), + "Free-form input passed verbatim to the tool.".to_owned(), + ) + }; vec![json!({ "type": "function", "function": { "name": name, - "description": description, + "description": tool_description, "parameters": { "type": "object", "properties": { "input": { "type": "string", - "description": "Free-form input passed verbatim to the tool.", + "description": input_description, } }, "required": ["input"], From cde491a59cae24fecd57d6c22c10452df96a4adf Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Wed, 20 May 2026 21:05:44 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(adapters):=20apply=5Fpatch=20tool=20des?= =?UTF-8?q?cription=20=E6=98=BE=E5=BC=8F=20hunk=20anchor=20=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机验证(用户 prompt 让模型在 README 5-10 行间插一段 markdown)发现:wire 桥接成功(25+ shim 触发 zero abort),但模型连续 20 分钟、25+ retry 在 V4A hunk header 上栽跟头,最终 fallback 到 sed 才完成。 根因:`@@ @@` 后的 space-prefixed 行的语义,在 freeform/lark grammar 受约束的解码空间下不会错(模型只能产出语法合法序列);切到 chat function-call 路径后 lark 强约束消失,description 只说了 ` `/`+`/`-` prefix,**没说 space 行对应 anchor *之后* 的行**。DeepSeek 反复把 anchor 当 space 行重复一次,parse_patch 找不到这样的双行存在 → 拒。 修复:在 `APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT` 加显式 "CRITICAL HUNK SEMANTICS" 段 + 一个最小可执行 V4A 示例(rename a let binding),展示 anchor 只出现在 `@@ ... @@` 里、不重复到 space 行。`APPLY_PATCH_INPUT_ DESCRIPTION_FOR_CHAT`(参数级)也加紧凑版同规则,防 provider 长 history 时截断 tool-level description。 测试:`tools_custom_apply_patch_injects_v4a_format_hint` 增加 4 个断言, 锁住 anchor 语义解释 + 最小示例 + 参数级紧凑版,防 description 在未来 refactor 时被误删。`cargo test --workspace` 全套通过。 注意:Codex CLI 端 `parse_patch` 失败不会经过 proxy log —— 那个错误在 client tool runtime 路径里被 emit 给模型作为 tool error,所以 PR 之前的 monitor 看不到。本次 follow-up 完全靠用户真机手工实测反馈(感谢)。 Refs #235 --- .../adapters/src/responses/request/tests.rs | 30 ++++++++++++++ .../adapters/src/responses/request/tools.rs | 41 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/adapters/src/responses/request/tests.rs b/crates/adapters/src/responses/request/tests.rs index 5d9470f4..56813fcd 100644 --- a/crates/adapters/src/responses/request/tests.rs +++ b/crates/adapters/src/responses/request/tests.rs @@ -2770,6 +2770,36 @@ fn tools_custom_apply_patch_injects_v4a_format_hint() { input_desc.contains("V4A") && input_desc.contains("*** Begin Patch"), "input description 应含 V4A 与边界标记:{input_desc}" ); + + // 回归保护(issue #235 真机验证暴露的二级问题):tool 描述必须显式解释 + // hunk semantics —— context 锚点 vs space-prefixed 行的区别。DeepSeek 在没 + // 有 lark grammar 强约束的 chat 路径上反复栽在这里(把 anchor 当 space 行 + // 重复一次),花 20 分钟、25+ 次 retry 最后 fallback 到 sed。description + // 必须含可执行的最小示例 + 显式的"do not repeat the anchor"指引。 + let outer_lc = outer.to_lowercase(); + assert!( + outer.contains("@@") && outer.contains("anchor"), + "tool description 必须解释 hunk anchor 语义:{outer}" + ); + assert!( + outer.contains("AFTER the anchor") || outer.contains("after it"), + "必须显式说明 space 行对应 anchor *之后* 的位置:{outer}" + ); + assert!( + outer_lc.contains("do not repeat the anchor") || outer_lc.contains("not again as a space"), + "必须显式禁止把 anchor 当 space 行重复:{outer}" + ); + assert!( + outer.contains("*** Update File:") && outer.contains("@@ fn main()"), + "必须包含一个最小可执行 V4A 示例:{outer}" + ); + + // 参数描述同样必须保留紧凑版的语义提示 + assert!( + input_desc.contains("anchor") + && (input_desc.contains("AFTER") || input_desc.contains("after")), + "input description 必须保留 anchor 语义紧凑版:{input_desc}" + ); } #[test] diff --git a/crates/adapters/src/responses/request/tools.rs b/crates/adapters/src/responses/request/tools.rs index 58264e6b..1d7f0c4e 100644 --- a/crates/adapters/src/responses/request/tools.rs +++ b/crates/adapters/src/responses/request/tools.rs @@ -16,6 +16,18 @@ pub(crate) const APPLY_PATCH_TOOL_NAME: &str = "apply_patch"; /// argument is a JSON string containing the V4A patch. We rewrite the /// description so the model sees instructions consistent with the wire /// format it has to produce. +/// +/// **重要:hunk body 的 space-prefixed 行语义** — 上游 freeform 工具用 lark +/// grammar 强制约束,模型在受约束的解码空间里不会搞错;但 chat function-call +/// 没有 grammar 约束,只剩 description。实测(issue #235 真机)DeepSeek +/// 反复在一个具体语义上栽跟头: +/// +/// > `@@ @@` 标记后的 space-prefixed 行 = 文件中 context 锚点 +/// > **之后**的行,**不是** context 行本身的重复 +/// +/// 不显式说清这个,模型会把 context 行当成 space 行再写一次,parse_patch +/// 找不到双行 → 整个 patch 拒收。本 description 通过显式规则 + 一个最小 +/// 可执行的更新文件 example 让模型看到正确形态。 pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( "Edit files using the apply_patch tool. ", "Call this function with a single `input` string containing a V4A patch. ", @@ -26,17 +38,42 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( "Within Update hunks, use `@@ @@` markers, prefix unchanged lines ", "with a single space, removed lines with `-`, and added lines with `+`. ", "Use relative paths only (never absolute). ", - "Embed real newlines as `\\n` inside the JSON string value for `input`." + "Embed real newlines as `\\n` inside the JSON string value for `input`.\n\n", + "CRITICAL HUNK SEMANTICS (the most common cause of patch rejection):\n", + "`@@ @@` is an anchor that names ONE existing line in the file. ", + "Every space-prefixed line that follows the `@@` marker corresponds to lines ", + "AFTER the anchor in the file (not the anchor itself). ", + "Do NOT repeat the anchor line as the first space-prefixed line — the parser will reject it.\n\n", + "EXAMPLE — to change `let x = 1;` to `let x = 2;` in a file whose lines around the change read:\n", + " fn main() {\n", + " let x = 1;\n", + " println!(\"{}\", x);\n", + " }\n", + "The correct patch is:\n", + "*** Begin Patch\n", + "*** Update File: src/main.rs\n", + "@@ fn main() {\n", + "- let x = 1;\n", + "+ let x = 2;\n", + " println!(\"{}\", x);\n", + "*** End Patch\n", + "Notice: `fn main() {` appears in `@@ ... @@` once as the anchor, NOT again as a space-prefixed line below. ", + "The first content line under the anchor is the line immediately after `fn main() {` in the file." ); /// Chat-path replacement for the freeform `input` parameter description. /// Mirrors `APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT` but at the parameter level, /// so the model sees the format constraint regardless of whether providers /// surface tool-level or parameter-level descriptions more prominently. +/// Same anchor-vs-space-line gotcha called out here in compact form (some +/// providers truncate or de-emphasize tool-level descriptions on long +/// histories — keep the rule visible at parameter level too). pub(crate) const APPLY_PATCH_INPUT_DESCRIPTION_FOR_CHAT: &str = concat!( "A V4A patch starting with `*** Begin Patch` and ending with `*** End Patch`. ", "Use `*** Add File:`, `*** Update File:`, or `*** Delete File:` headers and ", - "`@@ ... @@` hunks with ` `/`+`/`-` line prefixes. Relative paths only." + "`@@ @@` hunks with ` `/`+`/`-` line prefixes. Relative paths only. ", + "CRITICAL: in an Update hunk the `@@ @@` anchor is a SINGLE existing file line; ", + "the space-prefixed lines following the anchor describe lines AFTER it (do not repeat the anchor)." ); /// Responses tool 定义 → Chat tool 定义. From 9b7a7fc3a7081dbdd05466c02d887c42f5909340 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Wed, 20 May 2026 22:07:54 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(adapters):=20apply=5Fpatch=20chat-path?= =?UTF-8?q?=20=E5=AE=9E=E6=88=98=20workaround=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=20system=20+=20description=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek 稳定性测试(10 个 Level 全跑通,详见 #235 PR 评论)模型自己摸索出 4 个 chat-path 上 apply_patch 的非平凡行为,每次任务平均花 1-3 分钟绕弯子: 1. `@@ <非空文本> @@` 锚点在 chat 路径上常匹配失败 → 模型用 `printf '\n' >> file` 种空行作锚点 → patch → 事后清理多余空行 2. `*** Add File: foo` + 同 patch 内 `*** Update File: foo` 冲突 (新建文件未落盘 Update 已读取)→ 模型改用预建锚点文件 3. 纯空目标文件无法直接 `*** Update File:` → 必须 shell 先 seed 一行 4. 多行文件里纯 `+` 行在锚点后是"追加"不是"替换" → 需 `-` + `+` 配对替换 这些都是 Codex CLI 端 parse_patch 的实际行为,adapter 修不了 wire 层。但可以 预先在请求侧告诉模型这些 workaround,让首次成功率提升、token 浪费降低。 实现: - `crates/adapters/src/responses/request.rs`:加 `tools_register_apply_patch()` 检测 + `APPLY_PATCH_CHAT_PATH_SYSTEM_GUIDANCE` 文案 + `apply_patch_chat_guidance_message()` 构造器;`build_messages_from_input` 紧跟 Codex CLI instructions system message 之后追加注入,**仅当**当前 turn 的 tools 数组真正注册了 apply_patch (type:custom + name:apply_patch)。非 apply_patch turn 0 浪费。 - `crates/adapters/src/responses/request/tools.rs`:在 `APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT` 末尾补 4 条紧凑版 workaround;`APPLY_PATCH_INPUT_DESCRIPTION_FOR_CHAT`(参数级) 同步加更紧凑的 backup。三层 redundancy(system / tool desc / param desc)防止 上游 provider 截断或弱化某一层时模型完全失指引。 设计取舍: - 注入独立 system message(不合并到 Codex 原 instructions),保持职责分离 + 方便日后调整 / 替换 - 文本英文,匹配现有 description 风格,跟下游模型 vocab 也更对齐 - 检测条件用 `type:"custom" && name:"apply_patch"` 而不是 lowered 后的 `type:"function"`,因为我们在 `build_messages_from_input` 时拿到的是原始 Responses body(`convert_responses_tool_to_chat_tool` 在 `tools` 字段转换路径 里调用,跟 `messages` 字段构造路径平行) 测试:3 个新单测覆盖: - 注册 apply_patch 时注入(Codex instructions 不被覆盖、4 条 workaround 都在、 marker 存在) - 未注册 apply_patch 时不注入(无 system 数量增加、无 guidance marker) - 反复 convert 同一 body 3 次,每次 guidance 计数仍为 1(防 merge_consecutive_ system_messages 之类后处理累积) `cargo test --workspace`:509 adapter unit + 25 集成测试全过 (506→509)。 `cargo fmt --all -- --check` clean。 Refs #235 --- crates/adapters/src/responses/request.rs | 52 ++++++++++ .../adapters/src/responses/request/tests.rs | 95 +++++++++++++++++++ .../adapters/src/responses/request/tools.rs | 15 ++- 3 files changed, 160 insertions(+), 2 deletions(-) diff --git a/crates/adapters/src/responses/request.rs b/crates/adapters/src/responses/request.rs index 76a16cbd..c0082549 100644 --- a/crates/adapters/src/responses/request.rs +++ b/crates/adapters/src/responses/request.rs @@ -296,6 +296,14 @@ fn build_messages_from_input( messages.push(msg); } + // 紧跟 Codex CLI 自带 instructions 之后注入 apply_patch chat-path 指引 + // (仅当本 turn 真正注册了 apply_patch 工具时)。位置选择:Codex 系统 + // 指令之后,user input 之前 — 既不污染 Codex 原指令,又确保模型在 + // 读完工具列表准备调 apply_patch 时已经见过 chat-path 限制。 + if tools_register_apply_patch(body) { + messages.push(apply_patch_chat_guidance_message()); + } + let current_messages = body .get("input") .map(input_field_to_messages) @@ -2271,4 +2279,48 @@ mod tests; use tools::{ contains_kimi_web_search_tool, convert_responses_tool_to_chat_tool, normalize_tool_choice, + APPLY_PATCH_TOOL_NAME, }; + +/// chat-path 实战指引,作为独立 `role:"system"` 注入,仅在该 turn 的 tools +/// 数组里注册了 `apply_patch` 时启用。理由参见 issue #235 真机稳定性测试 +/// (DeepSeek 跑 10 个 Level 共发现的 4 个 chat-path 行为):tool/参数 +/// description 同时含紧凑版作 fallback,但 system message 在多数 chat +/// 上游里被赋予更高权重,且模型在 system 块里读到的指引更难被遗忘 / 截断。 +const APPLY_PATCH_CHAT_PATH_SYSTEM_GUIDANCE: &str = concat!( + "[apply_patch chat-path guidance — injected by codex-app-transfer adapter because the upstream lark grammar constraint is unavailable on chat function-call providers]\n", + "When you call the `apply_patch` tool, follow these rules empirically observed with non-OpenAI chat providers:\n", + "\n", + "1. Use an EMPTY LINE as the `@@` anchor whenever possible. Non-empty anchors (e.g. `@@ Hello World!`) frequently fail to match on this path. ", + "If the target file lacks a blank line near your hunk, first run `printf '\\n' >> ` via shell to seed one, then use `@@` with empty content as the anchor, and clean up extra blank lines after the patch lands.\n", + "\n", + "2. Do NOT combine `*** Add File: ` and `*** Update File: ` for the same path in a single patch. ", + "The Update step reads the file before the Add step lands on disk, so it sees an empty file and fails. Either: (a) make `*** Add File:` write the final content in one shot, or (b) split into two separate `apply_patch` invocations.\n", + "\n", + "3. `*** Update File:` cannot operate on a totally empty file. If the target is empty, first use shell (e.g. `printf '\\n' > `) to write at least one line, then call `apply_patch`.\n", + "\n", + "4. In a multi-line file, lone `+` lines following an `@@` anchor APPEND below the anchor — they do NOT replace the anchor line. To change an existing line, you must include BOTH a `-` line to remove the old content AND a `+` line to add the new content. Do not omit the `-` line.\n", + "\n", + "Following these rules avoids retry storms and improves the success rate on first attempt." +); + +/// 检测 Responses request body 的 tools 数组是否注册了 `apply_patch` 工具。 +/// `apply_patch` 在 Responses 协议里以 `type:"custom", name:"apply_patch"` 出现, +/// 在被 [`convert_responses_tool_to_chat_tool`] 降级前。 +/// 用于决定本 turn 是否注入 [`APPLY_PATCH_CHAT_PATH_SYSTEM_GUIDANCE`]。 +fn tools_register_apply_patch(body: &Value) -> bool { + let Some(tools) = body.get("tools").and_then(Value::as_array) else { + return false; + }; + tools.iter().any(|t| { + t.get("name").and_then(Value::as_str) == Some(APPLY_PATCH_TOOL_NAME) + && t.get("type").and_then(Value::as_str) == Some("custom") + }) +} + +fn apply_patch_chat_guidance_message() -> Value { + json!({ + "role": "system", + "content": APPLY_PATCH_CHAT_PATH_SYSTEM_GUIDANCE, + }) +} diff --git a/crates/adapters/src/responses/request/tests.rs b/crates/adapters/src/responses/request/tests.rs index 56813fcd..43f658e7 100644 --- a/crates/adapters/src/responses/request/tests.rs +++ b/crates/adapters/src/responses/request/tests.rs @@ -1952,6 +1952,101 @@ fn function_call_output_becomes_tool_message_with_placeholder_assistant() { assert_eq!(messages[1]["content"], "sunny"); } +#[test] +fn apply_patch_chat_path_guidance_injected_when_tool_registered() { + // 真机稳定性测试发现:即使 wire 桥接通了 + tool description 有 V4A + // 规则,DeepSeek 在 chat-path 上仍会反复尝试错误的 anchor / Add+Update + // 组合 / 空文件 Update 等无效路径,平均每次任务摸索 1-3 分钟。为节省 + // tokens 和提升首次成功率,adapter 在 tools 数组里注册了 apply_patch + // 的 turn 注入一段独立 system message 告知 chat-path 实战 workaround。 + let out = convert(json!({ + "input": [{"type": "message", "role": "user", "content": "edit foo.py"}], + "instructions": "You are a coding assistant.", + "tools": [{ + "type": "custom", + "name": "apply_patch", + "description": "Use the `apply_patch` tool to edit files." + }] + })); + let messages = out["messages"].as_array().unwrap(); + + // Codex CLI 原 instructions 必须保留在第一条 + assert_eq!(messages[0]["role"], "system"); + assert!( + messages[0]["content"] + .as_str() + .unwrap_or_default() + .contains("coding assistant"), + "Codex 原 instructions 不应被覆盖" + ); + + // 紧跟在 Codex instructions 之后必须有一条 adapter-injected guidance + assert_eq!(messages[1]["role"], "system"); + let guidance = messages[1]["content"].as_str().unwrap_or_default(); + assert!( + guidance.contains("apply_patch chat-path guidance"), + "注入的指引必须带可识别 marker:{guidance}" + ); + // 4 个核心 workaround 都要含进去 + assert!(guidance.contains("empty line") || guidance.contains("EMPTY LINE")); + assert!(guidance.contains("Add File") && guidance.contains("Update File")); + assert!(guidance.contains("empty file") || guidance.contains("totally empty")); + assert!(guidance.contains("APPEND") || guidance.contains("append")); +} + +#[test] +fn apply_patch_chat_path_guidance_skipped_when_tool_not_registered() { + // 非 apply_patch 任务不应注入指引,避免污染 token / 模型注意力 + let out = convert(json!({ + "input": [{"type": "message", "role": "user", "content": "list files"}], + "instructions": "You are a coding assistant.", + "tools": [{ + "type": "function", + "name": "shell_command", + "description": "Run a shell command", + "parameters": {"type": "object", "properties": {}} + }] + })); + let messages = out["messages"].as_array().unwrap(); + let has_guidance = messages.iter().any(|m| { + m["content"] + .as_str() + .unwrap_or_default() + .contains("apply_patch chat-path guidance") + }); + assert!( + !has_guidance, + "无 apply_patch 注册时不应注入 chat-path guidance" + ); +} + +#[test] +fn apply_patch_chat_path_guidance_idempotent_across_turns() { + // 防止 merge_consecutive_system_messages 把 adapter-injected guidance + // 跟 Codex instructions 拼到一起后,反复 convert 时被重复累积(连发 3 个 + // turn,每 turn 转换出的 messages 里仍只含 1 段 guidance)。 + let one_turn = json!({ + "input": [{"type": "message", "role": "user", "content": "edit"}], + "instructions": "You are helpful.", + "tools": [{ + "type": "custom", + "name": "apply_patch", + "description": "edit" + }] + }); + for _ in 0..3 { + let out = convert(one_turn.clone()); + let guidance_count = out["messages"] + .as_array() + .unwrap() + .iter() + .map(|m| m["content"].as_str().unwrap_or_default()) + .filter(|c| c.contains("apply_patch chat-path guidance")) + .count(); + assert_eq!(guidance_count, 1, "每次 convert 仅注入一次 guidance"); + } +} + #[test] fn custom_tool_call_input_item_lowered_to_assistant_tool_calls() { // 回归保护(issue #235):turn N+1 Codex CLI 回放上一轮的 diff --git a/crates/adapters/src/responses/request/tools.rs b/crates/adapters/src/responses/request/tools.rs index 1d7f0c4e..c8ef490d 100644 --- a/crates/adapters/src/responses/request/tools.rs +++ b/crates/adapters/src/responses/request/tools.rs @@ -58,7 +58,15 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( " println!(\"{}\", x);\n", "*** End Patch\n", "Notice: `fn main() {` appears in `@@ ... @@` once as the anchor, NOT again as a space-prefixed line below. ", - "The first content line under the anchor is the line immediately after `fn main() {` in the file." + "The first content line under the anchor is the line immediately after `fn main() {` in the file.\n\n", + "CHAT-PATH GOTCHAS (the lark grammar is gone here; observed empirically with non-OpenAI providers):\n", + "1. Prefer matching an empty line as the `@@` anchor. Non-empty anchors often fail to match on this path. ", + "If needed, run `printf '\\n' >> ` first to seed a blank line, then `@@` with empty content, then clean up extra blank lines afterward.\n", + "2. Do NOT combine `*** Add File: foo` and `*** Update File: foo` in the SAME patch — Update reads the file before Add lands on disk. ", + "Either make Add File write the final content in one shot, or split into two separate patches.\n", + "3. `*** Update File:` cannot operate on a completely empty file. Use shell to write at least one line first, then apply_patch.\n", + "4. In a multi-line file, lone `+` lines AFTER an `@@` anchor APPEND below the anchor — they do NOT replace the anchor line. ", + "To change a line, use `-` to remove the old line AND `+` to add the new one; do not omit the `-`." ); /// Chat-path replacement for the freeform `input` parameter description. @@ -73,7 +81,10 @@ pub(crate) const APPLY_PATCH_INPUT_DESCRIPTION_FOR_CHAT: &str = concat!( "Use `*** Add File:`, `*** Update File:`, or `*** Delete File:` headers and ", "`@@ @@` hunks with ` `/`+`/`-` line prefixes. Relative paths only. ", "CRITICAL: in an Update hunk the `@@ @@` anchor is a SINGLE existing file line; ", - "the space-prefixed lines following the anchor describe lines AFTER it (do not repeat the anchor)." + "the space-prefixed lines following the anchor describe lines AFTER it (do not repeat the anchor). ", + "Chat-path gotchas: prefer empty-line anchors (seed with `printf '\\n' >> file` if needed); ", + "do not Add+Update the same path in one patch; Update cannot operate on a totally empty file; ", + "lone `+` lines after `@@` APPEND below the anchor (use `-` + `+` to replace a line)." ); /// Responses tool 定义 → Chat tool 定义. From 3a7fb33def7284abd4829443807cba5cd41245de Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Thu, 21 May 2026 20:23:19 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(adapters):=20apply=5Fpatch=20V4A=20`@@`?= =?UTF-8?q?=20=E5=8D=95=E7=AB=AF=E8=AF=AD=E6=B3=95=20+=20=E5=88=A0=20EMPTY?= =?UTF-8?q?=20LINE=20=E8=AF=AF=E5=AF=BC(round=204=20=E5=AE=9E=E8=AF=81?= =?UTF-8?q?=E6=A0=B9=E5=9B=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #235 round 4 真机 capture(PR #236 单独 Kimi 测试,38 turn / 4.6 min) 显示 Update File 操作大面积失败。Kimi reasoning 自己诊断: > 错误信息说找不到 `MSG = 'hello' @@`,而不是 `MSG = 'hello'`... > 这看起来像是解析器把锚点行和后面的 @@ 一起当作文本了 对照上游 V4A 规范(`openai/codex` `codex-rs/core/prompt_with_apply_patch_instructions.md` L298-314),V4A `@@` 是**单端语法**:`@@
` 命名 class/function 等 section,**没有**尾随 `@@`。多 anchor 用多行 `@@ class X\n@@ def y():`。 本仓库 PR #236 初版 prompt 把语法写成 `@@ @@` 双端,叠加第 1 条 "Use an EMPTY LINE as the `@@` anchor whenever possible" 推荐,**双重错误** 导致 Codex Desktop V4A applier 把第二个 `@@` 当 anchor 文本字面一部分, 所有 Update File 调用报 `Failed to find context '... @@'`。 修复(3 处) - `crates/adapters/src/responses/request/tools.rs::APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT`: - `@@ @@` markers → `@@
` single-sided (no trailing `@@`) - CRITICAL HUNK SEMANTICS 重写:明示 single-sided + `@@` 是 OPTIONAL(3 行 context 足够时可省)+ 多 anchor 用多行 `@@` 单端 - 新增 ADD FILE FORMAT 章节:Add File **不**用 `@@`/hunks,每行 `+` 前缀 (含 blank 行作 bare `+`),对抗 `'def main():' is not a valid hunk header` - 3 个 example:Update 含 `@@
` / Add File 全 `+` / Update 无 `@@` (3 行 context 即可) - 加 BYTE-EXACT MATCHING 章节 - chat-path gotchas 加第 5 条:Update 反复失败时 fallback Delete+Add File - `APPLY_PATCH_INPUT_DESCRIPTION_FOR_CHAT`:同步紧凑版含 single-sided / no trailing `@@` / byte-exact / Delete+Add 兜底 - `crates/adapters/src/responses/request.rs::APPLY_PATCH_CHAT_PATH_SYSTEM_GUIDANCE`: 删除旧第 1 条 EMPTY LINE anchor 建议(误导),换成 single-sided 显式说明 + Add File `+` 前缀 + byte-exact + Update 反复失败 fallback Delete+Add 共 7 条 gotcha 单元测试更新 - `apply_patch_chat_path_guidance_injected_when_tool_registered`:断言新规则 (SINGLE-SIDED / never trailing / byte-for-byte / prefix EVERY line / Delete + Add) - `tools_custom_apply_patch_injects_v4a_format_hint`:断言 outer + input desc 含 single-sided / no trailing `@@` / byte-exact / Add File example / Delete+Add fallback 509 tests pass。 修复后真机 round 5 验证待跑。 Refs #235 --- crates/adapters/src/responses/request.rs | 36 +++++-- .../adapters/src/responses/request/tests.rs | 78 ++++++++++++--- .../adapters/src/responses/request/tools.rs | 99 ++++++++++++++----- 3 files changed, 164 insertions(+), 49 deletions(-) diff --git a/crates/adapters/src/responses/request.rs b/crates/adapters/src/responses/request.rs index c0082549..86d5f371 100644 --- a/crates/adapters/src/responses/request.rs +++ b/crates/adapters/src/responses/request.rs @@ -2283,23 +2283,39 @@ use tools::{ }; /// chat-path 实战指引,作为独立 `role:"system"` 注入,仅在该 turn 的 tools -/// 数组里注册了 `apply_patch` 时启用。理由参见 issue #235 真机稳定性测试 -/// (DeepSeek 跑 10 个 Level 共发现的 4 个 chat-path 行为):tool/参数 -/// description 同时含紧凑版作 fallback,但 system message 在多数 chat -/// 上游里被赋予更高权重,且模型在 system 块里读到的指引更难被遗忘 / 截断。 +/// 数组里注册了 `apply_patch` 时启用。理由参见 issue #235 真机稳定性测试。 +/// +/// **本版本(round 4 capture 实证根因修复)** : +/// 旧版第 1 条"Use an EMPTY LINE as the `@@` anchor"是事实错误 — 上游 +/// V4A 官方规范(`codex-rs/core/prompt_with_apply_patch_instructions.md` +/// L298-314)的 `@@` 是**单端语法**:`@@
` 命名 class/function 等 +/// section,**不带尾随 `@@`**。旧版误写为 `@@ @@` 双端 + 推荐 +/// "empty content as anchor" 双重错误导致 Codex Desktop V4A applier +/// 全程匹配失败(`Failed to find context '... @@'`)。本次修订: +/// 1. 删除 EMPTY LINE anchor 建议(误导) +/// 2. 显式说明 `@@` 单端语法 + 给出 `@@ class X` / `@@ def f():` 示例 +/// 3. 加 Add File 必须每行 `+` 前缀的强调 +/// 4. 加 "If Update repeatedly fails, fall back to Delete + Add File" 兜底 const APPLY_PATCH_CHAT_PATH_SYSTEM_GUIDANCE: &str = concat!( "[apply_patch chat-path guidance — injected by codex-app-transfer adapter because the upstream lark grammar constraint is unavailable on chat function-call providers]\n", "When you call the `apply_patch` tool, follow these rules empirically observed with non-OpenAI chat providers:\n", "\n", - "1. Use an EMPTY LINE as the `@@` anchor whenever possible. Non-empty anchors (e.g. `@@ Hello World!`) frequently fail to match on this path. ", - "If the target file lacks a blank line near your hunk, first run `printf '\\n' >> ` via shell to seed one, then use `@@` with empty content as the anchor, and clean up extra blank lines after the patch lands.\n", + "1. The V4A `@@` operator is SINGLE-SIDED: write `@@
` (e.g. `@@ class MyClass`, `@@ def my_function():`, `@@ fn main() {`) — **never** add a trailing `@@`. ", + "Writing `@@
@@` (double-sided) causes Codex Desktop's V4A applier to treat the trailing `@@` as literal text and fail with `Failed to find context '... @@'`. ", + "The `@@
` line itself is OPTIONAL: if 3 lines of surrounding space-prefixed context already uniquely identify the location, omit `@@` entirely. ", + "If a single `@@` is ambiguous (e.g. same method name in multiple classes), use MULTIPLE `@@` lines on separate rows (`@@ class Outer\\n@@ def inner():`).\n", + "\n", + "2. Add File uses NO `@@` markers and NO hunks. After `*** Add File: `, prefix EVERY line of the new file's content with `+`, including blank lines (write them as a bare `+` on its own row). Raw source code without `+` prefix (e.g. `def main():` directly) causes `'def main():' is not a valid hunk header` errors.\n", + "\n", + "3. Every `-` line and space-prefixed context line MUST match the file byte-for-byte (same leading whitespace, no trimmed trailing spaces, exact characters). If unsure, run `cat ` or `sed -n '1,80p' ` via shell first, then compose the patch from real bytes. Guessing produces `Failed to find context ''` errors.\n", + "\n", + "4. Do NOT combine `*** Add File: ` and `*** Update File: ` for the same path in a single patch. The Update step reads the file before the Add step lands on disk, so it sees an empty file and fails. Either: (a) make `*** Add File:` write the final content in one shot, or (b) split into two separate `apply_patch` invocations.\n", "\n", - "2. Do NOT combine `*** Add File: ` and `*** Update File: ` for the same path in a single patch. ", - "The Update step reads the file before the Add step lands on disk, so it sees an empty file and fails. Either: (a) make `*** Add File:` write the final content in one shot, or (b) split into two separate `apply_patch` invocations.\n", + "5. `*** Update File:` cannot operate on a totally empty file. If the target is empty, first use shell (e.g. `printf '\\n' > `) to write at least one line, then call `apply_patch`.\n", "\n", - "3. `*** Update File:` cannot operate on a totally empty file. If the target is empty, first use shell (e.g. `printf '\\n' > `) to write at least one line, then call `apply_patch`.\n", + "6. In a multi-line file, lone `+` lines without a corresponding `-` line APPEND below the previous context — they do NOT replace any existing line. To change an existing line, you MUST include BOTH a `-` line (removing the old content) AND a `+` line (adding the new content).\n", "\n", - "4. In a multi-line file, lone `+` lines following an `@@` anchor APPEND below the anchor — they do NOT replace the anchor line. To change an existing line, you must include BOTH a `-` line to remove the old content AND a `+` line to add the new content. Do not omit the `-` line.\n", + "7. If repeated Update File attempts on the same target fail with `Failed to find context` errors, fall back to a Delete File + Add File pair within the same patch (semantically equivalent to a full rewrite, avoids anchor-matching fragility).\n", "\n", "Following these rules avoids retry storms and improves the success rate on first attempt." ); diff --git a/crates/adapters/src/responses/request/tests.rs b/crates/adapters/src/responses/request/tests.rs index 43f658e7..d4e0da3b 100644 --- a/crates/adapters/src/responses/request/tests.rs +++ b/crates/adapters/src/responses/request/tests.rs @@ -1987,11 +1987,35 @@ fn apply_patch_chat_path_guidance_injected_when_tool_registered() { guidance.contains("apply_patch chat-path guidance"), "注入的指引必须带可识别 marker:{guidance}" ); - // 4 个核心 workaround 都要含进去 - assert!(guidance.contains("empty line") || guidance.contains("EMPTY LINE")); + // 关键规则覆盖(round 4 capture 实证根因修复后): + // (1) `@@` 单端语法(NEVER trailing `@@`)— 旧版双端误导已删除 + assert!( + guidance.contains("SINGLE-SIDED") && guidance.contains("never"), + "guidance 必须强调 @@ 单端语法 + 禁尾随 @@:{guidance}" + ); + assert!( + !guidance.contains("EMPTY LINE as the `@@` anchor"), + "旧版 EMPTY LINE anchor 误导已删除:{guidance}" + ); + // (2) Add File 全 `+` 前缀(对抗 `def main():` 当 invalid hunk header) + assert!( + guidance.contains("prefix EVERY line") && guidance.contains("`+`"), + "guidance 必须强调 Add File 全 `+` 前缀:{guidance}" + ); + // (3) byte-exact matching + assert!( + guidance.contains("byte-for-byte"), + "guidance 必须含 byte-exact 匹配规则:{guidance}" + ); + // (4) Add+Update 同 path conflict assert!(guidance.contains("Add File") && guidance.contains("Update File")); + // (5) 空文件 + lone `+` APPEND + Delete+Add fallback 兜底 assert!(guidance.contains("empty file") || guidance.contains("totally empty")); assert!(guidance.contains("APPEND") || guidance.contains("append")); + assert!( + guidance.contains("Delete File + Add File"), + "guidance 必须含 Update 反复失败时 fallback 到 Delete+Add 兜底:{guidance}" + ); } #[test] @@ -2871,29 +2895,57 @@ fn tools_custom_apply_patch_injects_v4a_format_hint() { // 有 lark grammar 强约束的 chat 路径上反复栽在这里(把 anchor 当 space 行 // 重复一次),花 20 分钟、25+ 次 retry 最后 fallback 到 sed。description // 必须含可执行的最小示例 + 显式的"do not repeat the anchor"指引。 + // 关键断言(round 4 真机 capture 修复后): + // (1) 单端 `@@
` 语法(禁双端 `@@ ... @@` — round 4 根因) + assert!( + outer.contains("single-sided") && outer.contains("@@"), + "tool description 必须显式说明 @@ 单端语法:{outer}" + ); let outer_lc = outer.to_lowercase(); assert!( - outer.contains("@@") && outer.contains("anchor"), - "tool description 必须解释 hunk anchor 语义:{outer}" + outer_lc.contains("never write a trailing") + || outer_lc.contains("never add a trailing") + || outer.contains("no trailing `@@`"), + "必须显式禁止尾随 @@:{outer}" ); + // (2) Add File 全 `+` 前缀(对抗 `def main():` 当 invalid hunk header) assert!( - outer.contains("AFTER the anchor") || outer.contains("after it"), - "必须显式说明 space 行对应 anchor *之后* 的位置:{outer}" + outer.contains("prefix EVERY line") || outer.contains("prefixed with `+`"), + "Add File 必须强调每行 `+` 前缀:{outer}" ); + // (3) byte-exact matching(对抗 Failed to find context) assert!( - outer_lc.contains("do not repeat the anchor") || outer_lc.contains("not again as a space"), - "必须显式禁止把 anchor 当 space 行重复:{outer}" + outer.contains("byte-for-byte") || outer.contains("byte-exact"), + "必须含 byte-exact 匹配规则:{outer}" ); + // (4) 完整 V4A example 必须包含 assert!( outer.contains("*** Update File:") && outer.contains("@@ fn main()"), - "必须包含一个最小可执行 V4A 示例:{outer}" + "必须包含一个最小可执行 V4A Update example:{outer}" + ); + assert!( + outer.contains("*** Add File: hello.py"), + "必须包含一个 Add File example:{outer}" + ); + // (5) Delete + Add File fallback 兜底(Update 反复失败时) + assert!( + outer.contains("Delete File + Add File"), + "必须含 Update 反复失败时 fallback 到 Delete+Add 兜底:{outer}" ); - // 参数描述同样必须保留紧凑版的语义提示 + // 参数描述紧凑版必须含同样核心规则(round 4 修复后) + assert!( + input_desc.contains("single-sided") && input_desc.contains("@@"), + "input description 必须含 @@ 单端语法紧凑版:{input_desc}" + ); + let input_lc = input_desc.to_lowercase(); + assert!( + input_lc.contains("never write a trailing") || input_desc.contains("trailing `@@`"), + "input description 必须含禁尾随 @@ 紧凑版:{input_desc}" + ); assert!( - input_desc.contains("anchor") - && (input_desc.contains("AFTER") || input_desc.contains("after")), - "input description 必须保留 anchor 语义紧凑版:{input_desc}" + input_desc.contains("byte-exact") || input_desc.contains("byte-for-byte"), + "input description 必须含 byte-exact 紧凑版:{input_desc}" ); } diff --git a/crates/adapters/src/responses/request/tools.rs b/crates/adapters/src/responses/request/tools.rs index c8ef490d..33bccc0c 100644 --- a/crates/adapters/src/responses/request/tools.rs +++ b/crates/adapters/src/responses/request/tools.rs @@ -35,21 +35,31 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( "Each file operation header is one of `*** Add File: `, ", "`*** Update File: ` (optionally followed by `*** Move to: `), ", "or `*** Delete File: `. ", - "Within Update hunks, use `@@ @@` markers, prefix unchanged lines ", - "with a single space, removed lines with `-`, and added lines with `+`. ", + "Within Update hunks, use single-sided `@@
` markers (no trailing `@@`), ", + "prefix unchanged context lines with a single space, removed lines with `-`, ", + "and added lines with `+`. ", "Use relative paths only (never absolute). ", "Embed real newlines as `\\n` inside the JSON string value for `input`.\n\n", - "CRITICAL HUNK SEMANTICS (the most common cause of patch rejection):\n", - "`@@ @@` is an anchor that names ONE existing line in the file. ", - "Every space-prefixed line that follows the `@@` marker corresponds to lines ", - "AFTER the anchor in the file (not the anchor itself). ", - "Do NOT repeat the anchor line as the first space-prefixed line — the parser will reject it.\n\n", - "EXAMPLE — to change `let x = 1;` to `let x = 2;` in a file whose lines around the change read:\n", - " fn main() {\n", - " let x = 1;\n", - " println!(\"{}\", x);\n", - " }\n", - "The correct patch is:\n", + "CRITICAL `@@` ANCHOR SYNTAX (the most common cause of patch rejection on chat-completions providers):\n", + "The V4A `@@` operator is SINGLE-SIDED: write `@@
` where `
` ", + "names the class/function/section the hunk belongs to (e.g. `@@ class MyClass`, ", + "`@@ def my_function():`, `@@ fn main() {`). ", + "**NEVER write a trailing `@@` (e.g. `@@ def f(): @@`)** — Codex Desktop's V4A ", + "applier will treat the trailing `@@` as literal text inside the anchor and ", + "fail with `Failed to find context '... @@'`. ", + "The `@@` header is OPTIONAL: if 3 lines of surrounding context already uniquely ", + "identify the location, omit the `@@` line entirely. ", + "If a single `@@
` is ambiguous (same name appears in multiple classes), ", + "use MULTIPLE `@@` lines on separate rows (e.g. `@@ class Outer\\n@@ def inner():`) ", + "to narrow down — each line is one `@@
`, single-sided.\n\n", + "ADD FILE FORMAT (different from Update — no hunks, no `@@`):\n", + "After `*** Add File: `, **every line of the new file's content MUST be ", + "prefixed with `+`**, including blank lines (write them as a bare `+` on its own ", + "row). Do NOT use `@@` markers, hunks, or space-prefixed context lines in an ", + "Add File block — they are reserved for Update File. Writing raw source code ", + "(e.g. `def main():` with no `+` prefix) directly after `*** Add File:` causes ", + "`'def main():' is not a valid hunk header` errors.\n\n", + "EXAMPLE 1 — change a single line inside a function (Update File with `@@` header):\n", "*** Begin Patch\n", "*** Update File: src/main.rs\n", "@@ fn main() {\n", @@ -57,16 +67,47 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( "+ let x = 2;\n", " println!(\"{}\", x);\n", "*** End Patch\n", - "Notice: `fn main() {` appears in `@@ ... @@` once as the anchor, NOT again as a space-prefixed line below. ", - "The first content line under the anchor is the line immediately after `fn main() {` in the file.\n\n", + "Notice: `@@ fn main() {` is single-sided (no trailing `@@`). The `-` line ", + "is byte-exact what currently appears in the file. The space-prefixed line is ", + "kept as-is for context.\n\n", + "EXAMPLE 2 — create a brand new file (Add File, no `@@`, every line `+`):\n", + "*** Begin Patch\n", + "*** Add File: hello.py\n", + "+def greet(name: str) -> str:\n", + "+ return f\"Hello, {name}!\"\n", + "+\n", + "+if __name__ == \"__main__\":\n", + "+ print(greet(\"world\"))\n", + "*** End Patch\n", + "Notice: no `@@`, every line has `+` (including the blank line as a bare `+`).\n\n", + "EXAMPLE 3 — update a function body without an `@@` header (3-line context suffices):\n", + "*** Begin Patch\n", + "*** Update File: src/util.py\n", + " def divide(a, b):\n", + " \"\"\"Divide two numbers.\"\"\"\n", + "- return a / b\n", + "+ if b == 0:\n", + "+ raise ValueError(\"divide by zero\")\n", + "+ return a / b\n", + "*** End Patch\n", + "Notice: no `@@` line at all — 2 lines of space-prefixed context above the `-` ", + "line already uniquely identify where to apply. Use this whenever you have ", + "unique context.\n\n", + "BYTE-EXACT MATCHING (#1 cause of `Failed to find context` on this path):\n", + "Every `-` line and every space-prefixed context line MUST match the file ", + "byte-for-byte — same leading whitespace, no trimmed trailing spaces, exact ", + "characters. If unsure, run `cat ` or `sed -n '1,80p' ` via shell ", + "to read it first, then compose the patch from real bytes. Guessing or ", + "paraphrasing produces `Failed to find context ''` errors.\n\n", "CHAT-PATH GOTCHAS (the lark grammar is gone here; observed empirically with non-OpenAI providers):\n", - "1. Prefer matching an empty line as the `@@` anchor. Non-empty anchors often fail to match on this path. ", - "If needed, run `printf '\\n' >> ` first to seed a blank line, then `@@` with empty content, then clean up extra blank lines afterward.\n", + "1. Use the SINGLE-SIDED `@@
` form. The double-sided `@@ ... @@` form ", + "is NOT V4A — the trailing `@@` becomes literal text and breaks context matching.\n", "2. Do NOT combine `*** Add File: foo` and `*** Update File: foo` in the SAME patch — Update reads the file before Add lands on disk. ", "Either make Add File write the final content in one shot, or split into two separate patches.\n", "3. `*** Update File:` cannot operate on a completely empty file. Use shell to write at least one line first, then apply_patch.\n", - "4. In a multi-line file, lone `+` lines AFTER an `@@` anchor APPEND below the anchor — they do NOT replace the anchor line. ", - "To change a line, use `-` to remove the old line AND `+` to add the new one; do not omit the `-`." + "4. In a multi-line file, lone `+` lines without a corresponding `-` APPEND below the previous context — they do NOT replace any existing line. ", + "To change a line, use `-` to remove the old line AND `+` to add the new one; do not omit the `-`.\n", + "5. If multiple Update attempts on the same file fail with `Failed to find context` errors, fall back to a Delete File + Add File pair within the same patch (semantically equivalent to a full rewrite) — this avoids anchor-matching fragility." ); /// Chat-path replacement for the freeform `input` parameter description. @@ -78,13 +119,19 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( /// histories — keep the rule visible at parameter level too). pub(crate) const APPLY_PATCH_INPUT_DESCRIPTION_FOR_CHAT: &str = concat!( "A V4A patch starting with `*** Begin Patch` and ending with `*** End Patch`. ", - "Use `*** Add File:`, `*** Update File:`, or `*** Delete File:` headers and ", - "`@@ @@` hunks with ` `/`+`/`-` line prefixes. Relative paths only. ", - "CRITICAL: in an Update hunk the `@@ @@` anchor is a SINGLE existing file line; ", - "the space-prefixed lines following the anchor describe lines AFTER it (do not repeat the anchor). ", - "Chat-path gotchas: prefer empty-line anchors (seed with `printf '\\n' >> file` if needed); ", - "do not Add+Update the same path in one patch; Update cannot operate on a totally empty file; ", - "lone `+` lines after `@@` APPEND below the anchor (use `-` + `+` to replace a line)." + "Use `*** Add File:`, `*** Update File:`, or `*** Delete File:` headers. ", + "Update hunks use single-sided `@@
` markers (e.g. `@@ class Foo`, `@@ def bar():`) ", + "with ` `/`+`/`-` line prefixes. **NEVER write a trailing `@@`** ", + "(`@@
@@` is wrong — Codex Desktop applier treats trailing `@@` as ", + "literal text and fails with `Failed to find context '... @@'`). ", + "`@@
` is OPTIONAL when 3 lines of surrounding context already pin the location. ", + "Add File uses NO `@@` markers — prefix EVERY line of new content with `+` ", + "(blank lines as bare `+`). Relative paths only. ", + "`-` lines and space-prefixed context lines MUST be byte-exact to the file's current content ", + "(read via `cat ` first if unsure) — guessing produces `Failed to find context` errors. ", + "Chat-path gotchas: do not Add+Update the same path in one patch; Update cannot ", + "operate on a totally empty file; lone `+` without `-` appends instead of replacing. ", + "If Update fails repeatedly, fall back to Delete File + Add File in one patch." ); /// Responses tool 定义 → Chat tool 定义. From 71cc402e1e51e7876cee5bd34718c7512bb84e57 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Thu, 21 May 2026 20:39:02 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(adapters):=20apply=5Fpatch=20prompt=20?= =?UTF-8?q?=E6=98=8E=E7=A4=BA"=E6=97=A0=20@@=20minimal=20Update"=E5=BD=A2?= =?UTF-8?q?=E5=BC=8F=20+=20=E6=A0=A1=E5=87=86=20-text=20=E6=97=A0=E7=A9=BA?= =?UTF-8?q?=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit round 5 真机 capture(PR #236 V4A 单端修复后,4 个 task 全 success)显示 Kimi 自创"无 @@ 直接 -/+"简化模式,reasoning t0012 原话: "不需要 @@ 标记,因为文件内容简单,上下文足够" 但 t0004/t0012/t0019 V4A 输出含 `- text`(dash+space)而非 V4A 规范的 `-text`,Codex Desktop applier 容错但其他 applier 严格。 用户反馈:"Kimi 自己摸索出简化用法但其他模型未必有这个能力,需要在 prompt 里明示"。 修复(stack 进 PR #236) - `tools.rs::APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT`: * 顶部 Update hunks 段重写为"simplest form = -/+ rows directly, no @@" + 加 "Lines are `-line`/`+line`/` line` (no space between prefix and content)" * 加 LINE PREFIX FORMAT 章节强调无空格 * EXAMPLE 1 重新定义为 MINIMAL UPDATE(`-DEBUG=False` / `+DEBUG=True`) * EXAMPLE 2 改为"@@ 仅在歧义时用" * EXAMPLE 3 = Add File(全 `+` 前缀)保持 * EXAMPLE 4 = 用 context lines(不用 `@@`) - `APPLY_PATCH_INPUT_DESCRIPTION_FOR_CHAT`:紧凑版同步,首推 minimal form - `APPLY_PATCH_CHAT_PATH_SYSTEM_GUIDANCE`: * 第 1 条重写为"PREFERRED Update File form is MINIMAL: just `-line` and `+line` directly, NO `@@`, NO context";`@@` 降级为"only if context lines also insufficient" * 加第 3a 条 "Line prefix is a SINGLE character with NO space between prefix and content" — 给其他严格 applier 留余地 修复目标:让 DeepSeek / Grok / 其他 chat-completions provider 上的模型 跟 Kimi 一样默认用 minimal form,不再被 `@@` 语法折磨。 509 tests pass。 Refs #235 --- crates/adapters/src/responses/request.rs | 15 +++-- .../adapters/src/responses/request/tools.rs | 57 +++++++++++++------ 2 files changed, 51 insertions(+), 21 deletions(-) diff --git a/crates/adapters/src/responses/request.rs b/crates/adapters/src/responses/request.rs index 86d5f371..be3c3e2c 100644 --- a/crates/adapters/src/responses/request.rs +++ b/crates/adapters/src/responses/request.rs @@ -2300,15 +2300,22 @@ const APPLY_PATCH_CHAT_PATH_SYSTEM_GUIDANCE: &str = concat!( "[apply_patch chat-path guidance — injected by codex-app-transfer adapter because the upstream lark grammar constraint is unavailable on chat function-call providers]\n", "When you call the `apply_patch` tool, follow these rules empirically observed with non-OpenAI chat providers:\n", "\n", - "1. The V4A `@@` operator is SINGLE-SIDED: write `@@
` (e.g. `@@ class MyClass`, `@@ def my_function():`, `@@ fn main() {`) — **never** add a trailing `@@`. ", - "Writing `@@
@@` (double-sided) causes Codex Desktop's V4A applier to treat the trailing `@@` as literal text and fail with `Failed to find context '... @@'`. ", - "The `@@
` line itself is OPTIONAL: if 3 lines of surrounding space-prefixed context already uniquely identify the location, omit `@@` entirely. ", - "If a single `@@` is ambiguous (e.g. same method name in multiple classes), use MULTIPLE `@@` lines on separate rows (`@@ class Outer\\n@@ def inner():`).\n", + "1. PREFERRED Update File form is MINIMAL: just `-line` (the row to remove, byte-exact) and `+line` (the new row) directly after `*** Update File: ` — NO `@@`, NO context lines. ", + "Use this whenever the `-` line is unique in the file (true for most simple single-line edits, config changes, function signatures, etc.). Example:\n", + " *** Update File: src/config.py\n", + " -DEBUG = False\n", + " +DEBUG = True\n", + "If the `-` line alone is ambiguous (same line text in multiple places), add space-prefixed context lines (` line`) above/below to pin it down. ", + "Only if context lines are also insufficient, add a SINGLE-SIDED `@@
` marker on its own row (`@@ class Foo`, `@@ def bar():`, `@@ fn main() {`). ", + "**NEVER add a trailing `@@`** (`@@
@@` is wrong) — Codex Desktop's V4A applier treats trailing `@@` as literal text and fails with `Failed to find context '... @@'`. ", + "For deeply nested disambiguation use MULTIPLE `@@` lines on separate rows (e.g. `@@ class Outer\\n@@ def inner():`), each single-sided.\n", "\n", "2. Add File uses NO `@@` markers and NO hunks. After `*** Add File: `, prefix EVERY line of the new file's content with `+`, including blank lines (write them as a bare `+` on its own row). Raw source code without `+` prefix (e.g. `def main():` directly) causes `'def main():' is not a valid hunk header` errors.\n", "\n", "3. Every `-` line and space-prefixed context line MUST match the file byte-for-byte (same leading whitespace, no trimmed trailing spaces, exact characters). If unsure, run `cat ` or `sed -n '1,80p' ` via shell first, then compose the patch from real bytes. Guessing produces `Failed to find context ''` errors.\n", "\n", + "3a. Line prefix is a SINGLE character with NO space between prefix and content: write `-DEBUG = False` (not `- DEBUG = False`), `+DEBUG = True` (not `+ DEBUG = True`), and ` keepme` (single leading space, for unchanged context). Codex Desktop V4A applier may tolerate a stray space, but other apply_patch implementations are strict — keep the prefix tight.\n", + "\n", "4. Do NOT combine `*** Add File: ` and `*** Update File: ` for the same path in a single patch. The Update step reads the file before the Add step lands on disk, so it sees an empty file and fails. Either: (a) make `*** Add File:` write the final content in one shot, or (b) split into two separate `apply_patch` invocations.\n", "\n", "5. `*** Update File:` cannot operate on a totally empty file. If the target is empty, first use shell (e.g. `printf '\\n' > `) to write at least one line, then call `apply_patch`.\n", diff --git a/crates/adapters/src/responses/request/tools.rs b/crates/adapters/src/responses/request/tools.rs index 33bccc0c..620e140b 100644 --- a/crates/adapters/src/responses/request/tools.rs +++ b/crates/adapters/src/responses/request/tools.rs @@ -35,9 +35,12 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( "Each file operation header is one of `*** Add File: `, ", "`*** Update File: ` (optionally followed by `*** Move to: `), ", "or `*** Delete File: `. ", - "Within Update hunks, use single-sided `@@
` markers (no trailing `@@`), ", - "prefix unchanged context lines with a single space, removed lines with `-`, ", - "and added lines with `+`. ", + "Within Update hunks, the simplest form is just `-`/`+` lines with no `@@` and ", + "no context (suitable when the `-` line is unique in the file). If disambiguation ", + "is needed, add space-prefixed context lines, or a single-sided `@@
` ", + "marker (e.g. `@@ class Foo`, `@@ def bar():`) — NEVER add a trailing `@@`. ", + "Lines are `-line` (removed, no space after `-`), `+line` (added, no space after `+`), ", + "or ` line` (single leading space = unchanged context). ", "Use relative paths only (never absolute). ", "Embed real newlines as `\\n` inside the JSON string value for `input`.\n\n", "CRITICAL `@@` ANCHOR SYNTAX (the most common cause of patch rejection on chat-completions providers):\n", @@ -59,7 +62,24 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( "Add File block — they are reserved for Update File. Writing raw source code ", "(e.g. `def main():` with no `+` prefix) directly after `*** Add File:` causes ", "`'def main():' is not a valid hunk header` errors.\n\n", - "EXAMPLE 1 — change a single line inside a function (Update File with `@@` header):\n", + "LINE PREFIX FORMAT (zero whitespace between prefix and content):\n", + "Every line in a hunk starts with exactly ONE character followed by content with ", + "NO intervening space — `-line_content` (NOT `- line_content`), `+line_content` ", + "(NOT `+ line_content`), ` line_content` (single leading space = unchanged context). ", + "Codex Desktop V4A applier may tolerate a stray space, but other apply_patch ", + "implementations are strict — keep the prefix tight.\n\n", + "EXAMPLE 1 (MINIMAL UPDATE — preferred form for simple single-line edits): ", + "When the `-` line you remove is byte-exact and unique in the file, you may omit ", + "BOTH `@@` markers AND context lines — just write `-` and `+` lines directly:\n", + "*** Begin Patch\n", + "*** Update File: src/config.py\n", + "-DEBUG = False\n", + "+DEBUG = True\n", + "*** End Patch\n", + "This is the simplest and most reliable mode on chat-completions providers. Use ", + "it whenever the `-` line is unique enough to pinpoint the change location.\n\n", + "EXAMPLE 2 — Update with `@@` header (only when needed: same name in multiple ", + "classes/functions, or you want to disambiguate which occurrence to change):\n", "*** Begin Patch\n", "*** Update File: src/main.rs\n", "@@ fn main() {\n", @@ -69,8 +89,9 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( "*** End Patch\n", "Notice: `@@ fn main() {` is single-sided (no trailing `@@`). The `-` line ", "is byte-exact what currently appears in the file. The space-prefixed line is ", - "kept as-is for context.\n\n", - "EXAMPLE 2 — create a brand new file (Add File, no `@@`, every line `+`):\n", + "kept as-is for context. Use this form when `let x = 1;` appears in multiple ", + "functions and you need to specify which one.\n\n", + "EXAMPLE 3 — create a brand new file (Add File, no `@@`, every line `+`):\n", "*** Begin Patch\n", "*** Add File: hello.py\n", "+def greet(name: str) -> str:\n", @@ -80,7 +101,8 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( "+ print(greet(\"world\"))\n", "*** End Patch\n", "Notice: no `@@`, every line has `+` (including the blank line as a bare `+`).\n\n", - "EXAMPLE 3 — update a function body without an `@@` header (3-line context suffices):\n", + "EXAMPLE 4 — update a function body with context lines (no `@@`, use when the ", + "`-` line is not unique enough by itself but a few surrounding lines pin it):\n", "*** Begin Patch\n", "*** Update File: src/util.py\n", " def divide(a, b):\n", @@ -90,9 +112,9 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( "+ raise ValueError(\"divide by zero\")\n", "+ return a / b\n", "*** End Patch\n", - "Notice: no `@@` line at all — 2 lines of space-prefixed context above the `-` ", - "line already uniquely identify where to apply. Use this whenever you have ", - "unique context.\n\n", + "Notice: 2 lines of space-prefixed context above the `-` line uniquely identify ", + "where to apply. Use this when minimal form (EXAMPLE 1) is ambiguous but `@@` ", + "(EXAMPLE 2) is overkill.\n\n", "BYTE-EXACT MATCHING (#1 cause of `Failed to find context` on this path):\n", "Every `-` line and every space-prefixed context line MUST match the file ", "byte-for-byte — same leading whitespace, no trimmed trailing spaces, exact ", @@ -120,14 +142,15 @@ pub(crate) const APPLY_PATCH_TOOL_DESCRIPTION_FOR_CHAT: &str = concat!( pub(crate) const APPLY_PATCH_INPUT_DESCRIPTION_FOR_CHAT: &str = concat!( "A V4A patch starting with `*** Begin Patch` and ending with `*** End Patch`. ", "Use `*** Add File:`, `*** Update File:`, or `*** Delete File:` headers. ", - "Update hunks use single-sided `@@
` markers (e.g. `@@ class Foo`, `@@ def bar():`) ", - "with ` `/`+`/`-` line prefixes. **NEVER write a trailing `@@`** ", - "(`@@
@@` is wrong — Codex Desktop applier treats trailing `@@` as ", - "literal text and fails with `Failed to find context '... @@'`). ", - "`@@
` is OPTIONAL when 3 lines of surrounding context already pin the location. ", - "Add File uses NO `@@` markers — prefix EVERY line of new content with `+` ", + "Update File simplest form: just `-line`/`+line` rows directly after the header ", + "(no `@@`, no context) — use this when the `-` line is unique in the file. ", + "If ambiguous, add space-prefixed context ` line` lines around the change, or ", + "single-sided `@@
` (e.g. `@@ def func():`, NO trailing `@@`). ", + "Writing `@@
@@` (double-sided) fails with `Failed to find context '... @@'`. ", + "Lines are `-text`/`+text`/` text` (single char prefix, NO space between prefix and content). ", + "Add File uses NO `@@` and NO hunks — prefix EVERY new content line with `+` ", "(blank lines as bare `+`). Relative paths only. ", - "`-` lines and space-prefixed context lines MUST be byte-exact to the file's current content ", + "`-` lines and space-prefixed context MUST be byte-exact to the file's current content ", "(read via `cat ` first if unsure) — guessing produces `Failed to find context` errors. ", "Chat-path gotchas: do not Add+Update the same path in one patch; Update cannot ", "operate on a totally empty file; lone `+` without `-` appends instead of replacing. ", From cd3ef8cc4b6361b9e724a538a3f67355ec14a878 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Thu, 21 May 2026 21:05:29 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(adapters):=20interrupted=20apply=5Fpatc?= =?UTF-8?q?h=20envelope=20status=20=E8=B7=9F=E6=B5=81=E5=BC=8F=20done=20ev?= =?UTF-8?q?ent=20=E4=B8=80=E8=87=B4(incomplete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin pre-merge review BUG_pr-review-job-9600e18f8e4c4a90 修复 报告问题:`close_tool_call` 在 interrupted apply_patch 路径已正确 emit `response.output_item.done` 的 `item.status="incomplete"`(防 Codex CLI 在 partial V4A 上执行 destructive patch),但 `tool_call_item_completed`(envelope `response.completed.output[]` 终态)硬编码 `"status":"completed"`,违反 "envelope.output[] 终态必须和流式 done event 的 item 一致"不变量(L1042-1043 代码注释明示)。严格客户端读 envelope 而非流式 done event 会误以为 patch 完整 并执行 partial V4A。 修复 - `PendingToolCall` 新增 `interrupted_during_close: bool` 字段记忆 interrupted 状态 - `close_tool_call` apply_patch interrupted 分支 set `interrupted_during_close=true` 同时 set `closed=true` - `tool_call_item_completed` apply_patch 路径读 flag 决定 `status` 为 `incomplete` / `completed` 回归测试 - 既有测 `apply_patch_interrupted_stream_emits_incomplete_status_skips_input_done` 补充 envelope.output[] item.status 断言(原测只断言 response-level status, 未发现这个不一致) 509 tests pass。 Refs #235 --- crates/adapters/src/responses/converter.rs | 38 +++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/adapters/src/responses/converter.rs b/crates/adapters/src/responses/converter.rs index 7ed86939..ea83fa28 100644 --- a/crates/adapters/src/responses/converter.rs +++ b/crates/adapters/src/responses/converter.rs @@ -81,6 +81,13 @@ struct PendingToolCall { /// 完全一致(避免 args_acc 在 close 与 envelope 构造之间发生变化时 /// 静默 drift)。 apply_patch_input: Option, + /// `close_tool_call` 在 interrupted apply_patch 路径 emit + /// `response.output_item.done` 时强制 `status="incomplete"`(防 Codex CLI + /// 在 partial V4A 上跑 destructive apply)。**envelope 终态必须保持一致** + /// (`tool_call_item_completed` 也得读 incomplete 才不会跟流式 done event + /// 矛盾,严格客户端读 envelope 误以为完成会执行 partial patch)。 + /// Devin AI BUG_pr-review-job 报告修复。 + interrupted_during_close: bool, } /// Codex CLI 把 `apply_patch` 作为 freeform 工具注册 @@ -543,6 +550,7 @@ impl ChatToResponsesConverter { is_apply_patch, output_item_added_emitted: false, apply_patch_input: None, + interrupted_during_close: false, }, ); // apply_patch:wire 必须是 `custom_tool_call`(裸 `input` 字段)。 @@ -756,8 +764,12 @@ impl ChatToResponsesConverter { ); // 不存 cache(下一轮如果引用此 call_id 重建会拿到 incomplete // 上下文,反而误导;让 orphan repair 路径补占位)。 + // 标记 interrupted 让 `tool_call_item_completed` 在 envelope + // 终态也 emit `status="incomplete"`,严格客户端读 envelope + // 才不会误以为 patch 完整(Devin pre-merge review 修复)。 if let Some(pending) = self.tool_calls.get_mut(&openai_index) { pending.closed = true; + pending.interrupted_during_close = true; } return; } @@ -1051,13 +1063,22 @@ impl ChatToResponsesConverter { .apply_patch_input .clone() .unwrap_or_else(|| pending.args_acc.clone()); + // interrupted apply_patch envelope 终态必须跟流式 done event 一致 + // 为 `incomplete`,否则严格客户端读 envelope output[] 看到 + // `completed` 会执行 partial V4A patch(destructive)— Devin + // pre-merge review 报告 BUG_pr-review-job-9600e18f8e4c4a90 修复。 + let status = if pending.interrupted_during_close { + "incomplete" + } else { + "completed" + }; return json!({ "type": "custom_tool_call", "id": pending.fc_id, "call_id": pending.call_id, "name": pending.name, "input": input, - "status": "completed", + "status": status, }); } let mut item = json!({ @@ -2946,6 +2967,21 @@ data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]} completed.1["response"]["incomplete_details"]["reason"], "interrupted" ); + + // Devin pre-merge review BUG_pr-review-job-9600e18f8e4c4a90 防御回归: + // envelope.output[] 终态 item.status 必须跟流式 `response.output_item.done` + // 一致(都是 `incomplete`);若 envelope 写 `completed` 而流式 done 写 + // `incomplete`,严格客户端读 envelope 会误执行 partial V4A patch + // (destructive)。 + let final_output = completed.1["response"]["output"].as_array().unwrap(); + let final_apply_patch_item = final_output + .iter() + .find(|item| item.get("type").and_then(|v| v.as_str()) == Some("custom_tool_call")) + .expect("envelope.output 必须含 apply_patch custom_tool_call item"); + assert_eq!( + final_apply_patch_item["status"], "incomplete", + "interrupted apply_patch envelope.output[] item.status 必须跟流式 done event 一致(都是 incomplete)" + ); } #[test] From 51786bad26e62ea4db4ac90f80bf25a3b8954b35 Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Thu, 21 May 2026 21:16:56 +0800 Subject: [PATCH 7/8] =?UTF-8?q?test(adapters):=20fix=20`guidance.contains(?= =?UTF-8?q?"never")`=20=E5=AD=90=E4=B8=B2=E8=AF=AF=E5=91=BD=E4=B8=AD(Devin?= =?UTF-8?q?=20pre-merge=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin BUG_pr-review-job-7be7d34a970e440a880c3fdc49685165 修复 原断言 `guidance.contains("never")` 通过不是因为命中 "**NEVER add a trailing**" 规则,而是因为 prompt 另一处含 "whenever" 一词的子串。如果未来 refactor 删 "whenever" 同时也删 "NEVER add a trailing" 规则,test 不会发现 critical rule 丢失。 精确改成 `guidance.contains("NEVER add a trailing")` 大写匹配。 Refs #235 --- crates/adapters/src/responses/request/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/adapters/src/responses/request/tests.rs b/crates/adapters/src/responses/request/tests.rs index d4e0da3b..af19fb08 100644 --- a/crates/adapters/src/responses/request/tests.rs +++ b/crates/adapters/src/responses/request/tests.rs @@ -1989,8 +1989,10 @@ fn apply_patch_chat_path_guidance_injected_when_tool_registered() { ); // 关键规则覆盖(round 4 capture 实证根因修复后): // (1) `@@` 单端语法(NEVER trailing `@@`)— 旧版双端误导已删除 + // 注意:用精确大写 "NEVER add a trailing" 匹配本规则,不能用小写 "never" + // (会被 "whenever" 等无关词的子串误命中,Devin pre-merge review 修复)。 assert!( - guidance.contains("SINGLE-SIDED") && guidance.contains("never"), + guidance.contains("SINGLE-SIDED") && guidance.contains("NEVER add a trailing"), "guidance 必须强调 @@ 单端语法 + 禁尾随 @@:{guidance}" ); assert!( From 5fd18536e0c48949e42352dcb65190c5e6f0ab1c Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Thu, 21 May 2026 21:25:46 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(adapters):=20apply=5Fpatch=20guidance?= =?UTF-8?q?=20=E4=BB=85=20first=20turn=20=E6=B3=A8=E5=85=A5,=E9=98=B2?= =?UTF-8?q?=E5=A4=9A=E8=BD=AE=E7=B4=AF=E7=A7=AF=E6=B1=A1=E6=9F=93=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin pre-merge review BUG_pr-review-job-7be7d34a970e440a880c3fdc49685165_0002 修复 报告问题:`build_messages_from_input` 无条件注入 apply_patch guidance 到 messages[1]。`merge_messages_with_previous_response` 在合并 cached history 时只去重 messages[0](instructions),不去重 guidance。session cache 保存的 是完整 merged messages(含上一轮的 guidance),所以每个后续 turn 都会拼回 上一轮 guidance + 再注入一份新的,N 轮后累积 N 份 ~2KB,token 浪费 + 长 apply_patch 工作流(5-10 turn)上下文被挤出。 修复:caller 这里 turn-gating — 检查 `previous_response_id` 是否为空: - 空 = first turn → 注入(history 还没有 guidance) - 非空 = 后续 turn → 不注入(history 已含上一轮注入的 guidance) 边界:first turn 没注册 apply_patch、中段才首次注册的场景会 miss 注入。 实测罕见(Codex Desktop 启动即注册 apply_patch tool),且 tool description 本身已含完整 V4A 规则,模型仍能正确生成 patch。 新增回归测试 `apply_patch_chat_path_guidance_skipped_when_previous_response_id_set` 覆盖后续 turn 不注入。 510 tests pass。 Refs #235 --- crates/adapters/src/responses/request.rs | 25 ++++++++++++--- .../adapters/src/responses/request/tests.rs | 32 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/adapters/src/responses/request.rs b/crates/adapters/src/responses/request.rs index be3c3e2c..d1fc4105 100644 --- a/crates/adapters/src/responses/request.rs +++ b/crates/adapters/src/responses/request.rs @@ -297,10 +297,27 @@ fn build_messages_from_input( } // 紧跟 Codex CLI 自带 instructions 之后注入 apply_patch chat-path 指引 - // (仅当本 turn 真正注册了 apply_patch 工具时)。位置选择:Codex 系统 - // 指令之后,user input 之前 — 既不污染 Codex 原指令,又确保模型在 - // 读完工具列表准备调 apply_patch 时已经见过 chat-path 限制。 - if tools_register_apply_patch(body) { + // (仅当本 turn 真正注册了 apply_patch 工具 **且** 本轮是 first turn 时)。 + // 位置选择:Codex 系统指令之后,user input 之前 — 既不污染 Codex 原指令, + // 又确保模型在读完工具列表准备调 apply_patch 时已经见过 chat-path 限制。 + // + // **仅 first turn 注入**(Devin pre-merge review BUG 修复):带 + // `previous_response_id` 的后续 turn,`merge_messages_with_previous_response` + // 把 cached history 拼到 current_messages 前面,history 已经包含上一轮注入的 + // guidance(session_cache 保存 merged messages)。如果继续注入,每 turn 都会 + // 加一份 ~2KB guidance,N 轮后 N 份,token 浪费 + 长 apply_patch 工作流 + // (5-10 turn)上下文被挤出。merge 阶段只去重 `messages[0]` instructions, + // 不去重 guidance(它在 index 1),所以必须 caller 这里做 turn-gating。 + // + // 边界:如果 first turn 没注册 apply_patch、中段 turn 才首次注册,会 miss + // 注入 — 实测罕见(Codex Desktop 启动即注册 apply_patch tool),且 tool + // description 本身已含完整 V4A 规则,模型仍能正确生成 patch。 + let is_first_turn = body + .get("previous_response_id") + .and_then(|v| v.as_str()) + .map(|s| s.trim().is_empty()) + .unwrap_or(true); + if is_first_turn && tools_register_apply_patch(body) { messages.push(apply_patch_chat_guidance_message()); } diff --git a/crates/adapters/src/responses/request/tests.rs b/crates/adapters/src/responses/request/tests.rs index af19fb08..a9a6a0a3 100644 --- a/crates/adapters/src/responses/request/tests.rs +++ b/crates/adapters/src/responses/request/tests.rs @@ -2046,6 +2046,38 @@ fn apply_patch_chat_path_guidance_skipped_when_tool_not_registered() { ); } +#[test] +fn apply_patch_chat_path_guidance_skipped_when_previous_response_id_set() { + // Devin pre-merge review BUG 修复:带 previous_response_id 的后续 turn, + // history 已经从 session cache 拼回来(其中含上一轮注入的 guidance), + // 当前 turn **不应**再注入,否则每 turn 累积一份 ~2KB,N 轮后 N 份 + // 浪费 token + 挤出上下文。 + let out = convert(json!({ + "input": [{"type": "message", "role": "user", "content": "another edit"}], + "instructions": "You are a coding assistant.", + "previous_response_id": "resp_18b_some_prior", + "tools": [{ + "type": "custom", + "name": "apply_patch", + "description": "Use the `apply_patch` tool to edit files." + }] + })); + let messages = out["messages"].as_array().unwrap(); + let guidance_count = messages + .iter() + .filter(|m| { + m["content"] + .as_str() + .unwrap_or_default() + .contains("apply_patch chat-path guidance") + }) + .count(); + assert_eq!( + guidance_count, 0, + "后续 turn(previous_response_id 非空)不应再注入 guidance(history 已含)" + ); +} + #[test] fn apply_patch_chat_path_guidance_idempotent_across_turns() { // 防止 merge_consecutive_system_messages 把 adapter-injected guidance