Skip to main content

aster_cli/session/
export.rs

1use aster::conversation::message::{
2    ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse,
3};
4use aster::utils::safe_truncate;
5use rmcp::model::{RawContent, ResourceContents, Role};
6use serde_json::Value;
7
8const MAX_STRING_LENGTH_MD_EXPORT: usize = 4096; // Generous limit for export
9const REDACTED_PREFIX_LENGTH: usize = 100; // Show first 100 chars before trimming
10
11fn value_to_simple_markdown_string(value: &Value, export_full_strings: bool) -> String {
12    match value {
13        Value::String(s) => {
14            if !export_full_strings && s.chars().count() > MAX_STRING_LENGTH_MD_EXPORT {
15                let prefix = safe_truncate(s, REDACTED_PREFIX_LENGTH);
16                let trimmed_chars = s.chars().count() - prefix.chars().count();
17                format!("`{}[ ... trimmed : {} chars ... ]`", prefix, trimmed_chars)
18            } else {
19                // Escape backticks and newlines for inline code.
20                let escaped = s.replace('`', "\\`").replace("\n", "\\\\n");
21                format!("`{}`", escaped)
22            }
23        }
24        Value::Number(n) => n.to_string(),
25        Value::Bool(b) => format!("*{}*", b),
26        Value::Null => "_null_".to_string(),
27        _ => "`[Complex Value]`".to_string(),
28    }
29}
30
31fn value_to_markdown(value: &Value, depth: usize, export_full_strings: bool) -> String {
32    let mut md_string = String::new();
33    let base_indent_str = "  ".repeat(depth); // Basic indentation for nesting
34
35    match value {
36        Value::Object(map) => {
37            if map.is_empty() {
38                md_string.push_str(&format!("{}*empty object*\n", base_indent_str));
39            } else {
40                for (key, val) in map {
41                    md_string.push_str(&format!("{}*   **{}**: ", base_indent_str, key));
42                    match val {
43                        Value::String(s) => {
44                            if s.contains('\n') || s.chars().count() > 80 {
45                                // Heuristic for block
46                                md_string.push_str(&format!(
47                                    "\n{}    ```\n{}{}\n{}    ```\n",
48                                    base_indent_str,
49                                    base_indent_str,
50                                    s.trim(),
51                                    base_indent_str
52                                ));
53                            } else {
54                                md_string.push_str(&format!("`{}`\n", s.replace('`', "\\`")));
55                            }
56                        }
57                        _ => {
58                            // Use recursive call for all values including complex objects/arrays
59                            md_string.push('\n');
60                            md_string.push_str(&value_to_markdown(
61                                val,
62                                depth + 2,
63                                export_full_strings,
64                            ));
65                        }
66                    }
67                }
68            }
69        }
70        Value::Array(arr) => {
71            if arr.is_empty() {
72                md_string.push_str(&format!("{}*   *empty list*\n", base_indent_str));
73            } else {
74                for item in arr {
75                    md_string.push_str(&format!("{}*   - ", base_indent_str));
76                    match item {
77                        Value::String(s) => {
78                            if s.contains('\n') || s.chars().count() > 80 {
79                                // Heuristic for block
80                                md_string.push_str(&format!(
81                                    "\n{}      ```\n{}{}\n{}      ```\n",
82                                    base_indent_str,
83                                    base_indent_str,
84                                    s.trim(),
85                                    base_indent_str
86                                ));
87                            } else {
88                                md_string.push_str(&format!("`{}`\n", s.replace('`', "\\`")));
89                            }
90                        }
91                        _ => {
92                            // Use recursive call for all values including complex objects/arrays
93                            md_string.push('\n');
94                            md_string.push_str(&value_to_markdown(
95                                item,
96                                depth + 2,
97                                export_full_strings,
98                            ));
99                        }
100                    }
101                }
102            }
103        }
104        _ => {
105            md_string.push_str(&format!(
106                "{}{}\n",
107                base_indent_str,
108                value_to_simple_markdown_string(value, export_full_strings)
109            ));
110        }
111    }
112    md_string
113}
114
115pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> String {
116    let mut md = String::new();
117    match &req.tool_call {
118        Ok(call) => {
119            let parts: Vec<_> = call.name.rsplitn(2, "__").collect();
120            let (namespace, tool_name_only) = if parts.len() == 2 {
121                (parts[1], parts[0])
122            } else {
123                ("Tool", parts[0])
124            };
125
126            md.push_str(&format!(
127                "#### Tool Call: `{}` (namespace: `{}`)\n",
128                tool_name_only, namespace
129            ));
130            md.push_str("**Arguments:**\n");
131
132            match call.name.as_ref() {
133                "developer__shell" => {
134                    if let Some(Value::String(command)) =
135                        call.arguments.as_ref().and_then(|args| args.get("command"))
136                    {
137                        md.push_str(&format!(
138                            "*   **command**:\n    ```sh\n    {}\n    ```\n",
139                            command.trim()
140                        ));
141                    }
142                    let other_args: serde_json::Map<String, Value> = call
143                        .arguments
144                        .as_ref()
145                        .map(|obj| {
146                            obj.iter()
147                                .filter(|(k, _)| k.as_str() != "command")
148                                .map(|(k, v)| (k.clone(), v.clone()))
149                                .collect()
150                        })
151                        .unwrap_or_default();
152                    if !other_args.is_empty() {
153                        md.push_str(&value_to_markdown(
154                            &Value::Object(other_args),
155                            0,
156                            export_all_content,
157                        ));
158                    }
159                }
160                "developer__text_editor" => {
161                    if let Some(Value::String(path)) =
162                        call.arguments.as_ref().and_then(|args| args.get("path"))
163                    {
164                        md.push_str(&format!("*   **path**: `{}`\n", path));
165                    }
166                    if let Some(Value::String(code_edit)) = call
167                        .arguments
168                        .as_ref()
169                        .and_then(|args| args.get("code_edit"))
170                    {
171                        md.push_str(&format!(
172                            "*   **code_edit**:\n    ```\n{}\n    ```\n",
173                            code_edit
174                        ));
175                    }
176
177                    let other_args: serde_json::Map<String, Value> = call
178                        .arguments
179                        .as_ref()
180                        .map(|obj| {
181                            obj.iter()
182                                .filter(|(k, _)| k.as_str() != "path" && k.as_str() != "code_edit")
183                                .map(|(k, v)| (k.clone(), v.clone()))
184                                .collect()
185                        })
186                        .unwrap_or_default();
187                    if !other_args.is_empty() {
188                        md.push_str(&value_to_markdown(
189                            &Value::Object(other_args),
190                            0,
191                            export_all_content,
192                        ));
193                    }
194                }
195                _ => {
196                    if let Some(args) = &call.arguments {
197                        md.push_str(&value_to_markdown(
198                            &Value::Object(args.clone()),
199                            0,
200                            export_all_content,
201                        ));
202                    } else {
203                        md.push_str("*No arguments*\n");
204                    }
205                }
206            }
207        }
208        Err(e) => {
209            md.push_str(&format!(
210                "**Error in Tool Call:**\n```\n{}
211```\n",
212                e
213            ));
214        }
215    }
216    md
217}
218
219pub fn tool_response_to_markdown(resp: &ToolResponse, export_all_content: bool) -> String {
220    let mut md = String::new();
221    md.push_str("#### Tool Response:\n");
222
223    match &resp.tool_result {
224        Ok(result) => {
225            if result.content.is_empty() {
226                md.push_str("*No textual output from tool.*\n");
227            }
228
229            for content in &result.content {
230                if !export_all_content {
231                    if let Some(audience) = content.audience() {
232                        if !audience.contains(&Role::Assistant) {
233                            continue;
234                        }
235                    }
236                }
237
238                match &content.raw {
239                    RawContent::Text(text_content) => {
240                        let trimmed_text = text_content.text.trim();
241                        if (trimmed_text.starts_with('{') && trimmed_text.ends_with('}'))
242                            || (trimmed_text.starts_with('[') && trimmed_text.ends_with(']'))
243                        {
244                            md.push_str(&format!("```json\n{}\n```\n", trimmed_text));
245                        } else if trimmed_text.starts_with('<')
246                            && trimmed_text.ends_with('>')
247                            && trimmed_text.contains("</")
248                        {
249                            md.push_str(&format!("```xml\n{}\n```\n", trimmed_text));
250                        } else {
251                            md.push_str(&text_content.text);
252                            md.push_str("\n\n");
253                        }
254                    }
255                    RawContent::Image(image_content) => {
256                        if image_content.mime_type.starts_with("image/") {
257                            // For actual images, provide a placeholder that indicates it's an image
258                            md.push_str(&format!(
259                                "**Image:** `(type: {}, data: first 30 chars of base64...)`\n\n",
260                                image_content.mime_type
261                            ));
262                        } else {
263                            // For non-image mime types, just indicate it's binary data
264                            md.push_str(&format!(
265                                "**Binary Content:** `(type: {}, length: {} bytes)`\n\n",
266                                image_content.mime_type,
267                                image_content.data.len()
268                            ));
269                        }
270                    }
271                    RawContent::Resource(resource) => {
272                        match &resource.resource {
273                            ResourceContents::TextResourceContents {
274                                uri,
275                                mime_type,
276                                text,
277                                meta: _,
278                            } => {
279                                // Extract file extension from the URI for syntax highlighting
280                                let file_extension = uri.split('.').next_back().unwrap_or("");
281                                let syntax_type = match file_extension {
282                                    "rs" => "rust",
283                                    "js" => "javascript",
284                                    "ts" => "typescript",
285                                    "py" => "python",
286                                    "json" => "json",
287                                    "yaml" | "yml" => "yaml",
288                                    "md" => "markdown",
289                                    "html" => "html",
290                                    "css" => "css",
291                                    "sh" => "bash",
292                                    _ => mime_type
293                                        .as_ref()
294                                        .map(|mime| if mime == "text" { "" } else { mime })
295                                        .unwrap_or(""),
296                                };
297
298                                md.push_str(&format!("**File:** `{}`\n", uri));
299                                md.push_str(&format!(
300                                    "```{}\n{}\n```\n\n",
301                                    syntax_type,
302                                    text.trim()
303                                ));
304                            }
305                            ResourceContents::BlobResourceContents {
306                                uri,
307                                mime_type,
308                                blob,
309                                ..
310                            } => {
311                                md.push_str(&format!(
312                                    "**Binary File:** `{}` (type: {}, {} bytes)\n\n",
313                                    uri,
314                                    mime_type.as_ref().map(|s| s.as_str()).unwrap_or("unknown"),
315                                    blob.len()
316                                ));
317                            }
318                        }
319                    }
320                    RawContent::ResourceLink(_link) => {
321                        // Show a simple placeholder for resource links when exporting
322                        md.push_str("[resource link]\n\n");
323                    }
324                    RawContent::Audio(_) => {
325                        md.push_str("[audio content not displayed in Markdown export]\n\n")
326                    }
327                }
328            }
329        }
330        Err(e) => {
331            md.push_str(&format!(
332                "**Error in Tool Response:**\n```\n{}
333```\n",
334                e
335            ));
336        }
337    }
338    md
339}
340
341pub fn message_to_markdown(message: &Message, export_all_content: bool) -> String {
342    let mut md = String::new();
343    for content in &message.content {
344        match content {
345            MessageContent::ActionRequired(action) => match &action.data {
346                ActionRequiredData::ToolConfirmation { tool_name, .. } => {
347                    md.push_str(&format!(
348                        "**Action Required** (tool_confirmation): {}\n\n",
349                        tool_name
350                    ));
351                }
352                ActionRequiredData::Elicitation { message, .. } => {
353                    md.push_str(&format!(
354                        "**Action Required** (elicitation): {}\n\n",
355                        message
356                    ));
357                }
358                ActionRequiredData::ElicitationResponse { id, user_data } => {
359                    md.push_str(&format!(
360                        "**Action Required** (elicitation_response): {}\n```json\n{}\n```\n\n",
361                        id,
362                        serde_json::to_string_pretty(user_data)
363                            .unwrap_or_else(|_| "{}".to_string())
364                    ));
365                }
366            },
367            MessageContent::Text(text) => {
368                md.push_str(&text.text);
369                md.push_str("\n\n");
370            }
371            MessageContent::ToolRequest(req) => {
372                md.push_str(&tool_request_to_markdown(req, export_all_content));
373                md.push('\n');
374            }
375            MessageContent::ToolResponse(resp) => {
376                md.push_str(&tool_response_to_markdown(resp, export_all_content));
377                md.push('\n');
378            }
379            MessageContent::Image(image) => {
380                md.push_str(&format!(
381                    "**Image:** `(type: {}, data placeholder: {}...)`\n\n",
382                    image.mime_type,
383                    image.data.chars().take(30).collect::<String>()
384                ));
385            }
386            MessageContent::Thinking(thinking) => {
387                md.push_str("**Thinking:**\n");
388                md.push_str("> ");
389                md.push_str(&thinking.thinking.replace("\n", "\n> "));
390                md.push_str("\n\n");
391            }
392            MessageContent::RedactedThinking(_) => {
393                md.push_str("**Thinking:**\n");
394                md.push_str("> *Thinking was redacted*\n\n");
395            }
396            MessageContent::SystemNotification(notification) => {
397                md.push_str(&format!("*{}*\n\n", notification.msg));
398            }
399            _ => {
400                md.push_str(
401                    "`WARNING: Message content type could not be rendered to Markdown`\n\n",
402                );
403            }
404        }
405    }
406    md.trim_end_matches("\n").to_string()
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use aster::conversation::message::{Message, ToolRequest, ToolResponse};
413    use rmcp::model::{CallToolRequestParam, Content, RawTextContent, TextContent};
414    use rmcp::object;
415    use serde_json::json;
416
417    #[test]
418    fn test_value_to_simple_markdown_string_normal() {
419        let value = json!("hello world");
420        let result = value_to_simple_markdown_string(&value, true);
421        assert_eq!(result, "`hello world`");
422    }
423
424    #[test]
425    fn test_value_to_simple_markdown_string_with_backticks() {
426        let value = json!("hello `world`");
427        let result = value_to_simple_markdown_string(&value, true);
428        assert_eq!(result, "`hello \\`world\\``");
429    }
430
431    #[test]
432    fn test_value_to_simple_markdown_string_long_string_full_export() {
433        let long_string = "a".repeat(5000);
434        let value = json!(long_string);
435        let result = value_to_simple_markdown_string(&value, true);
436        // When export_full_strings is true, should return full string
437        assert!(result.starts_with("`"));
438        assert!(result.ends_with("`"));
439        assert!(result.contains(&"a".repeat(5000)));
440    }
441
442    #[test]
443    fn test_value_to_simple_markdown_string_long_string_trimmed() {
444        let long_string = "a".repeat(5000);
445        let value = json!(long_string);
446        let result = value_to_simple_markdown_string(&value, false);
447        // When export_full_strings is false, should trim long strings
448        assert!(result.starts_with("`"));
449        assert!(result.contains("[ ... trimmed : "));
450        assert!(result.contains("4900 chars ... ]`"));
451        assert!(result.contains(&"a".repeat(97))); // Should contain the prefix (100 - 3 for "...")
452    }
453
454    #[test]
455    fn test_value_to_simple_markdown_string_numbers_and_bools() {
456        assert_eq!(value_to_simple_markdown_string(&json!(42), true), "42");
457        assert_eq!(
458            value_to_simple_markdown_string(&json!(true), true),
459            "*true*"
460        );
461        assert_eq!(
462            value_to_simple_markdown_string(&json!(false), true),
463            "*false*"
464        );
465        assert_eq!(
466            value_to_simple_markdown_string(&json!(null), true),
467            "_null_"
468        );
469    }
470
471    #[test]
472    fn test_value_to_markdown_empty_object() {
473        let value = json!({});
474        let result = value_to_markdown(&value, 0, true);
475        assert!(result.contains("*empty object*"));
476    }
477
478    #[test]
479    fn test_value_to_markdown_empty_array() {
480        let value = json!([]);
481        let result = value_to_markdown(&value, 0, true);
482        assert!(result.contains("*empty list*"));
483    }
484
485    #[test]
486    fn test_value_to_markdown_simple_object() {
487        let value = json!({
488            "name": "test",
489            "count": 42,
490            "active": true
491        });
492        let result = value_to_markdown(&value, 0, true);
493        assert!(result.contains("**name**"));
494        assert!(result.contains("`test`"));
495        assert!(result.contains("**count**"));
496        assert!(result.contains("42"));
497        assert!(result.contains("**active**"));
498        assert!(result.contains("*true*"));
499    }
500
501    #[test]
502    fn test_value_to_markdown_nested_object() {
503        let value = json!({
504            "user": {
505                "name": "Alice",
506                "age": 30
507            }
508        });
509        let result = value_to_markdown(&value, 0, true);
510        assert!(result.contains("**user**"));
511        assert!(result.contains("**name**"));
512        assert!(result.contains("`Alice`"));
513        assert!(result.contains("**age**"));
514        assert!(result.contains("30"));
515    }
516
517    #[test]
518    fn test_value_to_markdown_array_with_items() {
519        let value = json!(["item1", "item2", 42]);
520        let result = value_to_markdown(&value, 0, true);
521        assert!(result.contains("- `item1`"));
522        assert!(result.contains("- `item2`"));
523        // Numbers are handled by recursive call, so they get formatted differently
524        assert!(result.contains("42"));
525    }
526
527    #[test]
528    fn test_tool_request_to_markdown_shell() {
529        let tool_call = CallToolRequestParam {
530            name: "developer__shell".into(),
531            arguments: Some(object!({
532                "command": "ls -la",
533                "working_dir": "/home/user"
534            })),
535        };
536        let tool_request = ToolRequest {
537            id: "test-id".to_string(),
538            tool_call: Ok(tool_call),
539            metadata: None,
540            tool_meta: None,
541        };
542
543        let result = tool_request_to_markdown(&tool_request, true);
544        assert!(result.contains("#### Tool Call: `shell`"));
545        assert!(result.contains("namespace: `developer`"));
546        assert!(result.contains("**command**:"));
547        assert!(result.contains("```sh"));
548        assert!(result.contains("ls -la"));
549        assert!(result.contains("**working_dir**"));
550    }
551
552    #[test]
553    fn test_tool_request_to_markdown_text_editor() {
554        let tool_call = CallToolRequestParam {
555            name: "developer__text_editor".into(),
556            arguments: Some(object!({
557                "path": "/path/to/file.txt",
558                "code_edit": "print('Hello World')"
559            })),
560        };
561        let tool_request = ToolRequest {
562            id: "test-id".to_string(),
563            tool_call: Ok(tool_call),
564            metadata: None,
565            tool_meta: None,
566        };
567
568        let result = tool_request_to_markdown(&tool_request, true);
569        assert!(result.contains("#### Tool Call: `text_editor`"));
570        assert!(result.contains("**path**: `/path/to/file.txt`"));
571        assert!(result.contains("**code_edit**:"));
572        assert!(result.contains("print('Hello World')"));
573    }
574
575    #[test]
576    fn test_tool_response_to_markdown_text() {
577        let text_content = TextContent {
578            raw: RawTextContent {
579                text: "Command executed successfully".to_string(),
580                meta: None,
581            },
582            annotations: None,
583        };
584        let tool_response = ToolResponse {
585            metadata: None,
586            id: "test-id".to_string(),
587            tool_result: Ok(rmcp::model::CallToolResult {
588                content: vec![Content::text(text_content.raw.text)],
589                structured_content: None,
590                is_error: Some(false),
591                meta: None,
592            }),
593        };
594
595        let result = tool_response_to_markdown(&tool_response, true);
596        assert!(result.contains("#### Tool Response:"));
597        assert!(result.contains("Command executed successfully"));
598    }
599
600    #[test]
601    fn test_tool_response_to_markdown_json() {
602        let json_text = r#"{"status": "success", "data": "test"}"#;
603        let text_content = TextContent {
604            raw: RawTextContent {
605                text: json_text.to_string(),
606                meta: None,
607            },
608            annotations: None,
609        };
610        let tool_response = ToolResponse {
611            metadata: None,
612            id: "test-id".to_string(),
613            tool_result: Ok(rmcp::model::CallToolResult {
614                content: vec![Content::text(text_content.raw.text)],
615                structured_content: None,
616                is_error: Some(false),
617                meta: None,
618            }),
619        };
620
621        let result = tool_response_to_markdown(&tool_response, true);
622        assert!(result.contains("#### Tool Response:"));
623        assert!(result.contains("```json"));
624        assert!(result.contains(json_text));
625    }
626
627    #[test]
628    fn test_message_to_markdown_text() {
629        let message = Message::user().with_text("Hello, this is a test message");
630
631        let result = message_to_markdown(&message, true);
632        assert_eq!(result, "Hello, this is a test message");
633    }
634
635    #[test]
636    fn test_message_to_markdown_with_tool_request() {
637        let tool_call = CallToolRequestParam {
638            name: "test_tool".into(),
639            arguments: Some(object!({"param": "value"})),
640        };
641
642        let message = Message::assistant().with_tool_request("test-id", Ok(tool_call));
643
644        let result = message_to_markdown(&message, true);
645        assert!(result.contains("#### Tool Call: `test_tool`"));
646        assert!(result.contains("**param**"));
647    }
648
649    #[test]
650    fn test_message_to_markdown_thinking() {
651        let message = Message::assistant()
652            .with_thinking("I need to analyze this problem...", "test-signature");
653
654        let result = message_to_markdown(&message, true);
655        assert!(result.contains("**Thinking:**"));
656        assert!(result.contains("> I need to analyze this problem..."));
657    }
658
659    #[test]
660    fn test_message_to_markdown_redacted_thinking() {
661        let message = Message::assistant().with_redacted_thinking("redacted-data");
662
663        let result = message_to_markdown(&message, true);
664        assert!(result.contains("**Thinking:**"));
665        assert!(result.contains("> *Thinking was redacted*"));
666    }
667
668    #[test]
669    fn test_recursive_value_to_markdown() {
670        // Test that complex nested structures are properly handled with recursion
671        let value = json!({
672            "level1": {
673                "level2": {
674                    "data": "nested value"
675                },
676                "array": [
677                    {"item": "first"},
678                    {"item": "second"}
679                ]
680            }
681        });
682
683        let result = value_to_markdown(&value, 0, true);
684        assert!(result.contains("**level1**"));
685        assert!(result.contains("**level2**"));
686        assert!(result.contains("**data**"));
687        assert!(result.contains("`nested value`"));
688        assert!(result.contains("**array**"));
689        assert!(result.contains("**item**"));
690        assert!(result.contains("`first`"));
691        assert!(result.contains("`second`"));
692    }
693
694    #[test]
695    fn test_shell_tool_with_code_output() {
696        let tool_call = CallToolRequestParam {
697            name: "developer__shell".into(),
698            arguments: Some(object!({
699                "command": "cat main.py"
700            })),
701        };
702        let tool_request = ToolRequest {
703            id: "shell-cat".to_string(),
704            tool_call: Ok(tool_call),
705            metadata: None,
706            tool_meta: None,
707        };
708
709        let python_code = r#"#!/usr/bin/env python3
710def hello_world():
711    print("Hello, World!")
712    
713if __name__ == "__main__":
714    hello_world()"#;
715
716        let text_content = TextContent {
717            raw: RawTextContent {
718                text: python_code.to_string(),
719                meta: None,
720            },
721            annotations: None,
722        };
723        let tool_response = ToolResponse {
724            metadata: None,
725            id: "shell-cat".to_string(),
726            tool_result: Ok(rmcp::model::CallToolResult {
727                content: vec![Content::text(text_content.raw.text)],
728                structured_content: None,
729                is_error: Some(false),
730                meta: None,
731            }),
732        };
733
734        let request_result = tool_request_to_markdown(&tool_request, true);
735        let response_result = tool_response_to_markdown(&tool_response, true);
736
737        // Check request formatting
738        assert!(request_result.contains("#### Tool Call: `shell`"));
739        assert!(request_result.contains("```sh"));
740        assert!(request_result.contains("cat main.py"));
741
742        // Check response formatting - text content is output as plain text
743        assert!(response_result.contains("#### Tool Response:"));
744        assert!(response_result.contains("def hello_world():"));
745        assert!(response_result.contains("print(\"Hello, World!\")"));
746    }
747
748    #[test]
749    fn test_shell_tool_with_git_commands() {
750        let git_status_call = CallToolRequestParam {
751            name: "developer__shell".into(),
752            arguments: Some(object!({
753                "command": "git status --porcelain"
754            })),
755        };
756        let tool_request = ToolRequest {
757            id: "git-status".to_string(),
758            tool_call: Ok(git_status_call),
759            metadata: None,
760            tool_meta: None,
761        };
762
763        let git_output = " M src/main.rs\n?? temp.txt\n A new_feature.rs";
764        let text_content = TextContent {
765            raw: RawTextContent {
766                text: git_output.to_string(),
767                meta: None,
768            },
769            annotations: None,
770        };
771        let tool_response = ToolResponse {
772            metadata: None,
773            id: "git-status".to_string(),
774            tool_result: Ok(rmcp::model::CallToolResult {
775                content: vec![Content::text(text_content.raw.text)],
776                structured_content: None,
777                is_error: Some(false),
778                meta: None,
779            }),
780        };
781
782        let request_result = tool_request_to_markdown(&tool_request, true);
783        let response_result = tool_response_to_markdown(&tool_response, true);
784
785        // Check request formatting
786        assert!(request_result.contains("git status --porcelain"));
787        assert!(request_result.contains("```sh"));
788
789        // Check response formatting - git output as plain text
790        assert!(response_result.contains("M src/main.rs"));
791        assert!(response_result.contains("?? temp.txt"));
792    }
793
794    #[test]
795    fn test_shell_tool_with_build_output() {
796        let cargo_build_call = CallToolRequestParam {
797            name: "developer__shell".into(),
798            arguments: Some(object!({
799                "command": "cargo build"
800            })),
801        };
802        let _tool_request = ToolRequest {
803            id: "cargo-build".to_string(),
804            tool_call: Ok(cargo_build_call),
805            metadata: None,
806            tool_meta: None,
807        };
808
809        let build_output = r#"   Compiling aster-cli v0.1.0 (/Users/user/aster)
810warning: unused variable `x`
811 --> src/main.rs:10:9
812   |
81310 |     let x = 5;
814   |         ^ help: if this is intentional, prefix it with an underscore: `_x`
815   |
816   = note: `#[warn(unused_variables)]` on by default
817
818    Finished dev [unoptimized + debuginfo] target(s) in 2.45s"#;
819
820        let text_content = TextContent {
821            raw: RawTextContent {
822                text: build_output.to_string(),
823                meta: None,
824            },
825            annotations: None,
826        };
827        let tool_response = ToolResponse {
828            metadata: None,
829            id: "cargo-build".to_string(),
830            tool_result: Ok(rmcp::model::CallToolResult {
831                content: vec![Content::text(text_content.raw.text)],
832                structured_content: None,
833                is_error: Some(false),
834                meta: None,
835            }),
836        };
837
838        let response_result = tool_response_to_markdown(&tool_response, true);
839
840        // Should format as plain text since it's build output, not code
841        assert!(response_result.contains("Compiling aster-cli"));
842        assert!(response_result.contains("warning: unused variable"));
843        assert!(response_result.contains("Finished dev"));
844    }
845
846    #[test]
847    fn test_shell_tool_with_json_api_response() {
848        let curl_call = CallToolRequestParam {
849            name: "developer__shell".into(),
850            arguments: Some(object!({
851                "command": "curl -s https://api.github.com/repos/microsoft/vscode/releases/latest"
852            })),
853        };
854        let _tool_request = ToolRequest {
855            id: "curl-api".to_string(),
856            tool_call: Ok(curl_call),
857            metadata: None,
858            tool_meta: None,
859        };
860
861        let api_response = r#"{
862  "url": "https://api.github.com/repos/microsoft/vscode/releases/90543298",
863  "tag_name": "1.85.0",
864  "name": "1.85.0",
865  "published_at": "2023-12-07T16:54:32Z",
866  "assets": [
867    {
868      "name": "VSCode-darwin-universal.zip",
869      "download_count": 123456
870    }
871  ]
872}"#;
873
874        let text_content = TextContent {
875            raw: RawTextContent {
876                text: api_response.to_string(),
877                meta: None,
878            },
879            annotations: None,
880        };
881        let tool_response = ToolResponse {
882            metadata: None,
883            id: "curl-api".to_string(),
884            tool_result: Ok(rmcp::model::CallToolResult {
885                content: vec![Content::text(text_content.raw.text)],
886                structured_content: None,
887                is_error: Some(false),
888                meta: None,
889            }),
890        };
891
892        let response_result = tool_response_to_markdown(&tool_response, true);
893
894        // Should detect and format as JSON
895        assert!(response_result.contains("```json"));
896        assert!(response_result.contains("\"tag_name\": \"1.85.0\""));
897        assert!(response_result.contains("\"download_count\": 123456"));
898    }
899
900    #[test]
901    fn test_text_editor_tool_with_code_creation() {
902        let editor_call = CallToolRequestParam {
903            name: "developer__text_editor".into(),
904            arguments: Some(object!({
905                "command": "write",
906                "path": "/tmp/fibonacci.js",
907                "file_text": "function fibonacci(n) {\n  if (n <= 1) return n;\n  return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nconsole.log(fibonacci(10));"
908            })),
909        };
910        let tool_request = ToolRequest {
911            id: "editor-write".to_string(),
912            tool_call: Ok(editor_call),
913            metadata: None,
914            tool_meta: None,
915        };
916
917        let text_content = TextContent {
918            raw: RawTextContent {
919                text: "File created successfully".to_string(),
920                meta: None,
921            },
922            annotations: None,
923        };
924        let tool_response = ToolResponse {
925            metadata: None,
926            id: "editor-write".to_string(),
927            tool_result: Ok(rmcp::model::CallToolResult {
928                content: vec![Content::text(text_content.raw.text)],
929                structured_content: None,
930                is_error: Some(false),
931                meta: None,
932            }),
933        };
934
935        let request_result = tool_request_to_markdown(&tool_request, true);
936        let response_result = tool_response_to_markdown(&tool_response, true);
937
938        // Check request formatting - should format code in file_text properly
939        assert!(request_result.contains("#### Tool Call: `text_editor`"));
940        assert!(request_result.contains("**path**: `/tmp/fibonacci.js`"));
941        assert!(request_result.contains("**file_text**:"));
942        assert!(request_result.contains("function fibonacci(n)"));
943        assert!(request_result.contains("return fibonacci(n - 1)"));
944
945        // Check response formatting
946        assert!(response_result.contains("File created successfully"));
947    }
948
949    #[test]
950    fn test_text_editor_tool_view_code() {
951        let editor_call = CallToolRequestParam {
952            name: "developer__text_editor".into(),
953            arguments: Some(object!({
954                "command": "view",
955                "path": "/src/utils.py"
956            })),
957        };
958        let _tool_request = ToolRequest {
959            id: "editor-view".to_string(),
960            tool_call: Ok(editor_call),
961            metadata: None,
962            tool_meta: None,
963        };
964
965        let python_code = r#"import os
966import json
967from typing import Dict, List, Optional
968
969def load_config(config_path: str) -> Dict:
970    """Load configuration from JSON file."""
971    if not os.path.exists(config_path):
972        raise FileNotFoundError(f"Config file not found: {config_path}")
973    
974    with open(config_path, 'r') as f:
975        return json.load(f)
976
977def process_data(data: List[Dict]) -> List[Dict]:
978    """Process a list of data dictionaries."""
979    return [item for item in data if item.get('active', False)]"#;
980
981        let text_content = TextContent {
982            raw: RawTextContent {
983                text: python_code.to_string(),
984                meta: None,
985            },
986            annotations: None,
987        };
988        let tool_response = ToolResponse {
989            metadata: None,
990            id: "editor-view".to_string(),
991            tool_result: Ok(rmcp::model::CallToolResult {
992                content: vec![Content::text(text_content.raw.text)],
993                structured_content: None,
994                is_error: Some(false),
995                meta: None,
996            }),
997        };
998
999        let response_result = tool_response_to_markdown(&tool_response, true);
1000
1001        // Text content is output as plain text
1002        assert!(response_result.contains("import os"));
1003        assert!(response_result.contains("def load_config"));
1004        assert!(response_result.contains("typing import Dict"));
1005    }
1006
1007    #[test]
1008    fn test_shell_tool_with_error_output() {
1009        let error_call = CallToolRequestParam {
1010            name: "developer__shell".into(),
1011            arguments: Some(object!({
1012                "command": "python nonexistent_script.py"
1013            })),
1014        };
1015        let _tool_request = ToolRequest {
1016            id: "shell-error".to_string(),
1017            tool_call: Ok(error_call),
1018            metadata: None,
1019            tool_meta: None,
1020        };
1021
1022        let error_output = r#"python: can't open file 'nonexistent_script.py': [Errno 2] No such file or directory
1023Command failed with exit code 2"#;
1024
1025        let text_content = TextContent {
1026            raw: RawTextContent {
1027                text: error_output.to_string(),
1028                meta: None,
1029            },
1030            annotations: None,
1031        };
1032        let tool_response = ToolResponse {
1033            metadata: None,
1034            id: "shell-error".to_string(),
1035            tool_result: Ok(rmcp::model::CallToolResult {
1036                content: vec![Content::text(text_content.raw.text)],
1037                structured_content: None,
1038                is_error: Some(false),
1039                meta: None,
1040            }),
1041        };
1042
1043        let response_result = tool_response_to_markdown(&tool_response, true);
1044
1045        // Error output should be formatted as plain text
1046        assert!(response_result.contains("can't open file"));
1047        assert!(response_result.contains("Command failed with exit code 2"));
1048    }
1049
1050    #[test]
1051    fn test_shell_tool_complex_script_execution() {
1052        let script_call = CallToolRequestParam {
1053            name: "developer__shell".into(),
1054            arguments: Some(object!({
1055                "command": "python -c \"import sys; print(f'Python {sys.version}'); [print(f'{i}^2 = {i**2}') for i in range(1, 6)]\""
1056            })),
1057        };
1058        let tool_request = ToolRequest {
1059            id: "script-exec".to_string(),
1060            tool_call: Ok(script_call),
1061            metadata: None,
1062            tool_meta: None,
1063        };
1064
1065        let script_output = r#"Python 3.11.5 (main, Aug 24 2023, 15:18:16) [Clang 14.0.3 ]
10661^2 = 1
10672^2 = 4
10683^2 = 9
10694^2 = 16
10705^2 = 25"#;
1071
1072        let text_content = TextContent {
1073            raw: RawTextContent {
1074                text: script_output.to_string(),
1075                meta: None,
1076            },
1077            annotations: None,
1078        };
1079        let tool_response = ToolResponse {
1080            metadata: None,
1081            id: "script-exec".to_string(),
1082            tool_result: Ok(rmcp::model::CallToolResult {
1083                content: vec![Content::text(text_content.raw.text)],
1084                structured_content: None,
1085                is_error: Some(false),
1086                meta: None,
1087            }),
1088        };
1089
1090        let request_result = tool_request_to_markdown(&tool_request, true);
1091        let response_result = tool_response_to_markdown(&tool_response, true);
1092
1093        // Check request formatting for complex command
1094        assert!(request_result.contains("```sh"));
1095        assert!(request_result.contains("python -c"));
1096        assert!(request_result.contains("sys.version"));
1097
1098        // Check response formatting
1099        assert!(response_result.contains("Python 3.11.5"));
1100        assert!(response_result.contains("1^2 = 1"));
1101        assert!(response_result.contains("5^2 = 25"));
1102    }
1103
1104    #[test]
1105    fn test_shell_tool_with_multi_command() {
1106        let multi_call = CallToolRequestParam {
1107            name: "developer__shell".into(),
1108            arguments: Some(object!({
1109                "command": "cd /tmp && ls -la | head -5 && pwd"
1110            })),
1111        };
1112        let _tool_request = ToolRequest {
1113            id: "multi-cmd".to_string(),
1114            tool_call: Ok(multi_call),
1115            metadata: None,
1116            tool_meta: None,
1117        };
1118
1119        let multi_output = r#"total 24
1120drwxrwxrwt  15 root  wheel   480 Dec  7 10:30 .
1121drwxr-xr-x   6 root  wheel   192 Nov 15 09:15 ..
1122-rw-r--r--   1 user  staff   256 Dec  7 09:45 config.json
1123drwx------   3 user  staff    96 Dec  6 16:20 com.apple.launchd.abc
1124/tmp"#;
1125
1126        let text_content = TextContent {
1127            raw: RawTextContent {
1128                text: multi_output.to_string(),
1129                meta: None,
1130            },
1131            annotations: None,
1132        };
1133        let tool_response = ToolResponse {
1134            metadata: None,
1135            id: "multi-cmd".to_string(),
1136            tool_result: Ok(rmcp::model::CallToolResult {
1137                content: vec![Content::text(text_content.raw.text)],
1138                structured_content: None,
1139                is_error: Some(false),
1140                meta: None,
1141            }),
1142        };
1143
1144        let request_result = tool_request_to_markdown(&_tool_request, true);
1145        let response_result = tool_response_to_markdown(&tool_response, true);
1146
1147        // Check request formatting for chained commands
1148        assert!(request_result.contains("cd /tmp && ls -la | head -5 && pwd"));
1149
1150        // Check response formatting
1151        assert!(response_result.contains("drwxrwxrwt"));
1152        assert!(response_result.contains("config.json"));
1153        assert!(response_result.contains("/tmp"));
1154    }
1155
1156    #[test]
1157    fn test_developer_tool_grep_code_search() {
1158        let grep_call = CallToolRequestParam {
1159            name: "developer__shell".into(),
1160            arguments: Some(object!({
1161                "command": "rg 'async fn' --type rust -n"
1162            })),
1163        };
1164        let tool_request = ToolRequest {
1165            id: "grep-search".to_string(),
1166            tool_call: Ok(grep_call),
1167            metadata: None,
1168            tool_meta: None,
1169        };
1170
1171        let grep_output = r#"src/main.rs:15:async fn process_request(req: Request) -> Result<Response> {
1172src/handler.rs:8:async fn handle_connection(stream: TcpStream) {
1173src/database.rs:23:async fn query_users(pool: &Pool) -> Result<Vec<User>> {
1174src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Result<Response> {"#;
1175
1176        let text_content = TextContent {
1177            raw: RawTextContent {
1178                text: grep_output.to_string(),
1179                meta: None,
1180            },
1181            annotations: None,
1182        };
1183        let tool_response = ToolResponse {
1184            metadata: None,
1185            id: "grep-search".to_string(),
1186            tool_result: Ok(rmcp::model::CallToolResult {
1187                content: vec![Content::text(text_content.raw.text)],
1188                structured_content: None,
1189                is_error: Some(false),
1190                meta: None,
1191            }),
1192        };
1193
1194        let request_result = tool_request_to_markdown(&tool_request, true);
1195        let response_result = tool_response_to_markdown(&tool_response, true);
1196
1197        // Check request formatting
1198        assert!(request_result.contains("rg 'async fn' --type rust -n"));
1199
1200        // Check response formatting - should be formatted as search results
1201        assert!(response_result.contains("src/main.rs:15:"));
1202        assert!(response_result.contains("async fn process_request"));
1203        assert!(response_result.contains("src/database.rs:23:"));
1204    }
1205
1206    #[test]
1207    fn test_shell_tool_json_detection_works() {
1208        // This test shows that JSON detection in tool responses DOES work
1209        let tool_call = CallToolRequestParam {
1210            name: "developer__shell".into(),
1211            arguments: Some(object!({
1212                "command": "echo '{\"test\": \"json\"}'"
1213            })),
1214        };
1215        let _tool_request = ToolRequest {
1216            id: "json-test".to_string(),
1217            tool_call: Ok(tool_call),
1218            metadata: None,
1219            tool_meta: None,
1220        };
1221
1222        let json_output = r#"{"status": "success", "data": {"count": 42}}"#;
1223        let text_content = TextContent {
1224            raw: RawTextContent {
1225                text: json_output.to_string(),
1226                meta: None,
1227            },
1228            annotations: None,
1229        };
1230        let tool_response = ToolResponse {
1231            metadata: None,
1232            id: "json-test".to_string(),
1233            tool_result: Ok(rmcp::model::CallToolResult {
1234                content: vec![Content::text(text_content.raw.text)],
1235                structured_content: None,
1236                is_error: Some(false),
1237                meta: None,
1238            }),
1239        };
1240
1241        let response_result = tool_response_to_markdown(&tool_response, true);
1242
1243        // JSON should be auto-detected and formatted
1244        assert!(response_result.contains("```json"));
1245        assert!(response_result.contains("\"status\": \"success\""));
1246        assert!(response_result.contains("\"count\": 42"));
1247    }
1248
1249    #[test]
1250    fn test_shell_tool_with_package_management() {
1251        let npm_call = CallToolRequestParam {
1252            name: "developer__shell".into(),
1253            arguments: Some(object!({
1254                "command": "npm install express typescript @types/node --save-dev"
1255            })),
1256        };
1257        let tool_request = ToolRequest {
1258            id: "npm-install".to_string(),
1259            tool_call: Ok(npm_call),
1260            metadata: None,
1261            tool_meta: None,
1262        };
1263
1264        let npm_output = r#"added 57 packages, and audited 58 packages in 3s
1265
12668 packages are looking for funding
1267  run `npm fund` for details
1268
1269found 0 vulnerabilities"#;
1270
1271        let text_content = TextContent {
1272            raw: RawTextContent {
1273                text: npm_output.to_string(),
1274                meta: None,
1275            },
1276            annotations: None,
1277        };
1278        let tool_response = ToolResponse {
1279            metadata: None,
1280            id: "npm-install".to_string(),
1281            tool_result: Ok(rmcp::model::CallToolResult {
1282                content: vec![Content::text(text_content.raw.text)],
1283                structured_content: None,
1284                is_error: Some(false),
1285                meta: None,
1286            }),
1287        };
1288
1289        let request_result = tool_request_to_markdown(&tool_request, true);
1290        let response_result = tool_response_to_markdown(&tool_response, true);
1291
1292        // Check request formatting
1293        assert!(request_result.contains("npm install express typescript"));
1294        assert!(request_result.contains("--save-dev"));
1295
1296        // Check response formatting
1297        assert!(response_result.contains("added 57 packages"));
1298        assert!(response_result.contains("found 0 vulnerabilities"));
1299    }
1300}