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