mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-03 18:23:51 +08:00
修复:兼容已移除工具,避免旧会话 Resume 中断
兼容 v0.1.5-beta.1 删除 AwaitShell 后的旧会话 Resume,并将未知/已移除工具降级为模型可见失败结果,避免整个 Agent Run 被 Protocol Error 直接终止。
This commit is contained in:
@@ -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<pb::ToolCall> {
|
||||
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<pb::ToolCall> {
|
||||
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<pb::AgentServerMessage> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<cursor_server::cursor::tools::DispatchedTool> {
|
||||
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"));
|
||||
}
|
||||
Reference in New Issue
Block a user