mirror of
https://github.com/aljazceru/goose.git
synced 2026-02-11 01:24:24 +01:00
1096 lines
40 KiB
Rust
1096 lines
40 KiB
Rust
use goose::message::{Message, MessageContent, ToolRequest, ToolResponse};
|
|
use mcp_core::content::Content as McpContent;
|
|
use mcp_core::resource::ResourceContents;
|
|
use mcp_core::role::Role;
|
|
use serde_json::Value;
|
|
|
|
const MAX_STRING_LENGTH_MD_EXPORT: usize = 4096; // Generous limit for export
|
|
const REDACTED_PREFIX_LENGTH: usize = 100; // Show first 100 chars before trimming
|
|
|
|
fn value_to_simple_markdown_string(value: &Value, export_full_strings: bool) -> String {
|
|
match value {
|
|
Value::String(s) => {
|
|
if !export_full_strings && s.len() > MAX_STRING_LENGTH_MD_EXPORT {
|
|
let prefix = &s[..REDACTED_PREFIX_LENGTH.min(s.len())];
|
|
let trimmed_chars = s.len() - prefix.len();
|
|
format!("`{}[ ... trimmed : {} chars ... ]`", prefix, trimmed_chars)
|
|
} else {
|
|
// Escape backticks and newlines for inline code.
|
|
let escaped = s.replace('`', "\\`").replace("\n", "\\\\n");
|
|
format!("`{}`", escaped)
|
|
}
|
|
}
|
|
Value::Number(n) => n.to_string(),
|
|
Value::Bool(b) => format!("*{}*", b),
|
|
Value::Null => "_null_".to_string(),
|
|
_ => "`[Complex Value]`".to_string(),
|
|
}
|
|
}
|
|
|
|
fn value_to_markdown(value: &Value, depth: usize, export_full_strings: bool) -> String {
|
|
let mut md_string = String::new();
|
|
let base_indent_str = " ".repeat(depth); // Basic indentation for nesting
|
|
|
|
match value {
|
|
Value::Object(map) => {
|
|
if map.is_empty() {
|
|
md_string.push_str(&format!("{}*empty object*\n", base_indent_str));
|
|
} else {
|
|
for (key, val) in map {
|
|
md_string.push_str(&format!("{}* **{}**: ", base_indent_str, key));
|
|
match val {
|
|
Value::String(s) => {
|
|
if s.contains('\n') || s.len() > 80 {
|
|
// Heuristic for block
|
|
md_string.push_str(&format!(
|
|
"\n{} ```\n{}{}\n{} ```\n",
|
|
base_indent_str,
|
|
base_indent_str,
|
|
s.trim(),
|
|
base_indent_str
|
|
));
|
|
} else {
|
|
md_string.push_str(&format!("`{}`\n", s.replace('`', "\\`")));
|
|
}
|
|
}
|
|
_ => {
|
|
// Use recursive call for all values including complex objects/arrays
|
|
md_string.push('\n');
|
|
md_string.push_str(&value_to_markdown(
|
|
val,
|
|
depth + 2,
|
|
export_full_strings,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Value::Array(arr) => {
|
|
if arr.is_empty() {
|
|
md_string.push_str(&format!("{}* *empty list*\n", base_indent_str));
|
|
} else {
|
|
for item in arr {
|
|
md_string.push_str(&format!("{}* - ", base_indent_str));
|
|
match item {
|
|
Value::String(s) => {
|
|
if s.contains('\n') || s.len() > 80 {
|
|
// Heuristic for block
|
|
md_string.push_str(&format!(
|
|
"\n{} ```\n{}{}\n{} ```\n",
|
|
base_indent_str,
|
|
base_indent_str,
|
|
s.trim(),
|
|
base_indent_str
|
|
));
|
|
} else {
|
|
md_string.push_str(&format!("`{}`\n", s.replace('`', "\\`")));
|
|
}
|
|
}
|
|
_ => {
|
|
// Use recursive call for all values including complex objects/arrays
|
|
md_string.push('\n');
|
|
md_string.push_str(&value_to_markdown(
|
|
item,
|
|
depth + 2,
|
|
export_full_strings,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
md_string.push_str(&format!(
|
|
"{}{}\n",
|
|
base_indent_str,
|
|
value_to_simple_markdown_string(value, export_full_strings)
|
|
));
|
|
}
|
|
}
|
|
md_string
|
|
}
|
|
|
|
pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> String {
|
|
let mut md = String::new();
|
|
match &req.tool_call {
|
|
Ok(call) => {
|
|
let parts: Vec<_> = call.name.rsplitn(2, "__").collect();
|
|
let (namespace, tool_name_only) = if parts.len() == 2 {
|
|
(parts[1], parts[0])
|
|
} else {
|
|
("Tool", parts[0])
|
|
};
|
|
|
|
md.push_str(&format!(
|
|
"#### Tool Call: `{}` (namespace: `{}`)\n",
|
|
tool_name_only, namespace
|
|
));
|
|
md.push_str("**Arguments:**\n");
|
|
|
|
match call.name.as_str() {
|
|
"developer__shell" => {
|
|
if let Some(Value::String(command)) = call.arguments.get("command") {
|
|
md.push_str(&format!(
|
|
"* **command**:\n ```sh\n {}\n ```\n",
|
|
command.trim()
|
|
));
|
|
}
|
|
let other_args: serde_json::Map<String, Value> = call
|
|
.arguments
|
|
.as_object()
|
|
.map(|obj| {
|
|
obj.iter()
|
|
.filter(|(k, _)| k.as_str() != "command")
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
if !other_args.is_empty() {
|
|
md.push_str(&value_to_markdown(
|
|
&Value::Object(other_args),
|
|
0,
|
|
export_all_content,
|
|
));
|
|
}
|
|
}
|
|
"developer__text_editor" => {
|
|
if let Some(Value::String(path)) = call.arguments.get("path") {
|
|
md.push_str(&format!("* **path**: `{}`\n", path));
|
|
}
|
|
if let Some(Value::String(code_edit)) = call.arguments.get("code_edit") {
|
|
md.push_str(&format!(
|
|
"* **code_edit**:\n ```\n{}\n ```\n",
|
|
code_edit
|
|
));
|
|
}
|
|
|
|
let other_args: serde_json::Map<String, Value> = call
|
|
.arguments
|
|
.as_object()
|
|
.map(|obj| {
|
|
obj.iter()
|
|
.filter(|(k, _)| k.as_str() != "path" && k.as_str() != "code_edit")
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
if !other_args.is_empty() {
|
|
md.push_str(&value_to_markdown(
|
|
&Value::Object(other_args),
|
|
0,
|
|
export_all_content,
|
|
));
|
|
}
|
|
}
|
|
_ => {
|
|
md.push_str(&value_to_markdown(&call.arguments, 0, export_all_content));
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
md.push_str(&format!(
|
|
"**Error in Tool Call:**\n```\n{}
|
|
```\n",
|
|
e
|
|
));
|
|
}
|
|
}
|
|
md
|
|
}
|
|
|
|
pub fn tool_response_to_markdown(resp: &ToolResponse, export_all_content: bool) -> String {
|
|
let mut md = String::new();
|
|
md.push_str("#### Tool Response:\n");
|
|
|
|
match &resp.tool_result {
|
|
Ok(contents) => {
|
|
if contents.is_empty() {
|
|
md.push_str("*No textual output from tool.*\n");
|
|
}
|
|
|
|
for content in contents {
|
|
if !export_all_content {
|
|
if let Some(audience) = content.audience() {
|
|
if !audience.contains(&Role::Assistant) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
match content {
|
|
McpContent::Text(text_content) => {
|
|
let trimmed_text = text_content.text.trim();
|
|
if (trimmed_text.starts_with('{') && trimmed_text.ends_with('}'))
|
|
|| (trimmed_text.starts_with('[') && trimmed_text.ends_with(']'))
|
|
{
|
|
md.push_str(&format!("```json\n{}\n```\n", trimmed_text));
|
|
} else if trimmed_text.starts_with('<')
|
|
&& trimmed_text.ends_with('>')
|
|
&& trimmed_text.contains("</")
|
|
{
|
|
md.push_str(&format!("```xml\n{}\n```\n", trimmed_text));
|
|
} else {
|
|
md.push_str(&text_content.text);
|
|
md.push_str("\n\n");
|
|
}
|
|
}
|
|
McpContent::Image(image_content) => {
|
|
if image_content.mime_type.starts_with("image/") {
|
|
// For actual images, provide a placeholder that indicates it's an image
|
|
md.push_str(&format!(
|
|
"**Image:** `(type: {}, data: first 30 chars of base64...)`\n\n",
|
|
image_content.mime_type
|
|
));
|
|
} else {
|
|
// For non-image mime types, just indicate it's binary data
|
|
md.push_str(&format!(
|
|
"**Binary Content:** `(type: {}, length: {} bytes)`\n\n",
|
|
image_content.mime_type,
|
|
image_content.data.len()
|
|
));
|
|
}
|
|
}
|
|
McpContent::Resource(resource) => {
|
|
match &resource.resource {
|
|
ResourceContents::TextResourceContents {
|
|
uri,
|
|
mime_type,
|
|
text,
|
|
} => {
|
|
// Extract file extension from the URI for syntax highlighting
|
|
let file_extension = uri.split('.').next_back().unwrap_or("");
|
|
let syntax_type = match file_extension {
|
|
"rs" => "rust",
|
|
"js" => "javascript",
|
|
"ts" => "typescript",
|
|
"py" => "python",
|
|
"json" => "json",
|
|
"yaml" | "yml" => "yaml",
|
|
"md" => "markdown",
|
|
"html" => "html",
|
|
"css" => "css",
|
|
"sh" => "bash",
|
|
_ => mime_type
|
|
.as_ref()
|
|
.map(|mime| if mime == "text" { "" } else { mime })
|
|
.unwrap_or(""),
|
|
};
|
|
|
|
md.push_str(&format!("**File:** `{}`\n", uri));
|
|
md.push_str(&format!(
|
|
"```{}\n{}\n```\n\n",
|
|
syntax_type,
|
|
text.trim()
|
|
));
|
|
}
|
|
ResourceContents::BlobResourceContents {
|
|
uri,
|
|
mime_type,
|
|
blob,
|
|
} => {
|
|
md.push_str(&format!(
|
|
"**Binary File:** `{}` (type: {}, {} bytes)\n\n",
|
|
uri,
|
|
mime_type.as_ref().map(|s| s.as_str()).unwrap_or("unknown"),
|
|
blob.len()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
md.push_str(&format!(
|
|
"**Error in Tool Response:**\n```\n{}
|
|
```\n",
|
|
e
|
|
));
|
|
}
|
|
}
|
|
md
|
|
}
|
|
|
|
pub fn message_to_markdown(message: &Message, export_all_content: bool) -> String {
|
|
let mut md = String::new();
|
|
for content in &message.content {
|
|
match content {
|
|
MessageContent::Text(text) => {
|
|
md.push_str(&text.text);
|
|
md.push_str("\n\n");
|
|
}
|
|
MessageContent::ToolRequest(req) => {
|
|
md.push_str(&tool_request_to_markdown(req, export_all_content));
|
|
md.push('\n');
|
|
}
|
|
MessageContent::ToolResponse(resp) => {
|
|
md.push_str(&tool_response_to_markdown(resp, export_all_content));
|
|
md.push('\n');
|
|
}
|
|
MessageContent::Image(image) => {
|
|
md.push_str(&format!(
|
|
"**Image:** `(type: {}, data placeholder: {}...)`\n\n",
|
|
image.mime_type,
|
|
image.data.chars().take(30).collect::<String>()
|
|
));
|
|
}
|
|
MessageContent::Thinking(thinking) => {
|
|
md.push_str("**Thinking:**\n");
|
|
md.push_str("> ");
|
|
md.push_str(&thinking.thinking.replace("\n", "\n> "));
|
|
md.push_str("\n\n");
|
|
}
|
|
MessageContent::RedactedThinking(_) => {
|
|
md.push_str("**Thinking:**\n");
|
|
md.push_str("> *Thinking was redacted*\n\n");
|
|
}
|
|
_ => {
|
|
md.push_str(
|
|
"`WARNING: Message content type could not be rendered to Markdown`\n\n",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
md.trim_end_matches("\n").to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use goose::message::{Message, ToolRequest, ToolResponse};
|
|
use mcp_core::content::{Content as McpContent, TextContent};
|
|
use mcp_core::tool::ToolCall;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn test_value_to_simple_markdown_string_normal() {
|
|
let value = json!("hello world");
|
|
let result = value_to_simple_markdown_string(&value, true);
|
|
assert_eq!(result, "`hello world`");
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_to_simple_markdown_string_with_backticks() {
|
|
let value = json!("hello `world`");
|
|
let result = value_to_simple_markdown_string(&value, true);
|
|
assert_eq!(result, "`hello \\`world\\``");
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_to_simple_markdown_string_long_string_full_export() {
|
|
let long_string = "a".repeat(5000);
|
|
let value = json!(long_string);
|
|
let result = value_to_simple_markdown_string(&value, true);
|
|
// When export_full_strings is true, should return full string
|
|
assert!(result.starts_with("`"));
|
|
assert!(result.ends_with("`"));
|
|
assert!(result.contains(&"a".repeat(5000)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_to_simple_markdown_string_long_string_trimmed() {
|
|
let long_string = "a".repeat(5000);
|
|
let value = json!(long_string);
|
|
let result = value_to_simple_markdown_string(&value, false);
|
|
// When export_full_strings is false, should trim long strings
|
|
assert!(result.starts_with("`"));
|
|
assert!(result.contains("[ ... trimmed : "));
|
|
assert!(result.contains("4900 chars ... ]`"));
|
|
assert!(result.contains(&"a".repeat(100))); // Should contain the prefix
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_to_simple_markdown_string_numbers_and_bools() {
|
|
assert_eq!(value_to_simple_markdown_string(&json!(42), true), "42");
|
|
assert_eq!(
|
|
value_to_simple_markdown_string(&json!(true), true),
|
|
"*true*"
|
|
);
|
|
assert_eq!(
|
|
value_to_simple_markdown_string(&json!(false), true),
|
|
"*false*"
|
|
);
|
|
assert_eq!(
|
|
value_to_simple_markdown_string(&json!(null), true),
|
|
"_null_"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_to_markdown_empty_object() {
|
|
let value = json!({});
|
|
let result = value_to_markdown(&value, 0, true);
|
|
assert!(result.contains("*empty object*"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_to_markdown_empty_array() {
|
|
let value = json!([]);
|
|
let result = value_to_markdown(&value, 0, true);
|
|
assert!(result.contains("*empty list*"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_to_markdown_simple_object() {
|
|
let value = json!({
|
|
"name": "test",
|
|
"count": 42,
|
|
"active": true
|
|
});
|
|
let result = value_to_markdown(&value, 0, true);
|
|
assert!(result.contains("**name**"));
|
|
assert!(result.contains("`test`"));
|
|
assert!(result.contains("**count**"));
|
|
assert!(result.contains("42"));
|
|
assert!(result.contains("**active**"));
|
|
assert!(result.contains("*true*"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_to_markdown_nested_object() {
|
|
let value = json!({
|
|
"user": {
|
|
"name": "Alice",
|
|
"age": 30
|
|
}
|
|
});
|
|
let result = value_to_markdown(&value, 0, true);
|
|
assert!(result.contains("**user**"));
|
|
assert!(result.contains("**name**"));
|
|
assert!(result.contains("`Alice`"));
|
|
assert!(result.contains("**age**"));
|
|
assert!(result.contains("30"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_to_markdown_array_with_items() {
|
|
let value = json!(["item1", "item2", 42]);
|
|
let result = value_to_markdown(&value, 0, true);
|
|
assert!(result.contains("- `item1`"));
|
|
assert!(result.contains("- `item2`"));
|
|
// Numbers are handled by recursive call, so they get formatted differently
|
|
assert!(result.contains("42"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_request_to_markdown_shell() {
|
|
let tool_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "ls -la",
|
|
"working_dir": "/home/user"
|
|
}),
|
|
};
|
|
let tool_request = ToolRequest {
|
|
id: "test-id".to_string(),
|
|
tool_call: Ok(tool_call),
|
|
};
|
|
|
|
let result = tool_request_to_markdown(&tool_request, true);
|
|
assert!(result.contains("#### Tool Call: `shell`"));
|
|
assert!(result.contains("namespace: `developer`"));
|
|
assert!(result.contains("**command**:"));
|
|
assert!(result.contains("```sh"));
|
|
assert!(result.contains("ls -la"));
|
|
assert!(result.contains("**working_dir**"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_request_to_markdown_text_editor() {
|
|
let tool_call = ToolCall {
|
|
name: "developer__text_editor".to_string(),
|
|
arguments: json!({
|
|
"path": "/path/to/file.txt",
|
|
"code_edit": "print('Hello World')"
|
|
}),
|
|
};
|
|
let tool_request = ToolRequest {
|
|
id: "test-id".to_string(),
|
|
tool_call: Ok(tool_call),
|
|
};
|
|
|
|
let result = tool_request_to_markdown(&tool_request, true);
|
|
assert!(result.contains("#### Tool Call: `text_editor`"));
|
|
assert!(result.contains("**path**: `/path/to/file.txt`"));
|
|
assert!(result.contains("**code_edit**:"));
|
|
assert!(result.contains("print('Hello World')"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_response_to_markdown_text() {
|
|
let text_content = TextContent {
|
|
text: "Command executed successfully".to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "test-id".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let result = tool_response_to_markdown(&tool_response, true);
|
|
assert!(result.contains("#### Tool Response:"));
|
|
assert!(result.contains("Command executed successfully"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_response_to_markdown_json() {
|
|
let json_text = r#"{"status": "success", "data": "test"}"#;
|
|
let text_content = TextContent {
|
|
text: json_text.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "test-id".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let result = tool_response_to_markdown(&tool_response, true);
|
|
assert!(result.contains("#### Tool Response:"));
|
|
assert!(result.contains("```json"));
|
|
assert!(result.contains(json_text));
|
|
}
|
|
|
|
#[test]
|
|
fn test_message_to_markdown_text() {
|
|
let message = Message::user().with_text("Hello, this is a test message");
|
|
|
|
let result = message_to_markdown(&message, true);
|
|
assert_eq!(result, "Hello, this is a test message");
|
|
}
|
|
|
|
#[test]
|
|
fn test_message_to_markdown_with_tool_request() {
|
|
let tool_call = ToolCall {
|
|
name: "test_tool".to_string(),
|
|
arguments: json!({"param": "value"}),
|
|
};
|
|
|
|
let message = Message::assistant().with_tool_request("test-id", Ok(tool_call));
|
|
|
|
let result = message_to_markdown(&message, true);
|
|
assert!(result.contains("#### Tool Call: `test_tool`"));
|
|
assert!(result.contains("**param**"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_message_to_markdown_thinking() {
|
|
let message = Message::assistant()
|
|
.with_thinking("I need to analyze this problem...", "test-signature");
|
|
|
|
let result = message_to_markdown(&message, true);
|
|
assert!(result.contains("**Thinking:**"));
|
|
assert!(result.contains("> I need to analyze this problem..."));
|
|
}
|
|
|
|
#[test]
|
|
fn test_message_to_markdown_redacted_thinking() {
|
|
let message = Message::assistant().with_redacted_thinking("redacted-data");
|
|
|
|
let result = message_to_markdown(&message, true);
|
|
assert!(result.contains("**Thinking:**"));
|
|
assert!(result.contains("> *Thinking was redacted*"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_recursive_value_to_markdown() {
|
|
// Test that complex nested structures are properly handled with recursion
|
|
let value = json!({
|
|
"level1": {
|
|
"level2": {
|
|
"data": "nested value"
|
|
},
|
|
"array": [
|
|
{"item": "first"},
|
|
{"item": "second"}
|
|
]
|
|
}
|
|
});
|
|
|
|
let result = value_to_markdown(&value, 0, true);
|
|
assert!(result.contains("**level1**"));
|
|
assert!(result.contains("**level2**"));
|
|
assert!(result.contains("**data**"));
|
|
assert!(result.contains("`nested value`"));
|
|
assert!(result.contains("**array**"));
|
|
assert!(result.contains("**item**"));
|
|
assert!(result.contains("`first`"));
|
|
assert!(result.contains("`second`"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool_with_code_output() {
|
|
let tool_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "cat main.py"
|
|
}),
|
|
};
|
|
let tool_request = ToolRequest {
|
|
id: "shell-cat".to_string(),
|
|
tool_call: Ok(tool_call),
|
|
};
|
|
|
|
let python_code = r#"#!/usr/bin/env python3
|
|
def hello_world():
|
|
print("Hello, World!")
|
|
|
|
if __name__ == "__main__":
|
|
hello_world()"#;
|
|
|
|
let text_content = TextContent {
|
|
text: python_code.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "shell-cat".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let request_result = tool_request_to_markdown(&tool_request, true);
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Check request formatting
|
|
assert!(request_result.contains("#### Tool Call: `shell`"));
|
|
assert!(request_result.contains("```sh"));
|
|
assert!(request_result.contains("cat main.py"));
|
|
|
|
// Check response formatting - text content is output as plain text
|
|
assert!(response_result.contains("#### Tool Response:"));
|
|
assert!(response_result.contains("def hello_world():"));
|
|
assert!(response_result.contains("print(\"Hello, World!\")"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool_with_git_commands() {
|
|
let git_status_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "git status --porcelain"
|
|
}),
|
|
};
|
|
let tool_request = ToolRequest {
|
|
id: "git-status".to_string(),
|
|
tool_call: Ok(git_status_call),
|
|
};
|
|
|
|
let git_output = " M src/main.rs\n?? temp.txt\n A new_feature.rs";
|
|
let text_content = TextContent {
|
|
text: git_output.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "git-status".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let request_result = tool_request_to_markdown(&tool_request, true);
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Check request formatting
|
|
assert!(request_result.contains("git status --porcelain"));
|
|
assert!(request_result.contains("```sh"));
|
|
|
|
// Check response formatting - git output as plain text
|
|
assert!(response_result.contains("M src/main.rs"));
|
|
assert!(response_result.contains("?? temp.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool_with_build_output() {
|
|
let cargo_build_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "cargo build"
|
|
}),
|
|
};
|
|
let _tool_request = ToolRequest {
|
|
id: "cargo-build".to_string(),
|
|
tool_call: Ok(cargo_build_call),
|
|
};
|
|
|
|
let build_output = r#" Compiling goose-cli v0.1.0 (/Users/user/goose)
|
|
warning: unused variable `x`
|
|
--> src/main.rs:10:9
|
|
|
|
|
10 | let x = 5;
|
|
| ^ help: if this is intentional, prefix it with an underscore: `_x`
|
|
|
|
|
= note: `#[warn(unused_variables)]` on by default
|
|
|
|
Finished dev [unoptimized + debuginfo] target(s) in 2.45s"#;
|
|
|
|
let text_content = TextContent {
|
|
text: build_output.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "cargo-build".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Should format as plain text since it's build output, not code
|
|
assert!(response_result.contains("Compiling goose-cli"));
|
|
assert!(response_result.contains("warning: unused variable"));
|
|
assert!(response_result.contains("Finished dev"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool_with_json_api_response() {
|
|
let curl_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "curl -s https://api.github.com/repos/microsoft/vscode/releases/latest"
|
|
}),
|
|
};
|
|
let _tool_request = ToolRequest {
|
|
id: "curl-api".to_string(),
|
|
tool_call: Ok(curl_call),
|
|
};
|
|
|
|
let api_response = r#"{
|
|
"url": "https://api.github.com/repos/microsoft/vscode/releases/90543298",
|
|
"tag_name": "1.85.0",
|
|
"name": "1.85.0",
|
|
"published_at": "2023-12-07T16:54:32Z",
|
|
"assets": [
|
|
{
|
|
"name": "VSCode-darwin-universal.zip",
|
|
"download_count": 123456
|
|
}
|
|
]
|
|
}"#;
|
|
|
|
let text_content = TextContent {
|
|
text: api_response.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "curl-api".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Should detect and format as JSON
|
|
assert!(response_result.contains("```json"));
|
|
assert!(response_result.contains("\"tag_name\": \"1.85.0\""));
|
|
assert!(response_result.contains("\"download_count\": 123456"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_text_editor_tool_with_code_creation() {
|
|
let editor_call = ToolCall {
|
|
name: "developer__text_editor".to_string(),
|
|
arguments: json!({
|
|
"command": "write",
|
|
"path": "/tmp/fibonacci.js",
|
|
"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));"
|
|
}),
|
|
};
|
|
let tool_request = ToolRequest {
|
|
id: "editor-write".to_string(),
|
|
tool_call: Ok(editor_call),
|
|
};
|
|
|
|
let text_content = TextContent {
|
|
text: "File created successfully".to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "editor-write".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let request_result = tool_request_to_markdown(&tool_request, true);
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Check request formatting - should format code in file_text properly
|
|
assert!(request_result.contains("#### Tool Call: `text_editor`"));
|
|
assert!(request_result.contains("**path**: `/tmp/fibonacci.js`"));
|
|
assert!(request_result.contains("**file_text**:"));
|
|
assert!(request_result.contains("function fibonacci(n)"));
|
|
assert!(request_result.contains("return fibonacci(n - 1)"));
|
|
|
|
// Check response formatting
|
|
assert!(response_result.contains("File created successfully"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_text_editor_tool_view_code() {
|
|
let editor_call = ToolCall {
|
|
name: "developer__text_editor".to_string(),
|
|
arguments: json!({
|
|
"command": "view",
|
|
"path": "/src/utils.py"
|
|
}),
|
|
};
|
|
let _tool_request = ToolRequest {
|
|
id: "editor-view".to_string(),
|
|
tool_call: Ok(editor_call),
|
|
};
|
|
|
|
let python_code = r#"import os
|
|
import json
|
|
from typing import Dict, List, Optional
|
|
|
|
def load_config(config_path: str) -> Dict:
|
|
"""Load configuration from JSON file."""
|
|
if not os.path.exists(config_path):
|
|
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
|
|
with open(config_path, 'r') as f:
|
|
return json.load(f)
|
|
|
|
def process_data(data: List[Dict]) -> List[Dict]:
|
|
"""Process a list of data dictionaries."""
|
|
return [item for item in data if item.get('active', False)]"#;
|
|
|
|
let text_content = TextContent {
|
|
text: python_code.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "editor-view".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Text content is output as plain text
|
|
assert!(response_result.contains("import os"));
|
|
assert!(response_result.contains("def load_config"));
|
|
assert!(response_result.contains("typing import Dict"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool_with_error_output() {
|
|
let error_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "python nonexistent_script.py"
|
|
}),
|
|
};
|
|
let _tool_request = ToolRequest {
|
|
id: "shell-error".to_string(),
|
|
tool_call: Ok(error_call),
|
|
};
|
|
|
|
let error_output = r#"python: can't open file 'nonexistent_script.py': [Errno 2] No such file or directory
|
|
Command failed with exit code 2"#;
|
|
|
|
let text_content = TextContent {
|
|
text: error_output.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "shell-error".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Error output should be formatted as plain text
|
|
assert!(response_result.contains("can't open file"));
|
|
assert!(response_result.contains("Command failed with exit code 2"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool_complex_script_execution() {
|
|
let script_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "python -c \"import sys; print(f'Python {sys.version}'); [print(f'{i}^2 = {i**2}') for i in range(1, 6)]\""
|
|
}),
|
|
};
|
|
let tool_request = ToolRequest {
|
|
id: "script-exec".to_string(),
|
|
tool_call: Ok(script_call),
|
|
};
|
|
|
|
let script_output = r#"Python 3.11.5 (main, Aug 24 2023, 15:18:16) [Clang 14.0.3 ]
|
|
1^2 = 1
|
|
2^2 = 4
|
|
3^2 = 9
|
|
4^2 = 16
|
|
5^2 = 25"#;
|
|
|
|
let text_content = TextContent {
|
|
text: script_output.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "script-exec".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let request_result = tool_request_to_markdown(&tool_request, true);
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Check request formatting for complex command
|
|
assert!(request_result.contains("```sh"));
|
|
assert!(request_result.contains("python -c"));
|
|
assert!(request_result.contains("sys.version"));
|
|
|
|
// Check response formatting
|
|
assert!(response_result.contains("Python 3.11.5"));
|
|
assert!(response_result.contains("1^2 = 1"));
|
|
assert!(response_result.contains("5^2 = 25"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool_with_multi_command() {
|
|
let multi_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "cd /tmp && ls -la | head -5 && pwd"
|
|
}),
|
|
};
|
|
let _tool_request = ToolRequest {
|
|
id: "multi-cmd".to_string(),
|
|
tool_call: Ok(multi_call),
|
|
};
|
|
|
|
let multi_output = r#"total 24
|
|
drwxrwxrwt 15 root wheel 480 Dec 7 10:30 .
|
|
drwxr-xr-x 6 root wheel 192 Nov 15 09:15 ..
|
|
-rw-r--r-- 1 user staff 256 Dec 7 09:45 config.json
|
|
drwx------ 3 user staff 96 Dec 6 16:20 com.apple.launchd.abc
|
|
/tmp"#;
|
|
|
|
let text_content = TextContent {
|
|
text: multi_output.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "multi-cmd".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let request_result = tool_request_to_markdown(&_tool_request, true);
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Check request formatting for chained commands
|
|
assert!(request_result.contains("cd /tmp && ls -la | head -5 && pwd"));
|
|
|
|
// Check response formatting
|
|
assert!(response_result.contains("drwxrwxrwt"));
|
|
assert!(response_result.contains("config.json"));
|
|
assert!(response_result.contains("/tmp"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_developer_tool_grep_code_search() {
|
|
let grep_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "rg 'async fn' --type rust -n"
|
|
}),
|
|
};
|
|
let tool_request = ToolRequest {
|
|
id: "grep-search".to_string(),
|
|
tool_call: Ok(grep_call),
|
|
};
|
|
|
|
let grep_output = r#"src/main.rs:15:async fn process_request(req: Request) -> Result<Response> {
|
|
src/handler.rs:8:async fn handle_connection(stream: TcpStream) {
|
|
src/database.rs:23:async fn query_users(pool: &Pool) -> Result<Vec<User>> {
|
|
src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Result<Response> {"#;
|
|
|
|
let text_content = TextContent {
|
|
text: grep_output.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "grep-search".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let request_result = tool_request_to_markdown(&tool_request, true);
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Check request formatting
|
|
assert!(request_result.contains("rg 'async fn' --type rust -n"));
|
|
|
|
// Check response formatting - should be formatted as search results
|
|
assert!(response_result.contains("src/main.rs:15:"));
|
|
assert!(response_result.contains("async fn process_request"));
|
|
assert!(response_result.contains("src/database.rs:23:"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool_json_detection_works() {
|
|
// This test shows that JSON detection in tool responses DOES work
|
|
let tool_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "echo '{\"test\": \"json\"}'"
|
|
}),
|
|
};
|
|
let _tool_request = ToolRequest {
|
|
id: "json-test".to_string(),
|
|
tool_call: Ok(tool_call),
|
|
};
|
|
|
|
let json_output = r#"{"status": "success", "data": {"count": 42}}"#;
|
|
let text_content = TextContent {
|
|
text: json_output.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "json-test".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// JSON should be auto-detected and formatted
|
|
assert!(response_result.contains("```json"));
|
|
assert!(response_result.contains("\"status\": \"success\""));
|
|
assert!(response_result.contains("\"count\": 42"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool_with_package_management() {
|
|
let npm_call = ToolCall {
|
|
name: "developer__shell".to_string(),
|
|
arguments: json!({
|
|
"command": "npm install express typescript @types/node --save-dev"
|
|
}),
|
|
};
|
|
let tool_request = ToolRequest {
|
|
id: "npm-install".to_string(),
|
|
tool_call: Ok(npm_call),
|
|
};
|
|
|
|
let npm_output = r#"added 57 packages, and audited 58 packages in 3s
|
|
|
|
8 packages are looking for funding
|
|
run `npm fund` for details
|
|
|
|
found 0 vulnerabilities"#;
|
|
|
|
let text_content = TextContent {
|
|
text: npm_output.to_string(),
|
|
annotations: None,
|
|
};
|
|
let tool_response = ToolResponse {
|
|
id: "npm-install".to_string(),
|
|
tool_result: Ok(vec![McpContent::Text(text_content)]),
|
|
};
|
|
|
|
let request_result = tool_request_to_markdown(&tool_request, true);
|
|
let response_result = tool_response_to_markdown(&tool_response, true);
|
|
|
|
// Check request formatting
|
|
assert!(request_result.contains("npm install express typescript"));
|
|
assert!(request_result.contains("--save-dev"));
|
|
|
|
// Check response formatting
|
|
assert!(response_result.contains("added 57 packages"));
|
|
assert!(response_result.contains("found 0 vulnerabilities"));
|
|
}
|
|
}
|