claude_codex/providers/cursor/
request.rs1use crate::anthropic::schema::MessagesRequest;
2
3#[derive(Debug, Clone)]
5pub struct CursorSelectedImage {
6 pub data: String,
7 pub uuid: String,
8 pub path: String,
9 pub mime_type: String,
10}
11
12pub fn render_cursor_prompt(req: &MessagesRequest) -> String {
19 let mut sections: Vec<String> = Vec::new();
20
21 if let Some(system) = render_system(req) {
22 sections.push(format!("<system>\n{system}\n</system>"));
23 }
24
25 for message in &req.messages {
26 let content = render_message_content(message);
27 if let Some(c) = content {
28 sections.push(format!("<{}>\n{}\n</{}>", message.role, c, message.role));
29 }
30 }
31
32 if let Some(tools) = req.extra.get("tools").and_then(|v| v.as_array()) {
34 if !tools.is_empty() {
35 let tool_lines: Vec<String> = tools
36 .iter()
37 .filter_map(|t| {
38 let name = t.get("name").and_then(|n| n.as_str()).unwrap_or("");
39 let description = t.get("description").and_then(|d| d.as_str()).unwrap_or("");
40 let input_schema = t
41 .get("input_schema")
42 .cloned()
43 .unwrap_or(serde_json::Value::Object(Default::default()));
44 Some(format!(
45 "{}",
46 serde_json::json!({
47 "name": name,
48 "description": description,
49 "input_schema": input_schema,
50 })
51 ))
52 })
53 .collect();
54 if !tool_lines.is_empty() {
55 sections.push(format!("<tools>\n{}\n</tools>", tool_lines.join("\n")));
56 }
57 }
58 }
59
60 sections.join("\n\n")
61}
62
63pub fn cursor_selected_images(req: &MessagesRequest) -> Vec<CursorSelectedImage> {
68 let mut images: Vec<CursorSelectedImage> = Vec::new();
69 let mut index: u32 = 0;
70
71 for message in &req.messages {
72 let blocks = message_blocks(message);
73 for block in &blocks {
74 collect_image_blocks(block, &mut index, &mut images);
75 }
76 }
77
78 images
79}
80
81fn render_system(req: &MessagesRequest) -> Option<String> {
86 let system_value = req.extra.get("system")?;
87 let text = match system_value {
88 serde_json::Value::String(s) => s.clone(),
89 serde_json::Value::Array(blocks) => {
90 let parts: Vec<&str> = blocks
91 .iter()
92 .filter_map(|b| {
93 if b.get("type").and_then(|t| t.as_str()) == Some("text") {
94 b.get("text").and_then(|t| t.as_str())
95 } else {
96 None
97 }
98 })
99 .filter(|line| !line.starts_with("x-anthropic-billing-header:"))
100 .collect();
101 if parts.is_empty() {
102 return None;
103 }
104 parts.join("\n\n")
105 }
106 _ => return None,
107 };
108 if text.is_empty() {
109 return None;
110 }
111 Some(text)
112}
113
114fn render_message_content(message: &crate::anthropic::schema::Message) -> Option<String> {
115 let blocks = message_blocks(message);
116 let rendered: Vec<String> = blocks.iter().filter_map(render_block).collect();
117 if rendered.is_empty() {
118 None
119 } else {
120 Some(rendered.join("\n\n"))
121 }
122}
123
124fn render_block(block: &serde_json::Value) -> Option<String> {
125 let block_type = block.get("type").and_then(|t| t.as_str())?;
126 match block_type {
127 "text" => block
128 .get("text")
129 .and_then(|t| t.as_str())
130 .map(|s| s.to_string()),
131 "thinking" => {
132 let text = block.get("thinking").and_then(|t| t.as_str()).unwrap_or("");
133 Some(format!("<thinking>\n{text}\n</thinking>"))
134 }
135 "image" => {
136 let source = block.get("source")?;
137 match source.get("type").and_then(|t| t.as_str()) {
138 Some("url") => {
139 let url = source.get("url").and_then(|u| u.as_str()).unwrap_or("");
140 Some(format!("[image: {url}]"))
141 }
142 _ => {
143 let media_type = source
144 .get("media_type")
145 .and_then(|m| m.as_str())
146 .unwrap_or("unknown");
147 let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
148 Some(format!(
149 "[image: {media_type}, {} base64 chars]",
150 data.len()
151 ))
152 }
153 }
154 }
155 "tool_use" => {
156 let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
157 let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
158 let input = block
159 .get("input")
160 .and_then(|i| serde_json::to_string(i).ok())
161 .unwrap_or_else(|| "{}".to_string());
162 Some(format!(
163 "<tool_use id=\"{id}\" name=\"{name}\">\n{input}\n</tool_use>"
164 ))
165 }
166 "tool_result" => {
167 let tool_use_id = block
168 .get("tool_use_id")
169 .and_then(|t| t.as_str())
170 .unwrap_or("");
171 let is_error = block
172 .get("is_error")
173 .and_then(|e| e.as_bool())
174 .unwrap_or(false);
175 let error_attr = if is_error { " is_error=\"true\"" } else { "" };
176 let content = render_tool_result_content(block);
177 Some(format!(
178 "<tool_result tool_use_id=\"{tool_use_id}\"{error_attr}>\n{content}\n</tool_result>"
179 ))
180 }
181 "server_tool_use" => {
182 let id = block.get("id").and_then(|i| i.as_str()).unwrap_or("");
183 let name = block.get("name").and_then(|n| n.as_str()).unwrap_or("");
184 let input = block
185 .get("input")
186 .and_then(|i| serde_json::to_string(i).ok())
187 .unwrap_or_else(|| "{}".to_string());
188 Some(format!(
189 "<server_tool_use id=\"{id}\" name=\"{name}\">\n{input}\n</server_tool_use>"
190 ))
191 }
192 "web_search_tool_result" => {
193 let tool_use_id = block
194 .get("tool_use_id")
195 .and_then(|t| t.as_str())
196 .unwrap_or("");
197 let content = block
198 .get("content")
199 .and_then(|c| serde_json::to_string(c).ok())
200 .unwrap_or_else(|| "{}".to_string());
201 Some(format!(
202 "<web_search_tool_result tool_use_id=\"{tool_use_id}\">\n{content}\n</web_search_tool_result>"
203 ))
204 }
205 _ => {
206 block
208 .get("text")
209 .and_then(|t| t.as_str())
210 .map(|s| s.to_string())
211 }
212 }
213}
214
215fn render_tool_result_content(block: &serde_json::Value) -> String {
216 let content = match block.get("content") {
217 Some(serde_json::Value::String(s)) => return s.clone(),
218 Some(serde_json::Value::Array(arr)) => arr.clone(),
219 _ => return String::new(),
220 };
221
222 let parts: Vec<String> = content
223 .iter()
224 .filter_map(render_tool_result_block)
225 .collect();
226 parts.join("\n\n")
227}
228
229fn render_tool_result_block(block: &serde_json::Value) -> Option<String> {
230 let block_type = block.get("type").and_then(|t| t.as_str())?;
231 match block_type {
232 "text" | "image" | "tool_use" | "tool_result" | "thinking" => render_block(block),
233 _ => {
234 let type_str = block_type.to_string();
235 Some(format!("[unsupported tool result block: {type_str}]"))
236 }
237 }
238}
239
240fn message_blocks(message: &crate::anthropic::schema::Message) -> Vec<serde_json::Value> {
241 match &message.content {
242 serde_json::Value::String(s) => {
243 vec![serde_json::json!({"type": "text", "text": s})]
244 }
245 serde_json::Value::Array(arr) => arr.clone(),
246 _ => Vec::new(),
247 }
248}
249
250fn collect_image_blocks(
251 block: &serde_json::Value,
252 index: &mut u32,
253 images: &mut Vec<CursorSelectedImage>,
254) {
255 if block.get("type").and_then(|t| t.as_str()) == Some("image") {
256 let source = match block.get("source") {
257 Some(s) => s,
258 None => return,
259 };
260 if source.get("type").and_then(|t| t.as_str()) != Some("base64") {
261 return;
262 }
263 let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
264 let media_type = source
265 .get("media_type")
266 .and_then(|m| m.as_str())
267 .unwrap_or("image/png");
268 let uuid = uuid::Uuid::new_v4().to_string();
269 *index += 1;
270 let extension = image_extension(media_type);
271 images.push(CursorSelectedImage {
272 data: data.to_string(),
273 uuid,
274 path: format!("claude-image-{index}.{extension}"),
275 mime_type: media_type.to_string(),
276 });
277 return;
278 }
279
280 if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
282 let content = match block.get("content") {
283 Some(serde_json::Value::Array(arr)) => arr.clone(),
284 _ => return,
285 };
286 for child in &content {
287 let child_type = child.get("type").and_then(|t| t.as_str());
288 matches!(
289 child_type,
290 Some("text" | "image" | "tool_use" | "tool_result" | "thinking")
291 );
292 collect_image_blocks(child, index, images);
293 }
294 }
295}
296
297fn image_extension(media_type: &str) -> &'static str {
298 match media_type {
299 "image/jpeg" => "jpg",
300 "image/png" => "png",
301 "image/gif" => "gif",
302 "image/webp" => "webp",
303 _ => "img",
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn renders_system_message() {
313 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
314 "model": "cursor:gpt-5.5",
315 "system": "be direct",
316 "messages": [{"role": "user", "content": "hello"}]
317 }))
318 .unwrap();
319 let rendered = render_cursor_prompt(&req);
320 assert!(rendered.contains("<system>"));
321 assert!(rendered.contains("be direct"));
322 assert!(rendered.contains("</system>"));
323 assert!(rendered.contains("<user>"));
324 assert!(rendered.contains("hello"));
325 assert!(rendered.contains("</user>"));
326 }
327
328 #[test]
329 fn renders_tools_section() {
330 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
331 "model": "cursor:gpt-5.5",
332 "messages": [{"role": "user", "content": "hi"}],
333 "tools": [{"name": "Read", "description": "read files", "input_schema": {"type": "object"}}]
334 }))
335 .unwrap();
336 let rendered = render_cursor_prompt(&req);
337 assert!(rendered.contains("<tools>"));
338 assert!(rendered.contains("Read"));
339 }
340
341 #[test]
342 fn filters_billing_headers_from_system() {
343 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
344 "model": "cursor:gpt-5.5",
345 "system": [
346 {"type": "text", "text": "keep this"},
347 {"type": "text", "text": "x-anthropic-billing-header: skip-me"}
348 ],
349 "messages": [{"role": "user", "content": "hello"}]
350 }))
351 .unwrap();
352 let rendered = render_cursor_prompt(&req);
353 assert!(rendered.contains("keep this"));
354 assert!(!rendered.contains("x-anthropic-billing-header"));
355 }
356
357 #[test]
358 fn collects_selected_images() {
359 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
360 "model": "cursor:gpt-5.5",
361 "messages": [{
362 "role": "user",
363 "content": [
364 {"type": "text", "text": "hi"},
365 {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AAAA"}}
366 ]
367 }]
368 }))
369 .unwrap();
370 let images = cursor_selected_images(&req);
371 assert_eq!(images.len(), 1);
372 assert_eq!(images[0].mime_type, "image/png");
373 assert_eq!(images[0].data, "AAAA");
374 }
375
376 #[test]
377 fn skips_url_images_in_selected() {
378 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
379 "model": "cursor:gpt-5.5",
380 "messages": [{
381 "role": "user",
382 "content": [
383 {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}}
384 ]
385 }]
386 }))
387 .unwrap();
388 let images = cursor_selected_images(&req);
389 assert_eq!(images.len(), 0);
390 }
391
392 #[test]
393 fn renders_url_image_placeholder() {
394 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
395 "model": "cursor:gpt-5.5",
396 "messages": [{
397 "role": "user",
398 "content": [
399 {"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}}
400 ]
401 }]
402 }))
403 .unwrap();
404 let rendered = render_cursor_prompt(&req);
405 assert!(rendered.contains("[image: https://example.com/img.png]"));
406 }
407
408 #[test]
409 fn renders_thinking_blocks() {
410 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
411 "model": "cursor:gpt-5.5",
412 "messages": [{"role": "assistant", "content": [
413 {"type": "thinking", "thinking": "let me think..."},
414 {"type": "text", "text": "done"}
415 ]}]
416 }))
417 .unwrap();
418 let rendered = render_cursor_prompt(&req);
419 assert!(rendered.contains("<thinking>"));
420 assert!(rendered.contains("let me think..."));
421 assert!(rendered.contains("done"));
422 }
423
424 #[test]
425 fn renders_tool_use_blocks() {
426 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
427 "model": "cursor:gpt-5.5",
428 "messages": [{"role": "assistant", "content": [
429 {"type": "tool_use", "id": "tu1", "name": "Read", "input": {"path": "/tmp"}}
430 ]}]
431 }))
432 .unwrap();
433 let rendered = render_cursor_prompt(&req);
434 assert!(rendered.contains("<tool_use id=\"tu1\" name=\"Read\">"));
435 }
436
437 #[test]
438 fn renders_tool_result_with_content_blocks() {
439 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
440 "model": "cursor:gpt-5.5",
441 "messages": [{"role": "user", "content": [
442 {"type": "tool_result", "tool_use_id": "tu1", "content": [
443 {"type": "text", "text": "file contents"}
444 ]}
445 ]}]
446 }))
447 .unwrap();
448 let rendered = render_cursor_prompt(&req);
449 assert!(rendered.contains("<tool_result tool_use_id=\"tu1\">"));
450 assert!(rendered.contains("file contents"));
451 }
452
453 #[test]
454 fn handles_unsupported_block_types() {
455 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
456 "model": "cursor:gpt-5.5",
457 "messages": [{"role": "user", "content": [
458 {"type": "unknown_block", "text": "some fallback text"}
459 ]}]
460 }))
461 .unwrap();
462 let rendered = render_cursor_prompt(&req);
463 assert!(rendered.contains("some fallback text"));
465 }
466
467 #[test]
468 fn empty_messages_renders_emptyish() {
469 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
470 "model": "cursor:gpt-5.5",
471 "messages": [{"role": "user", "content": ""}]
472 }))
473 .unwrap();
474 let rendered = render_cursor_prompt(&req);
475 assert!(rendered.is_empty() || !rendered.is_empty());
476 }
477
478 #[test]
479 fn tool_result_with_nested_image() {
480 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
481 "model": "cursor:gpt-5.5",
482 "messages": [{"role": "user", "content": [
483 {"type": "tool_result", "tool_use_id": "tu1", "content": [
484 {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "BBBB"}}
485 ]}
486 ]}]
487 }))
488 .unwrap();
489 let images = cursor_selected_images(&req);
490 assert_eq!(images.len(), 1);
491 assert_eq!(images[0].mime_type, "image/jpeg");
492 assert_eq!(images[0].data, "BBBB");
493 }
494
495 #[test]
496 fn renders_server_tool_use() {
497 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
498 "model": "cursor:gpt-5.5",
499 "messages": [{"role": "assistant", "content": [
500 {"type": "server_tool_use", "id": "st1", "name": "WebSearch", "input": {"query": "rust"}}
501 ]}]
502 }))
503 .unwrap();
504 let rendered = render_cursor_prompt(&req);
505 assert!(rendered.contains("<server_tool_use id=\"st1\" name=\"WebSearch\">"));
506 }
507
508 #[test]
509 fn renders_web_search_tool_result() {
510 let req: MessagesRequest = serde_json::from_value(serde_json::json!({
511 "model": "cursor:gpt-5.5",
512 "messages": [{"role": "user", "content": [
513 {"type": "web_search_tool_result", "tool_use_id": "ws1", "content": {"results": []}}
514 ]}]
515 }))
516 .unwrap();
517 let rendered = render_cursor_prompt(&req);
518 assert!(rendered.contains("<web_search_tool_result tool_use_id=\"ws1\">"));
519 }
520}