修复:兼容已移除工具,避免旧会话 Resume 中断

兼容 v0.1.5-beta.1 删除 AwaitShell 后的旧会话 Resume,并将未知/已移除工具降级为模型可见失败结果,避免整个 Agent Run 被 Protocol Error 直接终止。
This commit is contained in:
jiah0231
2026-08-28 12:12:50 +08:00
committed by GitHub
parent c4031d4074
commit edc28de86b
5 changed files with 352 additions and 6 deletions
+87 -5
View File
@@ -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());
}
}
+155
View File
@@ -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")
);
}
}
+33 -1
View File
@@ -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"));
}
}
+1
View File
@@ -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;
+76
View File
@@ -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"));
}