Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ With any provider enabled, Codex CLI's model picker shows `<provider> / <real-mo
- Manage multiple providers; map OpenAI model names (`gpt-5.5` / `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.3-codex` / `gpt-5.2`) to the provider's real model IDs
- Translate Codex CLI's Responses API streaming / non-streaming requests into upstream protocols: Chat Completions, Gemini Native (`:streamGenerateContent`), Gemini CLI OAuth (Cloud Code Assist), Anthropic Messages (`/v1/messages`), Grok Web (`/rest/app-chat/conversations/new`), Responses passthrough, etc.
- Multi-turn tool conversation context + `previous_response_id` history replay + autocompact expansion + thinking / reasoning_content injection — all aligned with the OpenAI Responses API protocol
- Codex CLI's freeform `apply_patch` tool (edit-file +/- diff UI) works on DeepSeek / Kimi / MiMo and other chat-completions providers: the adapter bridges Responses `custom_tool_call` ↔ chat `function_call` wire forms, the model emits V4A-format patches, Codex CLI renders the diff (issue #235)
- **Two-layer session history persistence**: L1 in-memory LRU + L2 sqlite with 30-day TTL (`~/.codex-app-transfer/sessions.db`), preserving history across `.app` restarts
- Codex CLI config guardrails: snapshots `~/.codex/{config.toml,auth.json}` before apply; restores via per-key smart merge on exit / next start
- Real-time logs panel auto-refreshing every 2s; unified `tracing::warn!(error_id, detail)` with stable tokens — operators can grep / aggregate
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Codex App Transfer 是一个面向 **OpenAI Codex CLI** 的轻量桌面配置 +
- 管理多套供应商,按 OpenAI 模型名(`gpt-5.5` / `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.3-codex` / `gpt-5.2`)映射到供应商真实模型 ID
- 把 Codex CLI 的 Responses API 流式 / 非流式请求转换为上游协议:Chat Completions、Gemini Native(`:streamGenerateContent`)、Gemini CLI OAuth(Cloud Code Assist)、Anthropic Messages(`/v1/messages`)、Grok Web(`/rest/app-chat/conversations/new`)、Responses 透传等
- 多轮工具对话上下文 + `previous_response_id` 历史回放 + autocompact 展开 + thinking / reasoning_content 注入全部对齐 OpenAI Responses API 协议
- Codex CLI 的 freeform `apply_patch` 工具(编辑文件 +/- diff UI)在 DeepSeek / Kimi / MiMo 等 chat-completions provider 上正常工作:adapter 双向桥接 Responses `custom_tool_call` ↔ chat `function_call` 形态,模型按 V4A 格式生成 patch,Codex CLI 渲染为 diff(issue #235)
- 会话历史**两层持久化**:L1 内存 LRU + L2 sqlite 30 天 TTL(`~/.codex-app-transfer/sessions.db`),`.app` 重启不丢历史
- Codex CLI 原配置守护:apply 前自动快照 `~/.codex/{config.toml,auth.json}`,退出 / 下次启动按 key 智能合并还原
- 实时日志面板,2 秒自动刷新;统一 `tracing::warn!(error_id, detail)` + 稳定 token,operator 可 grep / 聚合
Expand Down
1,246 changes: 1,212 additions & 34 deletions crates/adapters/src/responses/converter.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/adapters/src/responses/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod compact;
pub mod converter;
pub mod request;
pub mod session;
pub mod shell_to_apply_patch;
pub mod stream;
pub mod tool_call_cache;

Expand Down
116 changes: 116 additions & 0 deletions crates/adapters/src/responses/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -518,6 +526,70 @@ fn input_item_to_messages(item: &serde_json::Map<String, Value>) -> Vec<Value> {
"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":"<V4A>"}`
// (与首轮在 `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")
Expand Down Expand Up @@ -2207,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' >> <path>` 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: <path>` and `*** Update File: <path>` 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' > <path>`) 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,
})
}
Loading