Skip to main content

claude_codex/providers/cursor/
request.rs

1use crate::anthropic::schema::MessagesRequest;
2
3/// A selected image extracted from the request content blocks.
4#[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
12/// Render the full Cursor prompt from an Anthropic MessagesRequest.
13///
14/// Includes:
15/// - System message (with billing-header filtering)
16/// - Conversation messages with content blocks
17/// - Tools block
18pub 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    // Tools block
33    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
63/// Extract selected images from the request, mimicking `cursorSelectedImages`.
64///
65/// Only base64 source images are included. URL images are skipped.
66/// Images nested inside tool_result blocks are also collected.
67pub 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
81// ---------------------------------------------------------------------------
82// Internal helpers
83// ---------------------------------------------------------------------------
84
85fn 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            // Unsupported block type - render as text placeholder
207            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    // Recurse into tool_result blocks for nested images
281    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        // Unsupported blocks fall back to text rendering if they have a text field
464        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}