To-Do Tools (#3902)

Co-authored-by: David Katz <dkatz@squareup.com>
This commit is contained in:
tlongwell-block
2025-08-11 15:57:17 -04:00
committed by GitHub
parent 99a40bd8d1
commit 566b9dc577
7 changed files with 804 additions and 13 deletions
+14
View File
@@ -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);
+8 -13
View File
@@ -2292,33 +2292,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();
}
+76
View File
@@ -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<Option<Arc<dyn SchedulerTrait>>>,
pub(super) retry_manager: RetryManager,
pub(super) todo_list: Arc<Mutex<String>>,
}
#[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(())
}
}
+1
View File
@@ -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;
+160
View File
@@ -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");
}
}
+17
View File
@@ -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 actionoriented.
Template:
```markdown
- [ ] Implement feature X
- [ ] Update API
- [ ] Write tests
- [ ] Blocked: waiting on credentials
```
# Response Guidelines
- Use Markdown formatting for all responses.
+528
View File
@@ -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("<!--"));
}
}
#[tokio::test]
#[serial]
async fn test_todo_unlimited_with_zero_limit() {
std::env::set_var("GOOSE_TODO_MAX_CHARS", "0");
let agent = Agent::new();
// Should accept very large content when limit is 0
let huge_content = "x".repeat(100_000);
let write_call = ToolCall {
name: TODO_WRITE_TOOL_NAME.to_string(),
arguments: json!({
"content": huge_content
}),
};
let (_, result) = agent
.dispatch_tool_call(write_call, "test-unlimited".to_string(), None)
.await;
// Should succeed
assert!(result.is_ok());
std::env::remove_var("GOOSE_TODO_MAX_CHARS");
}
#[tokio::test]
#[serial]
async fn test_todo_unicode_character_counting() {
std::env::set_var("GOOSE_TODO_MAX_CHARS", "10");
// Create agent AFTER setting the environment variable
let agent = Agent::new();
// Test with emoji - each emoji is 1 character in .chars().count()
let content = "📝📝📝📝📝📝📝📝📝📝📝"; // 11 emoji = 11 chars
let write_call = ToolCall {
name: TODO_WRITE_TOOL_NAME.to_string(),
arguments: json!({
"content": content
}),
};
let (_, result) = agent
.dispatch_tool_call(write_call, "test-unicode".to_string(), None)
.await;
// Should fail as it's 11 chars
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 - 11 chars exceeds limit of 10"
);
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("11 chars"),
"Error should mention '11 chars'"
);
assert!(
error_str.contains("max: 10"),
"Error should mention 'max: 10'"
);
}
}
std::env::remove_var("GOOSE_TODO_MAX_CHARS");
}