diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 4e6deb47..027d9656 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -34,7 +34,7 @@ struct Cli { command: Option, } -#[derive(Args)] +#[derive(Args, Debug)] #[group(required = false, multiple = false)] struct Identifier { #[arg( @@ -102,6 +102,19 @@ enum SessionCommand { #[arg(short, long, help = "Regex for removing matched sessions (optional)")] regex: Option, }, + #[command(about = "Export a session to Markdown format")] + Export { + #[command(flatten)] + identifier: Option, + + #[arg( + short, + long, + help = "Output file path (default: stdout)", + long_help = "Path to save the exported Markdown. If not provided, output will be sent to stdout" + )] + output: Option, + }, } #[derive(Subcommand, Debug)] @@ -550,6 +563,23 @@ pub async fn cli() -> Result<()> { handle_session_remove(id, regex)?; return Ok(()); } + Some(SessionCommand::Export { identifier, output }) => { + let session_identifier = if let Some(id) = identifier { + extract_identifier(id) + } else { + // If no identifier is provided, prompt for interactive selection + match crate::commands::session::prompt_interactive_session_selection() { + Ok(id) => id, + Err(e) => { + eprintln!("Error: {}", e); + return Ok(()); + } + } + }; + + crate::commands::session::handle_session_export(session_identifier, output)?; + Ok(()) + } None => { // Run session command by default let mut session: crate::Session = build_session(SessionBuilderConfig { diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index add1572d..f3fb97e7 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -1,8 +1,11 @@ +use crate::session::message_to_markdown; use anyhow::{Context, Result}; -use cliclack::{confirm, multiselect}; +use cliclack::{confirm, multiselect, select}; use goose::session::info::{get_session_info, SessionInfo, SortOrder}; +use goose::session::{self, Identifier}; use regex::Regex; use std::fs; +use std::path::{Path, PathBuf}; const TRUNCATED_DESC_LENGTH: usize = 60; @@ -29,7 +32,7 @@ pub fn remove_sessions(sessions: Vec) -> Result<()> { Ok(()) } -fn prompt_interactive_session_selection(sessions: &[SessionInfo]) -> Result> { +fn prompt_interactive_session_removal(sessions: &[SessionInfo]) -> Result> { if sessions.is_empty() { println!("No sessions to delete."); return Ok(vec![]); @@ -105,7 +108,7 @@ pub fn handle_session_remove(id: Option, regex_string: Option) - if all_sessions.is_empty() { return Err(anyhow::anyhow!("No sessions found.")); } - matched_sessions = prompt_interactive_session_selection(&all_sessions)?; + matched_sessions = prompt_interactive_session_removal(&all_sessions)?; } if matched_sessions.is_empty() { @@ -165,3 +168,184 @@ pub fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Re } Ok(()) } + +/// Export a session to Markdown without creating a full Session object +/// +/// This function directly reads messages from the session file and converts them to Markdown +/// without creating an Agent or prompting about working directories. +pub fn handle_session_export(identifier: Identifier, output_path: Option) -> Result<()> { + // Get the session file path + let session_file_path = goose::session::get_path(identifier.clone()); + + if !session_file_path.exists() { + return Err(anyhow::anyhow!( + "Session file not found (expected path: {})", + session_file_path.display() + )); + } + + // Read messages directly without using Session + let messages = match goose::session::read_messages(&session_file_path) { + Ok(msgs) => msgs, + Err(e) => { + return Err(anyhow::anyhow!("Failed to read session messages: {}", e)); + } + }; + + // Generate the markdown content using the export functionality + let markdown = export_session_to_markdown(messages, &session_file_path, None); + + // Output the markdown + if let Some(output) = output_path { + fs::write(&output, markdown) + .with_context(|| format!("Failed to write to output file: {}", output.display()))?; + println!("Session exported to {}", output.display()); + } else { + println!("{}", markdown); + } + + Ok(()) +} + +/// Convert a list of messages to markdown format for session export +/// +/// This function handles the formatting of a complete session including headers, +/// message organization, and proper tool request/response pairing. +fn export_session_to_markdown( + messages: Vec, + session_file: &Path, + session_name_override: Option<&str>, +) -> String { + let mut markdown_output = String::new(); + + let session_name = session_name_override.unwrap_or_else(|| { + session_file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unnamed Session") + }); + + markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name)); + + if messages.is_empty() { + markdown_output.push_str("*(This session has no messages)*\n"); + return markdown_output; + } + + markdown_output.push_str(&format!("*Total messages: {}*\n\n---\n\n", messages.len())); + + // Track if the last message had tool requests to properly handle tool responses + let mut skip_next_if_tool_response = false; + + for message in &messages { + // Check if this is a User message containing only ToolResponses + let is_only_tool_response = message.role == mcp_core::role::Role::User + && message + .content + .iter() + .all(|content| matches!(content, goose::message::MessageContent::ToolResponse(_))); + + // If the previous message had tool requests and this one is just tool responses, + // don't create a new User section - we'll attach the responses to the tool calls + if skip_next_if_tool_response && is_only_tool_response { + // Export the tool responses without a User heading + markdown_output.push_str(&message_to_markdown(message, false)); + markdown_output.push_str("\n\n---\n\n"); + skip_next_if_tool_response = false; + continue; + } + + // Reset the skip flag - we'll update it below if needed + skip_next_if_tool_response = false; + + // Output the role prefix except for tool response-only messages + if !is_only_tool_response { + let role_prefix = match message.role { + mcp_core::role::Role::User => "### User:\n", + mcp_core::role::Role::Assistant => "### Assistant:\n", + }; + markdown_output.push_str(role_prefix); + } + + // Add the message content + markdown_output.push_str(&message_to_markdown(message, false)); + markdown_output.push_str("\n\n---\n\n"); + + // Check if this message has any tool requests, to handle the next message differently + if message + .content + .iter() + .any(|content| matches!(content, goose::message::MessageContent::ToolRequest(_))) + { + skip_next_if_tool_response = true; + } + } + + markdown_output +} + +/// Prompt the user to interactively select a session +/// +/// Shows a list of available sessions and lets the user select one +pub fn prompt_interactive_session_selection() -> Result { + // Get sessions sorted by modification date (newest first) + let sessions = match get_session_info(SortOrder::Descending) { + Ok(sessions) => sessions, + Err(e) => { + tracing::error!("Failed to list sessions: {:?}", e); + return Err(anyhow::anyhow!("Failed to list sessions")); + } + }; + + if sessions.is_empty() { + return Err(anyhow::anyhow!("No sessions found")); + } + + // Build the selection prompt + let mut selector = select("Select a session to export:"); + + // Map to display text + let display_map: std::collections::HashMap = sessions + .iter() + .map(|s| { + let desc = if s.metadata.description.is_empty() { + "(no description)" + } else { + &s.metadata.description + }; + + // Truncate description if too long + let truncated_desc = if desc.len() > 40 { + format!("{}...", &desc[..37]) + } else { + desc.to_string() + }; + + let display_text = format!("{} - {} ({})", s.modified, truncated_desc, s.id); + (display_text, s.clone()) + }) + .collect(); + + // Add each session as an option + for display_text in display_map.keys() { + selector = selector.item(display_text.clone(), display_text.clone(), ""); + } + + // Add a cancel option + let cancel_value = String::from("cancel"); + selector = selector.item(cancel_value, "Cancel", "Cancel export"); + + // Get user selection + let selected_display_text: String = selector.interact()?; + + if selected_display_text == "cancel" { + return Err(anyhow::anyhow!("Export canceled")); + } + + // Retrieve the selected session + if let Some(session) = display_map.get(&selected_display_text) { + Ok(goose::session::Identifier::Name(session.id.clone())) + } else { + Err(anyhow::anyhow!("Invalid selection")) + } +} diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs new file mode 100644 index 00000000..57b83b1e --- /dev/null +++ b/crates/goose-cli/src/session/export.rs @@ -0,0 +1,1095 @@ +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 = 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 = 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(" { + 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::() + )); + } + 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 { +src/handler.rs:8:async fn handle_connection(stream: TcpStream) { +src/database.rs:23:async fn query_users(pool: &Pool) -> Result> { +src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Result {"#; + + 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")); + } +} diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index d980ba7c..23e2735d 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1,10 +1,12 @@ mod builder; mod completion; +mod export; mod input; mod output; mod prompt; mod thinking; +pub use self::export::message_to_markdown; pub use builder::{build_session, SessionBuilderConfig}; use console::Color; use goose::agents::AgentEvent;