diff --git a/server/src/cursor/interaction/mod.rs b/server/src/cursor/interaction/mod.rs index 7861eeb..6a11c64 100644 --- a/server/src/cursor/interaction/mod.rs +++ b/server/src/cursor/interaction/mod.rs @@ -4,19 +4,61 @@ mod render; use std::{collections::BTreeMap, time::Duration}; use crate::{ - cursor::proto::agent::v1 as pb, + cursor::{proto::agent::v1 as pb, tools::compat}, model::{ToolCall, Usage}, provider::ModelEvent, - Result, + Error, Result, }; pub use query::tool_query; pub(crate) use render::{create_plan_partial, edit_content_delta, edit_path_partial}; -pub use render::{ - dynamic_mcp_placeholder, render_dynamic_mcp, render_tool_call, tool_completed, - tool_placeholder, tool_started, +pub use render::{dynamic_mcp_placeholder, render_dynamic_mcp, tool_completed}; +use render::{ + render_tool_call as render_builtin_tool_call, tool_placeholder as builtin_tool_placeholder, + tool_started as builtin_tool_started, }; +pub fn tool_placeholder(name: &str, call_id: &str) -> Result { + match builtin_tool_placeholder(name, call_id) { + Ok(tool) => Ok(tool), + Err(error) if is_unsupported_tool(&error, name) => Ok(compat::placeholder(name, call_id)), + Err(error) => Err(error), + } +} + +pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result { + match render_builtin_tool_call(call, completed) { + Ok(tool) => Ok(tool), + Err(error) if is_unsupported_tool(&error, &call.name) => { + Ok(compat::render(call, completed)) + } + Err(error) => Err(error), + } +} + +pub fn tool_started( + call: &ToolCall, + dynamic_mcp: Option<&pb::McpToolDefinition>, +) -> Result { + match builtin_tool_started(call, dynamic_mcp) { + Ok(message) => Ok(message), + Err(error) if dynamic_mcp.is_none() && is_unsupported_tool(&error, &call.name) => { + Ok(server_interaction( + pb::interaction_update::Message::ToolCallStarted(pb::ToolCallStartedUpdate { + call_id: call.call_id.clone(), + tool_call: Some(compat::render(call, false)), + model_call_id: call.model_call_id.clone(), + }), + )) + } + Err(error) => Err(error), + } +} + +fn is_unsupported_tool(error: &Error, name: &str) -> bool { + matches!(error, Error::Protocol(message) if message == &format!("unsupported tool: {name}")) +} + pub fn response_event( event: &ModelEvent, model_call_id: &str, @@ -204,3 +246,43 @@ pub fn server_interaction(message: pb::interaction_update::Message) -> pb::Agent )), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn unknown_tool(name: &str) -> ToolCall { + let arguments = serde_json::json!({"shell_id": "legacy-shell", "value": 1}); + ToolCall { + index: 0, + call_id: "call-1".into(), + model_call_id: "model-call-1".into(), + name: name.into(), + arguments_text: arguments.to_string(), + arguments, + } + } + + #[test] + fn retired_tool_streaming_uses_a_compatibility_card() { + let call = unknown_tool("AwaitShell"); + + assert!(tool_placeholder(&call.name, &call.call_id).is_ok()); + assert!(render_tool_call(&call, false).is_ok()); + assert!(tool_started(&call, None).is_ok()); + assert!(arguments_delta(&call, "{\"shell_id\":").is_ok()); + } + + #[test] + fn arbitrary_unknown_tool_start_does_not_fail_the_agent_stream() { + let event = ModelEvent::ToolCallStart { + index: 0, + call_id: "call-1".into(), + name: "OldTool".into(), + }; + + assert!(response_event(&event, "model-call-1", &BTreeMap::new()) + .unwrap() + .is_some()); + } +} diff --git a/server/src/cursor/tools/compat.rs b/server/src/cursor/tools/compat.rs new file mode 100644 index 0000000..455340a --- /dev/null +++ b/server/src/cursor/tools/compat.rs @@ -0,0 +1,155 @@ +use crate::{ + cursor::proto::agent::v1 as pb, + model::{ToolCall, ToolResult}, +}; + +use super::{codec, result::ToolCompletion, runtime::now_ms}; + +// Unknown/retired tools use a generic Cursor MCP card only as a wire/UI +// representation; they are never dispatched to an MCP server. +const COMPAT_PROVIDER: &str = "cursor-byok-compat"; + +pub(crate) fn placeholder(name: &str, call_id: &str) -> pb::ToolCall { + pb::ToolCall { + hook_additional_contexts: Vec::new(), + tool_call_id: Some(call_id.into()), + started_at_ms: None, + completed_at_ms: None, + tool: Some(pb::tool_call::Tool::McpToolCall(pb::McpToolCall { + args: Some(pb::McpArgs { + name: name.into(), + tool_call_id: call_id.into(), + provider_identifier: COMPAT_PROVIDER.into(), + tool_name: name.into(), + server_identifier: COMPAT_PROVIDER.into(), + ..Default::default() + }), + result: None, + description: Some("Unavailable legacy or unsupported tool".into()), + })), + } +} + +pub(crate) fn render(call: &ToolCall, completed: bool) -> pb::ToolCall { + let mut output = placeholder(&call.name, &call.call_id); + let timestamp = now_ms(); + output.started_at_ms = Some(timestamp); + output.completed_at_ms = completed.then_some(timestamp); + if let Some(pb::tool_call::Tool::McpToolCall(tool)) = output.tool.as_mut() { + if let Some(args) = tool.args.as_mut() { + args.args = call + .arguments + .as_object() + .map(codec::json_object_to_prost) + .unwrap_or_default(); + } + } + output +} + +pub(crate) fn failure(call: &ToolCall) -> ToolCompletion { + let error = failure_message(&call.name); + let arguments = call + .arguments + .as_object() + .map(codec::json_object_to_prost) + .unwrap_or_default(); + ToolCompletion::new( + call, + now_ms(), + ToolResult { + call_id: call.call_id.clone(), + content: error.clone(), + is_error: true, + image: None, + }, + pb::tool_call::Tool::McpToolCall(pb::McpToolCall { + args: Some(pb::McpArgs { + name: call.name.clone(), + args: arguments, + tool_call_id: call.call_id.clone(), + provider_identifier: COMPAT_PROVIDER.into(), + tool_name: call.name.clone(), + server_identifier: COMPAT_PROVIDER.into(), + ..Default::default() + }), + result: Some(pb::McpToolResult { + result: Some(pb::mcp_tool_result::Result::Error(pb::McpToolError { + error, + read_tool_def_reminder: String::new(), + })), + }), + description: Some("Unavailable legacy or unsupported tool".into()), + }), + ) +} + +fn failure_message(name: &str) -> String { + if normalized(name) == "awaitshell" { + return "Tool \"AwaitShell\" is no longer available in this Cursor BYOK version. This call may have been restored from a conversation created by an older version. Treat the tool call as failed and continue using only tools advertised in the current prompt; for background shell work, use the current Shell/background completion flow.".into(); + } + format!( + "Tool \"{name}\" is not available in this Cursor BYOK version. It may come from an older conversation or from an unsupported model-generated tool call. Treat the tool call as failed and continue using a tool advertised in the current prompt." + ) +} + +fn normalized(name: &str) -> String { + name.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool(name: &str) -> ToolCall { + let arguments = serde_json::json!({"shell_id": "legacy-shell", "block_until_ms": 30000}); + ToolCall { + index: 0, + call_id: "call-1".into(), + model_call_id: "model-call-1".into(), + name: name.into(), + arguments_text: arguments.to_string(), + arguments, + } + } + + #[test] + fn retired_await_shell_is_a_model_visible_failure() { + let completion = failure(&tool("AwaitShell")); + assert!(completion.result().is_error); + assert!(completion.result().content.contains("older version")); + assert!(completion + .result() + .content + .contains("current Shell/background completion flow")); + let Some(pb::tool_call::Tool::McpToolCall(rendered)) = completion.tool_call().tool.as_ref() + else { + panic!("expected compatibility MCP card"); + }; + let args = rendered.args.as_ref().unwrap(); + assert_eq!(args.provider_identifier, COMPAT_PROVIDER); + assert_eq!(args.tool_name, "AwaitShell"); + } + + #[test] + fn arbitrary_unknown_tool_is_a_model_visible_failure() { + let completion = failure(&tool("OldTool")); + assert!(completion.result().is_error); + assert!(completion.result().content.contains("not available")); + assert_eq!( + completion + .tool_call() + .tool + .as_ref() + .and_then(|tool| match tool { + pb::tool_call::Tool::McpToolCall(tool) => tool.args.as_ref(), + _ => None, + }) + .map(|args| args.tool_name.as_str()), + Some("OldTool") + ); + } +} diff --git a/server/src/cursor/tools/dispatch/mod.rs b/server/src/cursor/tools/dispatch/mod.rs index 39957ba..cfe8e0b 100644 --- a/server/src/cursor/tools/dispatch/mod.rs +++ b/server/src/cursor/tools/dispatch/mod.rs @@ -15,6 +15,7 @@ use crate::{ }; use super::{ + compat, result::{ToolCompletion, ToolResultSender}, runtime::{CursorToolRuntime, ExecContext, PendingInteraction}, }; @@ -61,7 +62,14 @@ pub(super) async fn start( | "generateimage" => interaction::start(runtime, call).await, "todowrite" | "updatecurrentstep" => local::start(call, message_index), "semblesearch" | "semblefindrelated" => semble::start(results, call, store.cloned()), - _ => Err(Error::Protocol(format!("unsupported tool: {}", call.name))), + _ => Ok(unavailable_tool(call)), + } +} + +fn unavailable_tool(call: &ToolCall) -> ToolStart { + ToolStart { + messages: Vec::new(), + completion: Some(compat::failure(call)), } } @@ -196,4 +204,28 @@ mod tests { "protocol error: Shell block_until_ms is out of range" ); } + + #[test] + fn retired_await_shell_does_not_become_a_protocol_error() { + let call = tool( + "AwaitShell", + serde_json::json!({"shell_id": "legacy-shell", "block_until_ms": 30_000}), + ); + let started = unavailable_tool(&call); + let completion = started.completion.expect("compatibility completion"); + + assert!(started.messages.is_empty()); + assert!(completion.result().is_error); + assert!(completion.result().content.contains("older version")); + } + + #[test] + fn arbitrary_unknown_tool_does_not_become_a_protocol_error() { + let call = tool("OldTool", serde_json::json!({"value": 1})); + let started = unavailable_tool(&call); + let completion = started.completion.expect("compatibility completion"); + + assert!(completion.result().is_error); + assert!(completion.result().content.contains("not available")); + } } diff --git a/server/src/cursor/tools/mod.rs b/server/src/cursor/tools/mod.rs index 251968f..bea7bce 100644 --- a/server/src/cursor/tools/mod.rs +++ b/server/src/cursor/tools/mod.rs @@ -6,6 +6,7 @@ use std::{ use tokio::sync::Mutex; pub mod codec; +pub(crate) mod compat; mod dispatch; pub(crate) mod edit; pub(crate) mod result; diff --git a/server/tests/removed_tool_compat.rs b/server/tests/removed_tool_compat.rs new file mode 100644 index 0000000..0f81cfa --- /dev/null +++ b/server/tests/removed_tool_compat.rs @@ -0,0 +1,76 @@ +use std::collections::{BTreeMap, HashSet}; + +use cursor_server::{ + cursor::tools::{ + runtime::{CursorToolRuntime, ExecContext}, + ToolBatchState, ToolDispatcher, + }, + model::ToolCall, +}; + +fn tool(name: &str) -> ToolCall { + let arguments = serde_json::json!({ + "shell_id": "legacy-shell", + "block_until_ms": 30_000 + }); + ToolCall { + index: 0, + call_id: "call-1".into(), + model_call_id: "model-call-1".into(), + name: name.into(), + arguments_text: arguments.to_string(), + arguments, + } +} + +async fn dispatch( + name: &str, +) -> cursor_server::Result { + let dispatcher = ToolDispatcher::new(CursorToolRuntime::default()); + let completed = HashSet::new(); + let started = HashSet::new(); + let call = tool(name); + let dispatched = dispatcher + .start_batch( + &[call], + ToolBatchState { + completed: &completed, + started: &started, + response_text: "", + response_thinking: "", + }, + &[], + &BTreeMap::new(), + &ExecContext::default(), + ) + .await?; + Ok(dispatched.into_iter().next().expect("one dispatched tool")) +} + +#[tokio::test] +async fn resumed_await_shell_becomes_a_failed_tool_result_instead_of_a_protocol_error() { + let dispatched = dispatch("AwaitShell").await.unwrap(); + + assert_eq!( + dispatched.messages.len(), + 1, + "started card is still published" + ); + let completion = dispatched.completion.expect("compatibility completion"); + assert!(completion.result().is_error); + assert!(completion.result().content.contains("older version")); +} + +#[tokio::test] +async fn hallucinated_unknown_tool_becomes_a_failed_tool_result_instead_of_a_protocol_error() { + let dispatched = dispatch("OldTool").await.unwrap(); + + assert_eq!( + dispatched.messages.len(), + 1, + "started card is still published" + ); + let completion = dispatched.completion.expect("compatibility completion"); + assert!(completion.result().is_error); + assert!(completion.result().content.contains("not available")); +}