diff --git a/server/src/cursor/tools/codec/query.rs b/server/src/cursor/tools/codec/query.rs index ea7ef0c..fb8d151 100644 --- a/server/src/cursor/tools/codec/query.rs +++ b/server/src/cursor/tools/codec/query.rs @@ -12,6 +12,18 @@ pub fn tool_query(id: u32, call: &ToolCall) -> Result { .map(str::to_string) .ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name))) }; + // Claude 系模型常按 Claude Code 习惯输出别名参数(如 query),逐个回退兼容。 + let string_aliased = |names: &[&str]| -> Result { + for name in names { + if let Some(value) = call.arguments.get(name).and_then(Value::as_str) { + return Ok(value.to_string()); + } + } + Err(Error::Protocol(format!( + "{} is missing {}", + call.name, names[0] + ))) + }; let optional_string = |name: &str| { call.arguments .get(name) @@ -80,7 +92,7 @@ pub fn tool_query(id: u32, call: &ToolCall) -> Result { } "websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery { args: Some(pb::WebSearchArgs { - search_term: string("search_term")?, + search_term: string_aliased(&["search_term", "query"])?, tool_call_id: call.call_id.clone(), }), }), @@ -214,3 +226,37 @@ fn normalized(value: &str) -> String { .flat_map(char::to_lowercase) .collect() } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::tool_query; + use crate::cursor::protocol::proto::agent::v1 as pb; + use crate::model::ToolCall; + + #[test] + fn web_search_accepts_query_as_search_term_alias() { + // Claude 系模型常按 Claude Code 习惯发送 query 而非 search_term, + // 交互查询编码必须接受别名,而不是报 `WebSearch is missing search_term`。 + let call = ToolCall { + index: 0, + call_id: "call-1".into(), + model_call_id: "model-1".into(), + name: "WebSearch".into(), + arguments_text: String::new(), + arguments: json!({ "query": "lmarena leaderboard" }), + argument_error: None, + }; + let message = tool_query(1, &call).unwrap(); + let Some(pb::agent_server_message::Message::InteractionQuery(query)) = message.message + else { + panic!("expected an InteractionQuery"); + }; + let Some(pb::interaction_query::Query::WebSearchRequestQuery(request)) = query.query + else { + panic!("expected a WebSearchRequestQuery"); + }; + assert_eq!(request.args.unwrap().search_term, "lmarena leaderboard"); + } +} diff --git a/server/src/cursor/tools/codec/render.rs b/server/src/cursor/tools/codec/render.rs index d212576..36bf258 100644 --- a/server/src/cursor/tools/codec/render.rs +++ b/server/src/cursor/tools/codec/render.rs @@ -286,6 +286,20 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result String { + names + .iter() + .find_map(|name| call.arguments.get(name).and_then(Value::as_str)) + .unwrap_or_default() + .to_string() + }; + let optional_aliased = |names: &[&str]| -> Option { + names + .iter() + .find_map(|name| call.arguments.get(name).and_then(Value::as_str)) + .map(str::to_string) + }; match output.tool.as_mut() { Some(pb::tool_call::Tool::ShellToolCall(tool)) => { tool.description = optional("description"); @@ -299,7 +313,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result { tool.args = Some(pb::DeleteArgs { - path: string("path"), + path: aliased(&["path", "file_path", "filePath"]), tool_call_id: call.call_id.clone(), }) } @@ -321,7 +335,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result { tool.args = Some(pb::ReadToolArgs { - path: string("path"), + path: aliased(&["path", "file_path", "filePath"]), offset: call .arguments .get("offset") @@ -350,7 +364,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result { let stream_content = if normalized(&call.name) == "write" { - optional("contents").unwrap_or_default() + optional_aliased(&["contents", "content"]).unwrap_or_default() } else { optional("new_string").unwrap_or_default() }; @@ -358,7 +372,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result Result { tool.args = Some(pb::WebSearchArgs { - search_term: string("search_term"), + search_term: aliased(&["search_term", "query"]), tool_call_id: call.call_id.clone(), }) } diff --git a/server/src/cursor/tools/codec/request.rs b/server/src/cursor/tools/codec/request.rs index a2f5867..26d0686 100644 --- a/server/src/cursor/tools/codec/request.rs +++ b/server/src/cursor/tools/codec/request.rs @@ -22,6 +22,18 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result Result { + for name in names { + if let Some(value) = call.arguments.get(name).and_then(Value::as_str) { + return Ok(value.to_string()); + } + } + Err(Error::Protocol(format!( + "{} is missing {}", + call.name, names[0] + ))) + }; let optional_string = |name: &str| { call.arguments .get(name) @@ -63,7 +75,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result Message::ReadArgs(pb::ReadArgs { - path: string("path")?, + path: string_aliased(&["path", "file_path", "filePath"])?, tool_call_id: call.call_id.clone(), offset: int("offset"), limit: call @@ -74,7 +86,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result Message::DeleteArgs(pb::DeleteArgs { - path: string("path")?, + path: string_aliased(&["path", "file_path", "filePath"])?, tool_call_id: call.call_id.clone(), }), "grep" => Message::GrepArgs(pb::GrepArgs { diff --git a/server/src/cursor/tools/edit.rs b/server/src/cursor/tools/edit.rs index 084af3e..927ac3f 100644 --- a/server/src/cursor/tools/edit.rs +++ b/server/src/cursor/tools/edit.rs @@ -13,12 +13,12 @@ pub(crate) struct EditWrite { } pub(crate) fn path(call: &ToolCall) -> Result { - let field = if normalized(&call.name) == "editnotebook" { - "target_notebook" + if normalized(&call.name) == "editnotebook" { + string(call, "target_notebook") } else { - "path" - }; - string(call, field) + // Claude 系模型常按 Claude Code 习惯输出 file_path/filePath,做别名兼容。 + string_any(call, &["path", "file_path", "filePath"]) + } } pub(crate) fn execution_path(call: &ToolCall) -> Result> { @@ -63,7 +63,10 @@ pub(crate) fn after_read( }; let after = match normalized(&call.name).as_str() { "write" => { - normalize_newlines(&string(call, "contents").map_err(|error| error.to_string())?) + // Claude Code 习惯的 content 作为 contents 的别名兼容。 + normalize_newlines( + &string_any(call, &["contents", "content"]).map_err(|error| error.to_string())?, + ) } "strreplace" => replace_string(call, &before)?, "editnotebook" => edit_notebook(call, &before)?, @@ -230,11 +233,19 @@ fn source_lines(value: &str) -> Vec { } fn string(call: &ToolCall, field: &str) -> Result { - call.arguments - .get(field) - .and_then(Value::as_str) - .map(str::to_owned) - .ok_or_else(|| Error::Protocol(format!("{} is missing {field}", call.name))) + string_any(call, &[field]) +} + +fn string_any(call: &ToolCall, fields: &[&str]) -> Result { + for field in fields { + if let Some(value) = call.arguments.get(field).and_then(Value::as_str) { + return Ok(value.to_owned()); + } + } + Err(Error::Protocol(format!( + "{} is missing {}", + call.name, fields[0] + ))) } fn normalized(value: &str) -> String { diff --git a/server/src/cursor/tools/tool_call_dispatch/interaction.rs b/server/src/cursor/tools/tool_call_dispatch/interaction.rs index 5fc187c..051467b 100644 --- a/server/src/cursor/tools/tool_call_dispatch/interaction.rs +++ b/server/src/cursor/tools/tool_call_dispatch/interaction.rs @@ -88,12 +88,17 @@ fn start_web_search( search: WebSearch, pending: PendingInteraction, ) -> Result<()> { - let query = pending - .call - .arguments - .get("search_term") - .and_then(serde_json::Value::as_str) - .filter(|query| !query.trim().is_empty()) + // Claude Code 习惯的 query 作为 search_term 的别名兼容。 + let query = ["search_term", "query"] + .iter() + .find_map(|name| { + pending + .call + .arguments + .get(name) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + }) .ok_or_else(|| Error::Protocol("WebSearch is missing search_term".into()))? .to_string(); tokio::spawn(async move {