diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index a7b2872a7..185f8f1a9 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -89,6 +89,8 @@ enum CodexCustomToolKind { Raw, ApplyPatch, BuiltIn, + /// Codex 的 tool_search 工具(MCP 延迟加载检索,execution: client)。 + ToolSearch, } impl Default for CodexCustomToolKind { @@ -123,6 +125,11 @@ impl CodexToolContext { self.custom_tools.contains_key(upstream_name) } + fn is_tool_search_proxy(&self, upstream_name: &str) -> bool { + self.custom_tools.get(upstream_name).map(|spec| spec.kind) + == Some(CodexCustomToolKind::ToolSearch) + } + fn original_custom_tool_name(&self, upstream_name: &str) -> String { self.custom_tools .get(upstream_name) @@ -2564,6 +2571,48 @@ fn append_responses_item( } })); } + Some("tool_search_call") => { + // Codex 的 tool_search 是客户端执行的代理工具,历史回放时按 + // function tool_call 形态映射进 chat 消息流。 + let call_id = item + .get("call_id") + .or_else(|| item.get("id")) + .and_then(Value::as_str) + .unwrap_or(""); + if call_id.is_empty() { + return; + } + seen_tool_call_ids.insert(call_id.to_string()); + pending_tool_calls.push(json!({ + "id": call_id, + "type": "function", + "function": { + "name": "tool_search", + "arguments": responses_arguments_to_chat( + item.get("arguments").unwrap_or(&json!({})) + ) + } + })); + } + Some("tool_search_output") => { + let call_id = item.get("call_id").and_then(Value::as_str).unwrap_or(""); + if call_id.is_empty() { + return; + } + let output = item.get("tools").unwrap_or(&Value::Null); + if !seen_tool_call_ids.contains(call_id) { + flush_tool_calls(messages, pending_tool_calls, pending_reasoning); + flush_reasoning(messages, pending_reasoning); + messages.push(orphan_tool_output_message(call_id, output)); + return; + } + flush_tool_calls(messages, pending_tool_calls, pending_reasoning); + messages.push(json!({ + "role": "tool", + "tool_call_id": call_id, + "content": tool_output_content(output) + })); + } Some("custom_tool_call_output") => { let call_id = item.get("call_id").and_then(Value::as_str).unwrap_or(""); if call_id.is_empty() { @@ -3226,6 +3275,25 @@ fn build_codex_tool_context(tools: Option<&Value>) -> CodexToolContext { } } "namespace" => add_namespace_tools_to_context(&mut context, tool), + // Codex 的 tool_search(MCP 延迟加载检索)是客户端执行的代理工具: + // 转发层把它当作 custom 代理工具登记,模型调用回来时还原成 + // tool_search_call item,检索本身仍由 Codex 客户端执行。 + "tool_search" => { + let name = tool + .get("name") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .unwrap_or("tool_search"); + context.custom_tools.insert( + name.to_string(), + CodexCustomToolSpec { + openai_name: name.to_string(), + kind: CodexCustomToolKind::ToolSearch, + proxy_action: None, + }, + ); + context.has_custom_tools = true; + } "web_search" | "local_shell" | "computer_use" => { let name = tool .get("name") @@ -3325,6 +3393,31 @@ fn responses_tools_to_chat_tools(tools: &[Value], context: &CodexToolContext) -> } } "namespace" => converted.extend(namespace_tool_to_chat_tools(tool, context)), + // tool_search 透传为 chat 的 function 工具,名字保持 tool_search, + // 检索由 Codex 客户端执行(execution: client),转发层只做搬运。 + "tool_search" => { + let name = tool + .get("name") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .unwrap_or("tool_search"); + let description = tool + .get("description") + .and_then(Value::as_str) + .unwrap_or(""); + let parameters = tool + .get("parameters") + .cloned() + .unwrap_or_else(|| json!({})); + converted.push(json!({ + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": parameters + } + })); + } _ => {} } } @@ -3998,6 +4091,20 @@ fn tool_call_added_item( tool_context: &CodexToolContext, ) -> Value { if tool_context.is_custom_tool_proxy(&state.name) { + if tool_context.is_tool_search_proxy(&state.name) { + return json!({ + "type": "response.output_item.added", + "output_index": output_index, + "item": { + "id": tool_call_item_id(&state.call_id, &state.name, tool_context), + "type": "tool_search_call", + "status": "in_progress", + "call_id": state.call_id, + "execution": "client", + "arguments": {} + } + }); + } return json!({ "type": "response.output_item.added", "output_index": output_index, @@ -4037,7 +4144,19 @@ fn push_tool_call_delta_sse( delta: &str, tool_context: &CodexToolContext, ) { - if tool_context.is_custom_tool_proxy(&state.name) { + if tool_context.is_tool_search_proxy(&state.name) { + // tool_search 走 function 参数流,客户端按 tool_search_call.arguments 聚合。 + push_sse( + output, + "response.function_call_arguments.delta", + json!({ + "type": "response.function_call_arguments.delta", + "item_id": state.item_id, + "output_index": output_index, + "delta": delta + }), + ); + } else if tool_context.is_custom_tool_proxy(&state.name) { let _ = delta; } else { push_sse( @@ -4059,6 +4178,19 @@ fn push_tool_call_done_sse( output_index: u32, tool_context: &CodexToolContext, ) { + if tool_context.is_tool_search_proxy(&state.name) { + push_sse( + output, + "response.function_call_arguments.done", + json!({ + "type": "response.function_call_arguments.done", + "item_id": state.item_id, + "output_index": output_index, + "arguments": state.arguments + }), + ); + return; + } if tool_context.is_custom_tool_proxy(&state.name) { push_sse( output, @@ -4100,6 +4232,19 @@ fn response_tool_call_item( tool_context: &CodexToolContext, ) -> Value { if tool_context.is_custom_tool_proxy(name) { + if tool_context.is_tool_search_proxy(name) { + // 官方客户端的 tool_search handler 只接受 tool_search_call item, + // function_call 形态会被拒绝,因此必须还原成专属 item 类型; + // arguments 转发层原样搬运(对象字符串双向保真)。 + return json!({ + "id": format!("tsc_{call_id}"), + "type": "tool_search_call", + "status": "completed", + "call_id": call_id, + "execution": "client", + "arguments": responses_arguments_to_chat_parse(arguments) + }); + } return json!({ "id": tool_call_item_id(call_id, name, tool_context), "type": "custom_tool_call", @@ -4126,6 +4271,10 @@ fn response_tool_call_item( fn tool_call_item_id(call_id: &str, name: &str, tool_context: &CodexToolContext) -> String { let prefix = if tool_context.is_custom_tool_proxy(name) { + if tool_context.is_tool_search_proxy(name) { + // 官方客户端给 tool_search_call 分配的 item id 前缀(见 codex id_prefix)。 + return format!("tsc_{call_id}"); + } "ctc_" } else { "fc_" @@ -4906,6 +5055,16 @@ fn responses_arguments_to_chat(value: &Value) -> String { } } +/// 把 chat 侧的 arguments 字符串解析回 JSON Value,供需要对象形态参数的 +/// item(如 tool_search_call)使用;解析失败时退回空对象,避免构造非法 item。 +fn responses_arguments_to_chat_parse(arguments: &str) -> Value { + let trimmed = arguments.trim(); + if trimmed.is_empty() { + return json!({}); + } + serde_json::from_str(trimmed).unwrap_or_else(|_| json!({})) +} + fn normalize_chat_tool_arguments_string(text: &str) -> String { let trimmed = text.trim(); if trimmed.is_empty() { diff --git a/crates/codex-plus-core/src/relay_config.rs b/crates/codex-plus-core/src/relay_config.rs index 004094281..a863e0160 100644 --- a/crates/codex-plus-core/src/relay_config.rs +++ b/crates/codex-plus-core/src/relay_config.rs @@ -1713,8 +1713,13 @@ fn normalize_config_text_for_write(config_text: &str) -> String { config_text.trim_start_matches('\u{feff}').to_string() } -fn preserve_live_app_settings(home: &Path, config_text: &str) -> anyhow::Result { - let normalized = normalize_config_text_for_write(config_text); +/// 供集成测试直接验证 live 设置保留逻辑。 +#[doc(hidden)] +pub fn preserve_live_app_settings_for_test(home: &Path, config_text: &str) -> anyhow::Result { + preserve_live_app_settings(home, config_text) +} + +fn preserve_live_app_settings(home: &Path, config_text: &str) -> anyhow::Result { let normalized = normalize_config_text_for_write(config_text); let mut target_doc = parse_toml_document(&normalized)?; remove_unsupported_approval_policies(&mut target_doc); let live_text = read_optional_text(&home.join("config.toml"))?; @@ -1730,16 +1735,15 @@ fn preserve_live_app_settings(home: &Path, config_text: &str) -> anyhow::Result< } } // Windows 沙盒实现属于本机设置,切换模板时保留,避免重启后重新要求设置。 - for key in [ - "sandbox_mode", - "approval_policy", - "sandbox_workspace_write", - "windows", - ] { + for key in ["sandbox_mode", "approval_policy", "sandbox_workspace_write", "windows"] { if let Some(live_value) = live_doc.get(key).cloned() { merge_toml_item(&mut target_doc[key], &live_value); } } + // MCP server 条目由用户/Codex 桌面端直接管理:模板与通用配置里已有的 + // 条目优先,live 里多出来的条目原样补回,避免每次重写后 server 逐个 + // 消失(#2263)。整体合并会覆盖通用配置的新值,所以只补缺。 + preserve_missing_table_keys(&mut target_doc, &live_doc, "mcp_servers"); // Preserve user-managed feature flags such as multi_agent_v2 and memories. preserve_missing_table_keys(&mut target_doc, &live_doc, "features"); remove_unsupported_approval_policies(&mut target_doc); diff --git a/crates/codex-plus-core/tests/protocol_proxy.rs b/crates/codex-plus-core/tests/protocol_proxy.rs index 92cfa47ec..d0110f146 100644 --- a/crates/codex-plus-core/tests/protocol_proxy.rs +++ b/crates/codex-plus-core/tests/protocol_proxy.rs @@ -3772,3 +3772,400 @@ fn converted_message_item_id_uses_msg_prefix() { ); assert!(!id.ends_with("_msg"), "不能是 resp_*_msg 形态,实际 {id}"); } + +/// #2263:Codex 发出的 `type: "tool_search"` 工具必须透传为 chat 的 function 工具, +/// 不能落入 `_ => {}` 被静默丢弃,否则模型永远检索不到 mcp__* 工具。 +#[test] +fn responses_request_passes_tool_search_through_to_chat_tools() { + let converted = responses_to_chat_completions(json!({ + "model": "gpt-5-mini", + "input": "hi", + "tools": [ + { + "type": "tool_search", + "execution": "client", + "description": "Search exposed tools", + "parameters": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "number" } + }, + "required": ["query"] + } + }, + { "type": "function", "name": "exec_command", "parameters": { "type": "object" } } + ] + })) + .unwrap(); + + let tools = converted["tools"].as_array().unwrap(); + let tool_search = tools + .iter() + .find(|tool| tool["function"]["name"] == "tool_search") + .expect("tool_search 必须出现在转换后的 tools 里"); + assert_eq!(tool_search["type"], "function"); + assert_eq!(tool_search["function"]["name"], "tool_search"); + assert_eq!(tool_search["function"]["description"], "Search exposed tools"); + assert_eq!( + tool_search["function"]["parameters"]["properties"]["query"]["type"], + "string" + ); + assert_eq!( + tool_search["function"]["parameters"]["required"][0], + "query" + ); + // 其它工具不受影响 + assert!(tools.iter().any(|tool| tool["function"]["name"] == "exec_command")); +} + +/// #2263:模型调用回 tool_search 时必须还原成 `tool_search_call` item, +/// 官方客户端的 handler 只接受这个类型(function_call 形态会被拒绝)。 +#[test] +fn chat_response_restores_tool_search_call_item() { + let converted = chat_completion_to_response_with_request( + json!({ + "id": "chatcmpl_search", + "model": "gpt-5-mini", + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_search_1", + "type": "function", + "function": { + "name": "tool_search", + "arguments": "{\"query\":\"calendar create\",\"limit\":1}" + } + }] + }, + "finish_reason": "tool_calls" + }] + }), + &json!({ + "model": "gpt-5-mini", + "tools": [{ + "type": "tool_search", + "execution": "client", + "description": "Search exposed tools", + "parameters": { "type": "object" } + }] + }), + ) + .unwrap(); + + let item = converted["output"] + .as_array() + .unwrap() + .iter() + .find(|item| item["type"] == "tool_search_call") + .expect("必须还原成 tool_search_call item"); + assert_eq!(item["call_id"], "call_search_1"); + assert_eq!(item["execution"], "client"); + assert_eq!(item["arguments"]["query"], "calendar create"); + assert_eq!(item["arguments"]["limit"], 1); + assert!( + item["id"].as_str().unwrap().starts_with("tsc_"), + "tool_search_call 的 item id 必须是 tsc_ 前缀,实际 {:?}", + item["id"] + ); +} + +/// #2263:流式路径同样要还原成 tool_search_call,且 id 前缀为 tsc_。 +#[test] +fn chat_sse_restores_tool_search_call_item() { + let converted = chat_sse_to_responses_sse_with_request( + r#"data: {"id":"chatcmpl_ts","model":"gpt-5-mini","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_ts","type":"function","function":{"name":"tool_search"}}]}}]} + +data: {"id":"chatcmpl_ts","model":"gpt-5-mini","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\":"}}]}}]} + +data: {"id":"chatcmpl_ts","model":"gpt-5-mini","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"calendar\"}"}}]},"finish_reason":"tool_calls"}]} + +data: [DONE] + +"#, + &json!({ + "model": "gpt-5-mini", + "tools": [{ + "type": "tool_search", + "execution": "client", + "description": "Search exposed tools", + "parameters": { "type": "object" } + }] + }), + ); + + assert!(converted.contains("\"type\":\"tool_search_call\"")); + assert!(converted.contains("\"id\":\"tsc_call_ts\"")); + assert!(converted.contains("\"item_id\":\"tsc_call_ts\"")); + assert!(converted.contains("response.function_call_arguments.delta")); + assert!(converted.contains("response.function_call_arguments.done")); + assert!(converted.contains("\"execution\":\"client\"")); + // 不应把 tool_search 当成 custom/apply_patch 代理 + assert!(!converted.contains("custom_tool_call_input.delta")); +} + +/// #2263:历史回放时 tool_search_call / tool_search_output 要映射成 +/// chat 的 assistant tool_call + role:tool 消息,保持调用配对完整。 +#[test] +fn responses_input_maps_tool_search_history_items() { + let converted = responses_to_chat_completions(json!({ + "model": "gpt-5-mini", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "find calendar tools" }] }, + { + "type": "tool_search_call", + "call_id": "search-1", + "execution": "client", + "arguments": { "query": "calendar create", "limit": 1 } + }, + { + "type": "tool_search_output", + "call_id": "search-1", + "status": "completed", + "execution": "client", + "tools": [ + { "name": "mcp__calendar__create_event", "description": "Create event" } + ] + } + ] + })) + .unwrap(); + + let messages = converted["messages"].as_array().unwrap(); + let tool_call_msg = messages + .iter() + .find(|m| m.get("tool_calls").is_some()) + .expect("必须有 assistant 的 tool_call 消息"); + let tool_call = &tool_call_msg["tool_calls"][0]; + assert_eq!(tool_call["function"]["name"], "tool_search"); + let args: Value = serde_json::from_str(tool_call["function"]["arguments"].as_str().unwrap()) + .expect("arguments 必须是合法 JSON 字符串"); + assert_eq!(args["query"], "calendar create"); + + let tool_msg = messages + .iter() + .find(|m| m["role"] == "tool") + .expect("必须有 role:tool 的检索结果消息"); + assert_eq!(tool_msg["tool_call_id"], "search-1"); + let content = tool_msg["content"].as_str().unwrap(); + assert!(content.contains("mcp__calendar__create_event")); +} + +/// #2263 附带问题:重写 config.toml 时必须保留 live 配置里的 mcp_servers 段。 +#[test] +fn preserve_live_app_settings_keeps_mcp_servers() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + std::fs::write( + home.join("config.toml"), + r#"[mcp_servers.context7] +url = "https://mcp.context7.com/mcp" + +[mcp_signals.marker] +key = "value" +"#, + ) + .unwrap(); + + let preserved = + codex_plus_core::relay_config::preserve_live_app_settings_for_test(home, "[profile]\nname = \"x\"\n") + .unwrap(); + + assert!( + preserved.contains("[mcp_servers.context7]"), + "mcp_servers 段必须保留,实际:{preserved}" + ); + assert!(preserved.contains("https://mcp.context7.com/mcp")); +} + +/// #2263:tool_search_output 先于对应 call 出现(压缩/截断后的回放常见) +/// 时走孤儿输出路径,不能 panic 也不能丢内容。 +#[test] +fn responses_input_handles_orphan_tool_search_output() { + let converted = responses_to_chat_completions(json!({ + "model": "gpt-5-mini", + "input": [ + { "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }, + { + "type": "tool_search_output", + "call_id": "search-orphan", + "status": "completed", + "execution": "client", + "tools": [ + { "name": "mcp__calendar__list", "description": "List events" } + ] + } + ] + })) + .unwrap(); + + let messages = converted["messages"].as_array().unwrap(); + let orphan = messages + .iter() + .filter(|m| m["role"] == "user") + .find(|m| { + m["content"] + .as_str() + .is_some_and(|text| text.contains("search-orphan")) + }) + .expect("孤儿输出必须降级为 user 消息"); + let content = orphan["content"].as_str().unwrap(); + assert!( + content.contains("search-orphan"), + "孤儿输出消息必须携带 call_id,实际:{content}" + ); + assert!(content.contains("mcp__calendar__list")); +} + +/// #2263:模型吐出畸形 arguments JSON 时,tool_search_call 还原必须兜底成 +/// 空对象而不是产出非法 item 或 panic。 +#[test] +fn chat_response_tool_search_call_tolerates_malformed_arguments() { + let converted = chat_completion_to_response_with_request( + json!({ + "id": "chatcmpl_ts_bad", + "model": "gpt-5-mini", + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_ts_bad", + "type": "function", + "function": { + "name": "tool_search", + "arguments": "{\"query\": truncated" + } + }] + }, + "finish_reason": "tool_calls" + }] + }), + &json!({ + "model": "gpt-5-mini", + "tools": [{ + "type": "tool_search", + "execution": "client", + "parameters": { "type": "object" } + }] + }), + ) + .unwrap(); + + let item = converted["output"] + .as_array() + .unwrap() + .iter() + .find(|item| item["type"] == "tool_search_call") + .expect("畸形 arguments 也必须还原成 tool_search_call"); + // arguments 走 chat 侧参数归一:解析失败包装成 {"input": 原文} 保真传递, + // 客户端反序列化失败会按检索空结果处理,不会产出非法 item。 + let arguments = item["arguments"].as_object().expect("arguments 必须是对象"); + assert!( + arguments.contains_key("input"), + "解析失败的 arguments 必须走 {{\"input\": ...}} 包装兜底,实际 {:?}", + item["arguments"] + ); + assert_eq!(item["call_id"], "call_ts_bad"); + assert_eq!(item["execution"], "client"); +} + +/// #2263:tool_search_call 缺 call_id 时回退用 id 字段;两者皆空则整个 +/// item 被丢弃(与 function_call 分支的防御行为一致)。 +#[test] +fn responses_input_tool_search_call_falls_back_to_id_and_drops_empty() { + let converted = responses_to_chat_completions(json!({ + "model": "gpt-5-mini", + "input": [ + { + "type": "tool_search_call", + "id": "tsc_fallback", + "execution": "client", + "arguments": { "query": "calendar" } + }, + { + "type": "tool_search_output", + "call_id": "tsc_fallback", + "status": "completed", + "execution": "client", + "tools": [] + }, + { + "type": "tool_search_call", + "execution": "client", + "arguments": { "query": "no id at all" } + } + ] + })) + .unwrap(); + + let messages = converted["messages"].as_array().unwrap(); + let tool_calls: Vec = messages + .iter() + .filter_map(|m| m.get("tool_calls").and_then(Value::as_array)) + .flatten() + .cloned() + .collect(); + assert_eq!( + tool_calls.len(), + 1, + "只允许一条有效 tool_search 调用,实际 {tool_calls:?}" + ); + assert_eq!(tool_calls[0]["id"], "tsc_fallback"); + assert_eq!(tool_calls[0]["function"]["name"], "tool_search"); +} + +/// #2263:上游模型在未声明 tool_search 工具时越权调用它,退化为普通 +/// function_call item 转发给客户端——这是选定的默认行为,测试钉住防漂移。 +#[test] +fn chat_response_keeps_undeclared_tool_search_as_plain_function_call() { + let converted = chat_completion_to_response(json!({ + "id": "chatcmpl_undeclared", + "model": "gpt-5-mini", + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_undeclared", + "type": "function", + "function": { + "name": "tool_search", + "arguments": "{\"query\":\"x\"}" + } + }] + }, + "finish_reason": "tool_calls" + }] + })) + .unwrap(); + + let item = converted["output"] + .as_array() + .unwrap() + .iter() + .find(|item| item["type"] == "function_call") + .expect("未声明的 tool_search 调用按普通 function_call 转发"); + assert_eq!(item["name"], "tool_search"); + assert_eq!(item["call_id"], "call_undeclared"); +} + +/// #2263 附带问题负例:live 配置里本来就没有 mcp_servers 时,重写 +/// 不能凭空注入空段。 +#[test] +fn preserve_live_app_settings_does_not_invent_mcp_servers() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + std::fs::write(home.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap(); + + let preserved = + codex_plus_core::relay_config::preserve_live_app_settings_for_test(home, "[profile]\nname = \"x\"\n") + .unwrap(); + + assert!( + !preserved.contains("mcp_servers"), + "live 无 mcp_servers 时不得注入该段,实际:{preserved}" + ); +}