diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index e5adb4b88932..0e214d077dbd 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -248,6 +248,7 @@ fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { "developer__text_editor" => render_text_editor_request(call, debug), "developer__shell" => render_shell_request(call, debug), "dynamic_task__create_task" => render_dynamic_task_request(call, debug), + "todo__read" | "todo__write" => render_todo_request(call, debug), _ => render_default_request(call, debug), }, Err(e) => print_markdown(&e.to_string(), theme), @@ -452,6 +453,19 @@ fn render_dynamic_task_request(call: &ToolCall, debug: bool) { println!(); } +fn render_todo_request(call: &ToolCall, _debug: bool) { + print_tool_header(call); + + // For todo tools, always show the full content without redaction + if let Some(Value::String(content)) = call.arguments.get("content") { + println!("{}: {}", style("content").dim(), style(content).green()); + } else { + // For todo__read, there are no arguments + // Just print an empty line for consistency + } + println!(); +} + fn render_default_request(call: &ToolCall, debug: bool) { print_tool_header(call); print_params(&call.arguments, 0, debug); diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs index 817f372ffe20..ff87bcede2a0 100644 --- a/crates/goose-mcp/src/developer/mod.rs +++ b/crates/goose-mcp/src/developer/mod.rs @@ -2291,33 +2291,28 @@ mod tests { assert!(text_editor_tool .description .as_ref() - .map_or(false, |desc| desc - .contains("Replace a string in a file with a new string"))); + .is_some_and(|desc| desc.contains("Replace a string in a file with a new string"))); assert!(text_editor_tool .description .as_ref() - .map_or(false, |desc| desc - .contains("the `old_str` needs to exactly match one"))); + .is_some_and(|desc| desc.contains("the `old_str` needs to exactly match one"))); assert!(text_editor_tool .description .as_ref() - .map_or(false, |desc| desc.contains("str_replace"))); + .is_some_and(|desc| desc.contains("str_replace"))); // Should not contain editor API description or edit_file command assert!(!text_editor_tool .description .as_ref() - .map_or(false, |desc| desc - .contains("Edit the file with the new content"))); + .is_some_and(|desc| desc.contains("Edit the file with the new content"))); assert!(!text_editor_tool .description .as_ref() - .map_or(false, |desc| desc.contains("edit_file"))); - assert!(!text_editor_tool - .description - .as_ref() - .map_or(false, |desc| desc - .contains("work out how to place old_str with it intelligently"))); + .is_some_and(|desc| desc.contains("edit_file"))); + assert!(!text_editor_tool.description.as_ref().is_some_and( + |desc| desc.contains("work out how to place old_str with it intelligently") + )); temp_dir.close().unwrap(); } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 26521dcb6896..9eab644dda75 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -56,6 +56,9 @@ use super::final_output_tool::FinalOutputTool; use super::platform_tools; use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE}; use crate::agents::subagent_task_config::TaskConfig; +use crate::agents::todo_tools::{ + todo_read_tool, todo_write_tool, TODO_READ_TOOL_NAME, TODO_WRITE_TOOL_NAME, +}; use crate::conversation::message::{Message, ToolRequest}; const DEFAULT_MAX_TURNS: u32 = 1000; @@ -97,6 +100,7 @@ pub struct Agent { pub(super) tool_route_manager: ToolRouteManager, pub(super) scheduler_service: Mutex>>, pub(super) retry_manager: RetryManager, + pub(super) todo_list: Arc>, } #[derive(Clone, Debug)] @@ -148,6 +152,15 @@ where } impl Agent { + const DEFAULT_TODO_MAX_CHARS: usize = 50_000; + + fn get_todo_max_chars() -> usize { + std::env::var("GOOSE_TODO_MAX_CHARS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(Self::DEFAULT_TODO_MAX_CHARS) + } + pub fn new() -> Self { // Create channels with buffer size 32 (adjust if needed) let (confirm_tx, confirm_rx) = mpsc::channel(32); @@ -173,6 +186,7 @@ impl Agent { tool_route_manager: ToolRouteManager::new(), scheduler_service: Mutex::new(None), retry_manager, + todo_list: Arc::new(Mutex::new(String::new())), } } @@ -467,6 +481,45 @@ impl Agent { ToolCallResult::from(Err(ToolError::ExecutionError( "Frontend tool execution required".to_string(), ))) + } else if tool_call.name == TODO_READ_TOOL_NAME { + // Handle task planner read tool + let todo_content = self.todo_list.lock().await.clone(); + ToolCallResult::from(Ok(vec![Content::text(todo_content)])) + } else if tool_call.name == TODO_WRITE_TOOL_NAME { + // Handle task planner write tool + let content = tool_call + .arguments + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Acquire lock first to prevent race condition + let mut todo_list = self.todo_list.lock().await; + + // Character limit validation + let char_count = content.chars().count(); + let max_chars = Self::get_todo_max_chars(); + + // Simple validation - reject if over limit (0 means unlimited) + if max_chars > 0 && char_count > max_chars { + return ( + request_id, + Ok(ToolCallResult::from(Err(ToolError::ExecutionError( + format!( + "Todo list too large: {} chars (max: {})", + char_count, max_chars + ), + )))), + ); + } + + *todo_list = content; + + ToolCallResult::from(Ok(vec![Content::text(format!( + "Updated ({} chars)", + char_count + ))])) } else if tool_call.name == ROUTER_VECTOR_SEARCH_TOOL_NAME || tool_call.name == ROUTER_LLM_SEARCH_TOOL_NAME { @@ -684,6 +737,9 @@ impl Agent { platform_tools::manage_schedule_tool(), ]); + // Add task planner tools + prefixed_tools.extend([todo_read_tool(), todo_write_tool()]); + // Dynamic task tool prefixed_tools.push(create_dynamic_task_tool()); @@ -1431,4 +1487,24 @@ mod tests { assert!(system_prompt.contains(&final_output_tool_system_prompt)); Ok(()) } + + #[tokio::test] + async fn test_todo_tools_integration() -> Result<()> { + let agent = Agent::new(); + + // Test that task planner tools are listed + let tools = agent.list_tools(None).await; + + let todo_read = tools.iter().find(|tool| tool.name == TODO_READ_TOOL_NAME); + let todo_write = tools.iter().find(|tool| tool.name == TODO_WRITE_TOOL_NAME); + + assert!(todo_read.is_some(), "TODO read tool should be present"); + assert!(todo_write.is_some(), "TODO write tool should be present"); + + // Test todo_list initialization + let todo_content = agent.todo_list.lock().await; + assert_eq!(*todo_content, "", "TODO list should be initially empty"); + + Ok(()) + } } diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 9cd782f3522e..8fb20fbc15ff 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -17,6 +17,7 @@ pub mod subagent; pub mod subagent_execution_tool; pub mod subagent_handler; mod subagent_task_config; +pub mod todo_tools; mod tool_execution; mod tool_route_manager; mod tool_router_index_manager; diff --git a/crates/goose/src/agents/todo_tools.rs b/crates/goose/src/agents/todo_tools.rs new file mode 100644 index 000000000000..865365441907 --- /dev/null +++ b/crates/goose/src/agents/todo_tools.rs @@ -0,0 +1,160 @@ +use indoc::indoc; +use rmcp::model::{Tool, ToolAnnotations}; +use rmcp::object; + +/// Tool name constant for reading task planner content +pub const TODO_READ_TOOL_NAME: &str = "todo__read"; + +/// Tool name constant for writing task planner content +pub const TODO_WRITE_TOOL_NAME: &str = "todo__write"; + +/// Creates a tool for reading task planner content. +/// +/// This tool reads the entire task planner file content as a string. +/// It is marked as read-only and safe to use repeatedly. +/// +/// # Returns +/// A configured `Tool` instance for reading task planner content +pub fn todo_read_tool() -> Tool { + Tool::new( + TODO_READ_TOOL_NAME.to_string(), + indoc! {r#" + Read the entire TODO file content. + + This tool reads the complete TODO file and returns its content as a string. + Use this to view current tasks, notes, and any other information stored in the TODO file. + + The tool will return an error if the TODO file doesn't exist or cannot be read. + "#} + .to_string(), + object!({ + "type": "object", + "required": [], + "properties": {} + }), + ) + .annotate(ToolAnnotations { + title: Some("Read TODO content".to_string()), + read_only_hint: Some(true), + destructive_hint: Some(false), + idempotent_hint: Some(true), + open_world_hint: Some(false), + }) +} + +/// Creates a tool for writing task planner content. +/// +/// This tool writes or overwrites the entire task planner file with new content. +/// It replaces the complete file content with the provided string. +/// +/// # Returns +/// A configured `Tool` instance for writing task planner content +pub fn todo_write_tool() -> Tool { + Tool::new( + TODO_WRITE_TOOL_NAME.to_string(), + indoc! {r#" + Write or overwrite the entire TODO file content. + + This tool replaces the complete TODO file content with the provided string. + Use this to update tasks, add new items, or reorganize the TODO file. + + WARNING: This operation completely replaces the file content. Make sure to include + all content you want to keep, not just the changes. + + The tool will create the TODO file if it doesn't exist, or overwrite it if it does. + Returns an error if the file cannot be written due to permissions or other I/O issues. + "#} + .to_string(), + object!({ + "type": "object", + "required": ["content"], + "properties": { + "content": { + "type": "string", + "description": "The complete content to write to the TODO file. This will replace all existing content." + } + } + }), + ) + .annotate(ToolAnnotations { + title: Some("Write TODO content".to_string()), + read_only_hint: Some(false), + destructive_hint: Some(true), // It overwrites the entire file + idempotent_hint: Some(true), // Writing the same content multiple times has the same effect + open_world_hint: Some(false), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_todo_read_tool_creation() { + let tool = todo_read_tool(); + + // Verify tool name + assert_eq!(tool.name, TODO_READ_TOOL_NAME); + + // Verify description exists and is not empty + assert!(tool.description.is_some()); + let description = tool.description.as_ref().unwrap(); + assert!(!description.is_empty()); + + // Verify input schema + let schema = &tool.input_schema; + assert_eq!(schema["type"], "object"); + assert_eq!(schema["required"].as_array().unwrap().len(), 0); + + // Verify annotations + let annotations = tool.annotations.as_ref().unwrap(); + assert_eq!(annotations.title, Some("Read TODO content".to_string())); + assert_eq!(annotations.read_only_hint, Some(true)); + assert_eq!(annotations.destructive_hint, Some(false)); + assert_eq!(annotations.idempotent_hint, Some(true)); + assert_eq!(annotations.open_world_hint, Some(false)); + } + + #[test] + fn test_todo_write_tool_creation() { + let tool = todo_write_tool(); + + // Verify tool name + assert_eq!(tool.name, TODO_WRITE_TOOL_NAME); + + // Verify description exists and is not empty + assert!(tool.description.is_some()); + let description = tool.description.as_ref().unwrap(); + assert!(!description.is_empty()); + + // Verify input schema + let schema = &tool.input_schema; + assert_eq!(schema["type"], "object"); + + // Verify required parameters + let required = schema["required"].as_array().unwrap(); + assert_eq!(required.len(), 1); + assert_eq!(required[0], "content"); + + // Verify properties + assert!(schema["properties"]["content"].is_object()); + assert_eq!(schema["properties"]["content"]["type"], "string"); + + // Verify annotations + let annotations = tool.annotations.as_ref().unwrap(); + assert_eq!(annotations.title, Some("Write TODO content".to_string())); + assert_eq!(annotations.read_only_hint, Some(false)); + assert_eq!(annotations.destructive_hint, Some(true)); + assert_eq!(annotations.idempotent_hint, Some(true)); + assert_eq!(annotations.open_world_hint, Some(false)); + } + + #[test] + fn test_tool_name_constants() { + // Verify the constants follow the naming pattern + assert!(TODO_READ_TOOL_NAME.starts_with("todo__")); + assert!(TODO_WRITE_TOOL_NAME.starts_with("todo__")); + assert_eq!(TODO_READ_TOOL_NAME, "todo__read"); + assert_eq!(TODO_WRITE_TOOL_NAME, "todo__write"); + } +} diff --git a/crates/goose/src/prompts/system.md b/crates/goose/src/prompts/system.md index 2681696a0dbc..1bcf22993c72 100644 --- a/crates/goose/src/prompts/system.md +++ b/crates/goose/src/prompts/system.md @@ -38,6 +38,23 @@ No extensions are defined. You should let the user know that they should add ext {{tool_selection_strategy}} +# Task Management + +- Required — use `todo__read` and `todo__write` for any task with 2+ steps, multiple files/components, or uncertain scope. Skipping them is an error. +- Start — `todo__read`, then `todo__write` a brief checklist (Markdown checkboxes). +- During — after each major action, update via `todo__write`: mark done, add/edit items, note blockers/dependencies. +- Finish — ensure every item is checked, or clearly list what remains. +- Overwrite warning — `todo__write` replaces the entire list; always read before writing. It is an error to not read before writing. +- Quality — keep items short, specific, and action‑oriented. + +Template: +```markdown +- [ ] Implement feature X + - [ ] Update API + - [ ] Write tests +- [ ] Blocked: waiting on credentials +``` + # Response Guidelines - Use Markdown formatting for all responses. diff --git a/crates/goose/tests/todo_tools_test.rs b/crates/goose/tests/todo_tools_test.rs new file mode 100644 index 000000000000..078d54b29384 --- /dev/null +++ b/crates/goose/tests/todo_tools_test.rs @@ -0,0 +1,528 @@ +use goose::agents::todo_tools::{TODO_READ_TOOL_NAME, TODO_WRITE_TOOL_NAME}; +use goose::agents::Agent; +use mcp_core::tool::ToolCall; +use serde_json::json; +use serial_test::serial; +use std::sync::Arc; + +#[tokio::test] +async fn test_todo_tools_in_agent_list() { + let agent = Agent::new(); + let tools = agent.list_tools(None).await; + + // Check that todo tools are present + let todo_read = tools.iter().find(|t| t.name == TODO_READ_TOOL_NAME); + let todo_write = tools.iter().find(|t| t.name == TODO_WRITE_TOOL_NAME); + + assert!( + todo_read.is_some(), + "Todo read tool should be in agent's tool list" + ); + assert!( + todo_write.is_some(), + "Todo write tool should be in agent's tool list" + ); +} + +#[tokio::test] +#[serial] +async fn test_todo_write_and_read() { + // Ensure we have a clean environment for this test + std::env::remove_var("GOOSE_TODO_MAX_CHARS"); + + let agent = Agent::new(); + + // Write to the todo list + let write_call = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": "1. Buy milk\n2. Walk the dog\n3. Review code" + }), + }; + + let (_, write_result) = agent + .dispatch_tool_call(write_call, "test-write-1".to_string(), None) + .await; + assert!(write_result.is_ok(), "Write should succeed"); + + // Read from the todo list + let read_call = ToolCall { + name: TODO_READ_TOOL_NAME.to_string(), + arguments: json!({}), + }; + + let (_, read_result) = agent + .dispatch_tool_call(read_call, "test-read-1".to_string(), None) + .await; + assert!(read_result.is_ok(), "Read should succeed"); + + // Verify the content matches what we wrote + if let Ok(result) = read_result { + let content_future = result.result; + let content_result = content_future.await; + + if let Ok(contents) = content_result { + assert!(!contents.is_empty(), "Should have content"); + let text = contents[0].as_text().map(|t| t.text.as_str()).unwrap_or(""); + assert_eq!(text, "1. Buy milk\n2. Walk the dog\n3. Review code"); + } else { + panic!("Failed to get content from read result"); + } + } +} + +#[tokio::test] +async fn test_todo_empty_initially() { + let agent = Agent::new(); + + // Read from empty todo list + let read_call = ToolCall { + name: TODO_READ_TOOL_NAME.to_string(), + arguments: json!({}), + }; + + let (_, read_result) = agent + .dispatch_tool_call(read_call, "test-read-empty".to_string(), None) + .await; + assert!(read_result.is_ok(), "Read should succeed even when empty"); + + if let Ok(result) = read_result { + let content_future = result.result; + let content_result = content_future.await; + + if let Ok(contents) = content_result { + assert!(!contents.is_empty(), "Should have content"); + let text = contents[0].as_text().map(|t| t.text.as_str()).unwrap_or(""); + assert_eq!(text, "", "Empty todo list should return empty string"); + } + } +} + +#[tokio::test] +#[serial] +async fn test_todo_overwrite() { + // Ensure no limit is set for this test + std::env::remove_var("GOOSE_TODO_MAX_CHARS"); + + let agent = Agent::new(); + + // Write initial content + let write_call1 = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": "Initial todo list" + }), + }; + let (_, write_result1) = agent + .dispatch_tool_call(write_call1, "test-write-1".to_string(), None) + .await; + assert!(write_result1.is_ok(), "First write should succeed"); + + // Overwrite with new content + let write_call2 = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": "Completely new todo list" + }), + }; + let (_, write_result2) = agent + .dispatch_tool_call(write_call2, "test-write-2".to_string(), None) + .await; + assert!(write_result2.is_ok(), "Second write should succeed"); + + // Read and verify it was overwritten + let read_call = ToolCall { + name: TODO_READ_TOOL_NAME.to_string(), + arguments: json!({}), + }; + + let (_, read_result) = agent + .dispatch_tool_call(read_call, "test-read-2".to_string(), None) + .await; + + if let Ok(result) = read_result { + let content_future = result.result; + let content_result = content_future.await; + + if let Ok(contents) = content_result { + let text = contents[0].as_text().map(|t| t.text.as_str()).unwrap_or(""); + assert_eq!( + text, "Completely new todo list", + "Content should be overwritten" + ); + } + } +} + +#[tokio::test] +async fn test_todo_concurrent_access() { + let agent = Arc::new(Agent::new()); + + // Spawn multiple concurrent writes + let mut handles = vec![]; + + for i in 0..10 { + let agent_clone = agent.clone(); + let handle = tokio::spawn(async move { + let write_call = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": format!("Todo list {}", i) + }), + }; + agent_clone + .dispatch_tool_call(write_call, format!("concurrent-{}", i), None) + .await + }); + handles.push(handle); + } + + // Wait for all writes to complete + for handle in handles { + let _ = handle.await.unwrap(); + } + + // Read the final state + let read_call = ToolCall { + name: TODO_READ_TOOL_NAME.to_string(), + arguments: json!({}), + }; + + let (_, read_result) = agent + .dispatch_tool_call(read_call, "final-read".to_string(), None) + .await; + + if let Ok(result) = read_result { + let content_future = result.result; + let content_result = content_future.await; + + if let Ok(contents) = content_result { + let text = contents[0].as_text().map(|t| t.text.as_str()).unwrap_or(""); + // The last write wins - we just verify it's one of the valid values + assert!( + text.starts_with("Todo list "), + "Should have valid todo content" + ); + } + } +} + +#[tokio::test] +#[serial] +async fn test_todo_large_content() { + // Ensure we have a clean environment for this test + std::env::remove_var("GOOSE_TODO_MAX_CHARS"); + + let agent = Agent::new(); + + // Create a large todo list that exceeds the 50,000 character limit + let large_content = "X".repeat(100_000); + + let write_call = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": large_content.clone() + }), + }; + + let (_, write_result) = agent + .dispatch_tool_call(write_call, "large-write".to_string(), None) + .await; + + // Should fail because it exceeds the 50,000 character limit + if let Ok(result) = write_result { + let response = result.result.await; + assert!( + response.is_err(), + "Should fail with error for content exceeding limit" + ); + if let Err(error) = response { + let error_str = error.to_string(); + assert!(error_str.contains("Todo list too large")); + assert!(error_str.contains("100000 chars")); + assert!(error_str.contains("max: 50000")); + } + } else { + panic!("Expected Ok(ToolCallResult) with inner error, got Err"); + } + + // Test with content within the limit + let valid_content = "X".repeat(50_000); + + let write_call = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": valid_content.clone() + }), + }; + + let (_, write_result) = agent + .dispatch_tool_call(write_call, "valid-write".to_string(), None) + .await; + assert!(write_result.is_ok(), "Should handle content within limit"); + + // Read it back + let read_call = ToolCall { + name: TODO_READ_TOOL_NAME.to_string(), + arguments: json!({}), + }; + + let (_, read_result) = agent + .dispatch_tool_call(read_call, "valid-read".to_string(), None) + .await; + + if let Ok(result) = read_result { + let content_future = result.result; + let content_result = content_future.await; + + if let Ok(contents) = content_result { + let text = contents[0].as_text().map(|t| t.text.as_str()).unwrap_or(""); + assert_eq!( + text.len(), + valid_content.len(), + "Valid content should be preserved" + ); + } + } +} + +#[tokio::test] +#[serial] +async fn test_todo_unicode_content() { + // Ensure no limit is set for this test + std::env::remove_var("GOOSE_TODO_MAX_CHARS"); + + let agent = Agent::new(); + + let unicode_content = "📝 Todo List:\n✅ Task 1\n⭐ Task 2\n🔥 Urgent: Task 3\n日本語のタスク"; + + let write_call = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": unicode_content + }), + }; + + let (_, write_result) = agent + .dispatch_tool_call(write_call, "unicode-write".to_string(), None) + .await; + assert!(write_result.is_ok(), "Write should succeed"); + + let read_call = ToolCall { + name: TODO_READ_TOOL_NAME.to_string(), + arguments: json!({}), + }; + + let (_, read_result) = agent + .dispatch_tool_call(read_call, "unicode-read".to_string(), None) + .await; + + if let Ok(result) = read_result { + let content_future = result.result; + let content_result = content_future.await; + + if let Ok(contents) = content_result { + let text = contents[0].as_text().map(|t| t.text.as_str()).unwrap_or(""); + assert_eq!(text, unicode_content, "Unicode content should be preserved"); + } + } +} + +#[tokio::test] +#[serial] +async fn test_todo_character_limit_enforcement() { + // Set a small limit for testing + std::env::set_var("GOOSE_TODO_MAX_CHARS", "100"); + + // Create agent AFTER setting the environment variable + let agent = Agent::new(); + + // Create content that exceeds the limit + let large_content = "x".repeat(101); + + let write_call = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": large_content + }), + }; + + let (_, result) = agent + .dispatch_tool_call(write_call, "test-limit".to_string(), None) + .await; + + // Should fail with error + assert!(result.is_ok(), "dispatch_tool_call should return Ok"); + if let Ok(result) = result { + let response = result.result.await; + assert!(response.is_err(), "Should fail with error"); + if let Err(error) = response { + let error_str = error.to_string(); + assert!( + error_str.contains("Todo list too large"), + "Error should mention 'Todo list too large'" + ); + assert!( + error_str.contains("101 chars"), + "Error should mention '101 chars'" + ); + assert!( + error_str.contains("max: 100"), + "Error should mention 'max: 100'" + ); + } + } + + // Clean up + std::env::remove_var("GOOSE_TODO_MAX_CHARS"); +} + +#[tokio::test] +#[serial] +async fn test_todo_character_count_in_write_response() { + // Ensure no limit is set for this test + std::env::remove_var("GOOSE_TODO_MAX_CHARS"); + + let agent = Agent::new(); + + let content = "Test todo content"; + let write_call = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": content + }), + }; + + let (_, result) = agent + .dispatch_tool_call(write_call, "test-count".to_string(), None) + .await; + + assert!(result.is_ok()); + if let Ok(tool_result) = result { + let response = tool_result.result.await.unwrap(); + let text = response[0].as_text().unwrap().text.clone(); + assert!(text.contains("Updated (17 chars)")); // "Test todo content" is 17 chars + } +} + +#[tokio::test] +#[serial] +async fn test_todo_read_returns_clean_content() { + // Ensure no limit is set for this test + std::env::remove_var("GOOSE_TODO_MAX_CHARS"); + + let agent = Agent::new(); + + // Write some content + let content = "My todo list\n- Task 1\n- Task 2"; + let write_call = ToolCall { + name: TODO_WRITE_TOOL_NAME.to_string(), + arguments: json!({ + "content": content + }), + }; + + let (_, write_result) = agent + .dispatch_tool_call(write_call, "test-write".to_string(), None) + .await; + assert!(write_result.is_ok(), "Write should succeed"); + + // Read should return exact content, no metadata + let read_call = ToolCall { + name: TODO_READ_TOOL_NAME.to_string(), + arguments: json!({}), + }; + + let (_, result) = agent + .dispatch_tool_call(read_call, "test-read".to_string(), None) + .await; + + assert!(result.is_ok()); + if let Ok(tool_result) = result { + let response = tool_result.result.await.unwrap(); + let text = response[0].as_text().unwrap().text.clone(); + + // Should be exactly the original content + assert_eq!(text, content); + // Should NOT contain any metadata + assert!(!text.contains("chars")); + assert!(!text.contains("