Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
396 changes: 351 additions & 45 deletions src/providers/cursor/client.rs

Large diffs are not rendered by default.

349 changes: 237 additions & 112 deletions src/providers/cursor/mod.rs

Large diffs are not rendered by default.

613 changes: 608 additions & 5 deletions src/providers/cursor/proto.rs

Large diffs are not rendered by default.

48 changes: 47 additions & 1 deletion src/providers/cursor/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ pub struct CursorSelectedImage {
pub mime_type: String,
}

/// How the model calls a tool from `<tools>`. Without it the agent only knows
/// its own built-in tools: it tried MCP tools and ToolSearch on Cursor's
/// channel, and Cursor answered "Tool not available" (measured 13/10). The
/// format is the one `tool_use_xml` parses and the transcript already uses.
const TOOL_PROTOCOL: &str = "These tools run on the user's machine and are available in this \
session, although they are not in your built-in tool list. To read files, write files or \
run shell commands, use your built-in tools. To call any other tool listed here, write the \
call as text, exactly in this form, and stop: \
<tool_use name=\"TOOL_NAME\">{\"param\": \"value\"}</tool_use> \
The result comes back as <tool_result>. Never call these tools through your built-in tool channel.";

/// Render the full Cursor prompt from an Anthropic MessagesRequest.
///
/// Includes:
Expand All @@ -35,6 +46,7 @@ pub fn render_cursor_prompt(req: &MessagesRequest) -> String {
{
let tool_lines: Vec<String> = tools
.iter()
.filter(|t| t.get("name").and_then(|n| n.as_str()) != Some("DeferredToolPlaceholder"))
.map(|t| {
let name = t.get("name").and_then(|n| n.as_str()).unwrap_or("");
let description = t.get("description").and_then(|d| d.as_str()).unwrap_or("");
Expand All @@ -51,7 +63,10 @@ pub fn render_cursor_prompt(req: &MessagesRequest) -> String {
})
.collect();
if !tool_lines.is_empty() {
sections.push(format!("<tools>\n{}\n</tools>", tool_lines.join("\n")));
sections.push(format!(
"<tools>\n{TOOL_PROTOCOL}\n\n{}\n</tools>",
tool_lines.join("\n")
));
}
}

Expand Down Expand Up @@ -228,6 +243,13 @@ fn render_tool_result_block(block: &serde_json::Value) -> Option<String> {
let block_type = block.get("type").and_then(|t| t.as_str())?;
match block_type {
"text" | "image" | "tool_use" | "tool_result" | "thinking" => render_block(block),
// Tool search: the harness answers ToolSearch with a reference and puts
// the tool's schema in the request's `tools`, where the prompt's
// <tools> section already carries it.
"tool_reference" => {
let name = block.get("tool_name").and_then(|n| n.as_str())?;
Some(format!("Tool loaded: {name} (its schema is in <tools>)."))
}
_ => {
let type_str = block_type.to_string();
Some(format!("[unsupported tool result block: {type_str}]"))
Expand Down Expand Up @@ -333,6 +355,7 @@ mod tests {
.unwrap();
let rendered = render_cursor_prompt(&req);
assert!(rendered.contains("<tools>"));
assert!(rendered.contains(r#"<tool_use name="TOOL_NAME">"#));
assert!(rendered.contains("Read"));
}

Expand Down Expand Up @@ -515,4 +538,27 @@ mod tests {
let rendered = render_cursor_prompt(&req);
assert!(rendered.contains("<web_search_tool_result tool_use_id=\"ws1\">"));
}

#[test]
fn tool_search_reference_renders_as_a_loaded_tool_and_the_placeholder_is_hidden() {
let req: MessagesRequest = serde_json::from_value(serde_json::json!({
"model": "cursor:grok-4.7",
"max_tokens": 16,
"messages": [
{"role": "user", "content": "use whoami"},
{"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "ToolSearch", "input": {"query": "select:whoami"}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": [{"type": "tool_reference", "tool_name": "mcp__x__whoami"}]}]}
],
"tools": [
{"name": "DeferredToolPlaceholder", "description": "", "input_schema": {"type": "object"}, "defer_loading": true},
{"name": "mcp__x__whoami", "description": "who", "input_schema": {"type": "object"}, "defer_loading": true}
]
}))
.unwrap();
let rendered = render_cursor_prompt(&req);
assert!(rendered.contains("Tool loaded: mcp__x__whoami"));
assert!(!rendered.contains("unsupported tool result block"));
assert!(!rendered.contains("DeferredToolPlaceholder"));
assert!(rendered.contains("\"name\":\"mcp__x__whoami\""));
}
}
121 changes: 97 additions & 24 deletions src/providers/cursor/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ pub enum CursorStreamEvent {
TextDelta {
text: String,
},
/// A native exec request: Cursor asking the client to run Read, Write or a
/// shell command. Decoded from ExecServerMessage.
Exec(CursorExecRequest),
Usage {
input_tokens: u64,
output_tokens: u64,
Expand All @@ -26,6 +29,30 @@ pub enum CursorStreamEvent {
End,
}

/// A tool the Cursor upstream wants the client to execute.
#[derive(Debug, Clone, PartialEq)]
pub struct CursorExecRequest {
pub exec_id: Option<String>,
pub tool_call_id: Option<String>,
pub kind: CursorExecKind,
}

#[derive(Debug, Clone, PartialEq)]
pub enum CursorExecKind {
Read {
path: String,
},
Write {
path: String,
content: String,
},
Shell {
command: String,
working_directory: Option<String>,
timeout_ms: Option<u64>,
},
}

#[derive(Debug, Clone)]
pub enum CursorDecodeError {
ConnectEnd(ConnectEndError),
Expand Down Expand Up @@ -279,14 +306,42 @@ fn events_from_current_payload(payload: &[u8], events: &mut Vec<CursorStreamEven
}

fn events_from_message(msg: &AgentServerMessage, events: &mut Vec<CursorStreamEvent>) {
// Check for exec_server_message with session info
if let Some(ref exec) = msg.exec_server_message
&& let Some(ref session_id) = exec.notes_session_id
&& !session_id.is_empty()
{
events.push(CursorStreamEvent::Session {
session_id: session_id.clone(),
});
// Native exec request: Cursor asks the client to run the tool itself.
if let Some(ref exec) = msg.exec_server_message {
let read = exec.read_args.as_ref();
let write = exec.write_args.as_ref();
let shell = exec.shell_stream_args.as_ref().or(exec.shell_args.as_ref());

let kind = if let Some(read) = read {
Some(CursorExecKind::Read {
path: read.path.clone(),
})
} else if let Some(write) = write {
Some(CursorExecKind::Write {
path: write.path.clone(),
content: write.file_text.clone(),
})
} else {
shell.map(|shell| CursorExecKind::Shell {
command: shell.command.clone(),
working_directory: shell.working_directory.clone(),
timeout_ms: shell
.timeout
.and_then(|timeout| u64::try_from(timeout).ok()),
})
};

if let Some(kind) = kind {
let tool_call_id = read
.and_then(|r| r.tool_call_id.clone())
.or_else(|| write.and_then(|w| w.tool_call_id.clone()))
.or_else(|| shell.and_then(|s| s.tool_call_id.clone()));
events.push(CursorStreamEvent::Exec(CursorExecRequest {
exec_id: exec.exec_id.clone(),
tool_call_id,
kind,
}));
}
}

if let Some(ref update) = msg.interaction_update {
Expand Down Expand Up @@ -332,9 +387,8 @@ pub fn estimate_request_input_tokens(req: &MessagesRequest) -> u64 {
mod tests {
use super::*;
use crate::providers::cursor::connect::encode_connect_frame;
use crate::providers::cursor::proto::*;

use crate::providers::cursor::test_frames;
use prost::Message;

#[test]
fn decodes_text_and_usage_events() {
Expand Down Expand Up @@ -366,27 +420,46 @@ mod tests {
}
}

// The frame a Cursor Read tool call actually arrives in, built with the
// field numbers measured from the live upstream on 2026-10-12: tag 2 is the
// exec server message, tag 7 inside it is read_args, tag 1 of read_args is
// the path and tag 2 is Cursor's own tool_call_id.
#[test]
fn decodes_session_event() {
let msg = AgentServerMessage {
interaction_update: None,
exec_server_message: Some(ExecServerMessage {
notes_session_id: Some("session-123".to_string()),
}),
};
let mut payload = Vec::new();
msg.encode(&mut payload).unwrap();
let body = encode_connect_frame(&payload, 0).to_vec();
fn decodes_native_read_exec() {
let body = test_frames::native_read_exec_frame("/etc/hostname", "tool_abc", "exec-1");

let events = decode_upstream_response(&body).unwrap();
assert_eq!(events.len(), 1);
if let CursorStreamEvent::Session { session_id } = &events[0] {
assert_eq!(session_id, "session-123");
} else {
panic!("expected Session");
match &events[0] {
CursorStreamEvent::Exec(exec) => {
assert_eq!(exec.exec_id.as_deref(), Some("exec-1"));
assert_eq!(exec.tool_call_id.as_deref(), Some("tool_abc"));
assert_eq!(
exec.kind,
CursorExecKind::Read {
path: "/etc/hostname".to_string()
}
);
}
other => panic!("expected Exec, got {other:?}"),
}
}

/// A model tool call must never reach Claude Code as the model's prose: the
/// shipped mapping read tool_call_started (tag 2) as if it were text_delta.
#[test]
fn tool_call_started_is_not_text() {
let body = test_frames::tool_call_started_frame("call_abc\nfc_123");

let events = decode_upstream_response(&body).unwrap();
assert!(
!events
.iter()
.any(|event| matches!(event, CursorStreamEvent::TextDelta { .. })),
"tool_call_started leaked into the text channel: {events:?}"
);
}

#[test]
fn accumulate_response_produces_anthropic_json() {
let mut body = Vec::new();
Expand Down
Loading