add context window monitor

Writes the current context window to logs/current_context_window (uses a symlink to a session ID).

This PR was unfortunately generated by a different LLM and did a ton of superficial reformating, it's actually a fairly small and benign change, but I don't want to roll back everything. Hope that's ok.
This commit is contained in:
Jochen
2025-11-27 21:00:02 +11:00
parent 93dc4acf86
commit 52f78653b4
89 changed files with 4040 additions and 2576 deletions

View File

@@ -8,7 +8,7 @@ async fn test_find_async_functions() {
// Create a temporary test file
let test_dir = std::env::temp_dir().join("g3_test_code_search");
fs::create_dir_all(&test_dir).unwrap();
let test_file = test_dir.join("test.rs");
fs::write(
&test_file,
@@ -47,7 +47,10 @@ pub async fn another_async(x: i32) -> Result<(), ()> {
assert_eq!(response.searches.len(), 1);
let search_result = &response.searches[0];
assert_eq!(search_result.name, "find_async_functions");
assert_eq!(search_result.match_count, 2, "Should find 2 async functions");
assert_eq!(
search_result.match_count, 2,
"Should find 2 async functions"
);
assert!(search_result.error.is_none());
// Check that we found the right functions
@@ -69,7 +72,7 @@ async fn test_find_all_functions() {
// Create a temporary test file
let test_dir = std::env::temp_dir().join("g3_test_code_search_2");
fs::create_dir_all(&test_dir).unwrap();
let test_file = test_dir.join("test.rs");
fs::write(
&test_file,
@@ -107,7 +110,10 @@ pub async fn another_async(x: i32) -> Result<(), ()> {
assert_eq!(response.searches.len(), 1);
let search_result = &response.searches[0];
assert_eq!(search_result.name, "find_all_functions");
assert_eq!(search_result.match_count, 3, "Should find 3 functions total");
assert_eq!(
search_result.match_count, 3,
"Should find 3 functions total"
);
assert!(search_result.error.is_none());
// Check that we found all functions
@@ -130,7 +136,7 @@ async fn test_find_structs() {
// Create a temporary test file
let test_dir = std::env::temp_dir().join("g3_test_code_search_3");
fs::create_dir_all(&test_dir).unwrap();
let test_file = test_dir.join("test.rs");
fs::write(
&test_file,
@@ -188,7 +194,7 @@ async fn test_context_lines() {
// Create a temporary test file
let test_dir = std::env::temp_dir().join("g3_test_code_search_4");
fs::create_dir_all(&test_dir).unwrap();
let test_file = test_dir.join("test.rs");
fs::write(
&test_file,
@@ -223,16 +229,22 @@ pub fn target_function() {
assert_eq!(response.searches.len(), 1);
let search_result = &response.searches[0];
assert_eq!(search_result.match_count, 1);
let match_result = &search_result.matches[0];
assert!(match_result.context.is_some());
let context = match_result.context.as_ref().unwrap();
assert!(context.contains("Line 2"), "Should include 2 lines before");
assert!(context.contains("target_function"), "Should include the function");
assert!(
context.contains("target_function"),
"Should include the function"
);
// Note: context_lines=2 means 2 lines before and after the match line (line 4)
// So we get lines 2-6, which includes up to println but not the closing brace
assert!(context.contains("println"), "Should include 2 lines after the match");
assert!(
context.contains("println"),
"Should include 2 lines after the match"
);
// Cleanup
fs::remove_dir_all(&test_dir).ok();
@@ -243,7 +255,7 @@ async fn test_multiple_searches() {
// Create a temporary test file
let test_dir = std::env::temp_dir().join("g3_test_code_search_5");
fs::create_dir_all(&test_dir).unwrap();
let test_file = test_dir.join("test.rs");
fs::write(
&test_file,
@@ -301,7 +313,7 @@ async fn test_python_search() {
// Create a temporary Python test file
let test_dir = std::env::temp_dir().join("g3_test_code_search_python");
fs::create_dir_all(&test_dir).unwrap();
let test_file = test_dir.join("test.py");
fs::write(
&test_file,
@@ -338,14 +350,17 @@ class MyClass:
assert_eq!(response.searches.len(), 1);
let search_result = &response.searches[0];
assert_eq!(search_result.match_count, 3, "Should find 3 functions in Python (2 regular + 1 async + 1 method)");
assert_eq!(
search_result.match_count, 3,
"Should find 3 functions in Python (2 regular + 1 async + 1 method)"
);
let function_names: Vec<String> = search_result
.matches
.iter()
.filter_map(|m| m.captures.get("name").cloned())
.collect();
assert!(function_names.contains(&"regular_function".to_string()));
assert!(function_names.contains(&"async_function".to_string()));
assert!(function_names.contains(&"method".to_string()));
@@ -359,7 +374,7 @@ async fn test_javascript_search() {
// Create a temporary JavaScript test file
let test_dir = std::env::temp_dir().join("g3_test_code_search_js");
fs::create_dir_all(&test_dir).unwrap();
let test_file = test_dir.join("test.js");
fs::write(
&test_file,
@@ -396,14 +411,17 @@ class MyClass {
assert_eq!(response.searches.len(), 1);
let search_result = &response.searches[0];
assert_eq!(search_result.match_count, 2, "Should find 2 functions in JavaScript");
assert_eq!(
search_result.match_count, 2,
"Should find 2 functions in JavaScript"
);
let function_names: Vec<String> = search_result
.matches
.iter()
.filter_map(|m| m.captures.get("name").cloned())
.collect();
assert!(function_names.contains(&"regularFunction".to_string()));
assert!(function_names.contains(&"asyncFunction".to_string()));
@@ -420,7 +438,7 @@ async fn test_go_search() {
.and_then(|p| p.parent())
.unwrap();
let test_code_path = workspace_root.join("examples/test_code");
let request = CodeSearchRequest {
searches: vec![SearchSpec {
name: "go_functions".to_string(),
@@ -435,14 +453,19 @@ async fn test_go_search() {
let response = execute_code_search(request).await.unwrap();
assert_eq!(response.searches.len(), 1);
eprintln!("Go search result: {:?}", response.searches[0]);
eprintln!("Match count: {}", response.searches[0].matches.len());
eprintln!("Error: {:?}", response.searches[0].error);
assert!(response.searches[0].matches.len() > 0, "No matches found for Go search");
assert!(
response.searches[0].matches.len() > 0,
"No matches found for Go search"
);
// Should find main and greet functions
let names: Vec<&str> = response.searches[0].matches.iter()
let names: Vec<&str> = response.searches[0]
.matches
.iter()
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
.collect();
assert!(names.contains(&"main"));
@@ -458,7 +481,7 @@ async fn test_java_search() {
.and_then(|p| p.parent())
.unwrap();
let test_code_path = workspace_root.join("examples/test_code");
let request = CodeSearchRequest {
searches: vec![SearchSpec {
name: "java_classes".to_string(),
@@ -474,9 +497,11 @@ async fn test_java_search() {
let response = execute_code_search(request).await.unwrap();
assert_eq!(response.searches.len(), 1);
assert!(response.searches[0].matches.len() > 0);
// Should find Example class
let names: Vec<&str> = response.searches[0].matches.iter()
let names: Vec<&str> = response.searches[0]
.matches
.iter()
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
.collect();
assert!(names.contains(&"Example"));
@@ -491,7 +516,7 @@ async fn test_c_search() {
.and_then(|p| p.parent())
.unwrap();
let test_code_path = workspace_root.join("examples/test_code");
let request = CodeSearchRequest {
searches: vec![SearchSpec {
name: "c_functions".to_string(),
@@ -507,9 +532,11 @@ async fn test_c_search() {
let response = execute_code_search(request).await.unwrap();
assert_eq!(response.searches.len(), 1);
assert!(response.searches[0].matches.len() > 0);
// Should find greet, add, and main functions
let names: Vec<&str> = response.searches[0].matches.iter()
let names: Vec<&str> = response.searches[0]
.matches
.iter()
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
.collect();
assert!(names.contains(&"greet"));
@@ -526,7 +553,7 @@ async fn test_cpp_search() {
.and_then(|p| p.parent())
.unwrap();
let test_code_path = workspace_root.join("examples/test_code");
let request = CodeSearchRequest {
searches: vec![SearchSpec {
name: "cpp_classes".to_string(),
@@ -542,9 +569,11 @@ async fn test_cpp_search() {
let response = execute_code_search(request).await.unwrap();
assert_eq!(response.searches.len(), 1);
assert!(response.searches[0].matches.len() > 0);
// Should find Person class
let names: Vec<&str> = response.searches[0].matches.iter()
let names: Vec<&str> = response.searches[0]
.matches
.iter()
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
.collect();
assert!(names.contains(&"Person"));
@@ -568,9 +597,11 @@ async fn test_kotlin_search() {
let response = execute_code_search(request).await.unwrap();
assert_eq!(response.searches.len(), 1);
assert!(response.searches[0].matches.len() > 0);
// Should find Person class
let names: Vec<&str> = response.searches[0].matches.iter()
let names: Vec<&str> = response.searches[0]
.matches
.iter()
.filter_map(|m| m.captures.get("name").map(|s| s.as_str()))
.collect();
assert!(names.contains(&"Person"));

View File

@@ -4,35 +4,35 @@ use g3_providers::{Message, MessageRole};
#[test]
fn test_thinning_thresholds() {
let mut context = ContextWindow::new(10000);
// At 0%, should not thin
assert!(!context.should_thin());
// Simulate reaching 50% usage
context.used_tokens = 5000;
assert!(context.should_thin());
// After thinning at 50%, should not thin again until next threshold
context.last_thinning_percentage = 50;
assert!(!context.should_thin());
// At 60%, should thin again
context.used_tokens = 6000;
assert!(context.should_thin());
// After thinning at 60%, should not thin
context.last_thinning_percentage = 60;
assert!(!context.should_thin());
// At 70%, should thin
context.used_tokens = 7000;
assert!(context.should_thin());
// At 80%, should thin
context.last_thinning_percentage = 70;
context.used_tokens = 8000;
assert!(context.should_thin());
// After 80%, should not thin (compaction takes over)
context.last_thinning_percentage = 80;
context.used_tokens = 8500;
@@ -42,7 +42,7 @@ fn test_thinning_thresholds() {
#[test]
fn test_thin_context_basic() {
let mut context = ContextWindow::new(10000);
// Add some messages to the first third
for i in 0..9 {
if i % 2 == 0 {
@@ -62,24 +62,25 @@ fn test_thin_context_basic() {
// Small tool result (< 1000 chars)
format!("Tool result: small result {}", i)
};
context.add_message(Message::new(
MessageRole::User,
content,
));
context.add_message(Message::new(MessageRole::User, content));
}
}
// Trigger thinning at 50%
context.used_tokens = 5000;
let (summary, _chars_saved) = context.thin_context();
println!("Thinning summary: {}", summary);
// Should have thinned at least 1 large tool result in the first third
assert!(summary.contains("1 tool result"), "Summary was: {}", summary);
assert!(
summary.contains("1 tool result"),
"Summary was: {}",
summary
);
assert!(summary.contains("50%"));
// Check that the large tool results were replaced
let first_third_end = context.conversation_history.len() / 3;
for i in 0..first_third_end {
@@ -96,13 +97,13 @@ fn test_thin_context_basic() {
#[test]
fn test_thin_write_file_tool_calls() {
let mut context = ContextWindow::new(10000);
// Add some messages including a write_file tool call with large content
context.add_message(Message::new(
MessageRole::User,
"Please create a large file".to_string(),
));
// Add an assistant message with a write_file tool call containing large content
let large_content = "x".repeat(1500);
let tool_call_json = format!(
@@ -113,12 +114,12 @@ fn test_thin_write_file_tool_calls() {
MessageRole::Assistant,
format!("I'll create that file.\n\n{}", tool_call_json),
));
context.add_message(Message::new(
MessageRole::User,
"Tool result: ✅ Successfully wrote 1500 lines".to_string(),
));
// Add more messages to ensure we have enough for "first third" logic
for i in 0..6 {
context.add_message(Message::new(
@@ -126,16 +127,16 @@ fn test_thin_write_file_tool_calls() {
format!("Response {}", i),
));
}
// Trigger thinning at 50%
context.used_tokens = 5000;
let (summary, _chars_saved) = context.thin_context();
println!("Thinning summary: {}", summary);
// Should have thinned the write_file tool call
assert!(summary.contains("tool call") || summary.contains("chars saved"));
// Check that the large content was replaced with a file reference
let first_third_end = context.conversation_history.len() / 3;
for i in 0..first_third_end {
@@ -152,15 +153,19 @@ fn test_thin_write_file_tool_calls() {
#[test]
fn test_thin_str_replace_tool_calls() {
let mut context = ContextWindow::new(10000);
// Add some messages including a str_replace tool call with large diff
context.add_message(Message::new(
MessageRole::User,
"Please update the file".to_string(),
));
// Add an assistant message with a str_replace tool call containing large diff
let large_diff = format!("--- old\n{}\n+++ new\n{}", "-old line\n".repeat(100), "+new line\n".repeat(100));
let large_diff = format!(
"--- old\n{}\n+++ new\n{}",
"-old line\n".repeat(100),
"+new line\n".repeat(100)
);
let tool_call_json = format!(
r#"{{"tool": "str_replace", "args": {{"file_path": "test.txt", "diff": "{}"}}}}"#,
large_diff.replace('\n', "\\n")
@@ -169,12 +174,12 @@ fn test_thin_str_replace_tool_calls() {
MessageRole::Assistant,
format!("I'll update that file.\n\n{}", tool_call_json),
));
context.add_message(Message::new(
MessageRole::User,
"Tool result: ✅ applied unified diff".to_string(),
));
// Add more messages to ensure we have enough for "first third" logic
for i in 0..6 {
context.add_message(Message::new(
@@ -182,16 +187,16 @@ fn test_thin_str_replace_tool_calls() {
format!("Response {}", i),
));
}
// Trigger thinning at 50%
context.used_tokens = 5000;
let (summary, _chars_saved) = context.thin_context();
println!("Thinning summary: {}", summary);
// Should have thinned the str_replace tool call
assert!(summary.contains("tool call") || summary.contains("chars saved"));
// Check that the large diff was replaced with a file reference
let first_third_end = context.conversation_history.len() / 3;
for i in 0..first_third_end {
@@ -209,7 +214,7 @@ fn test_thin_str_replace_tool_calls() {
#[test]
fn test_thin_context_no_large_results() {
let mut context = ContextWindow::new(10000);
// Add only small messages
for i in 0..9 {
context.add_message(Message::new(
@@ -217,10 +222,10 @@ fn test_thin_context_no_large_results() {
format!("Tool result: small {}", i),
));
}
context.used_tokens = 5000;
let (summary, _chars_saved) = context.thin_context();
// Should report no large results found
assert!(summary.contains("no large tool results or tool calls found"));
}
@@ -228,7 +233,7 @@ fn test_thin_context_no_large_results() {
#[test]
fn test_thin_context_only_affects_first_third() {
let mut context = ContextWindow::new(10000);
// Add 12 messages (first third = 4 messages)
for i in 0..12 {
let content = if i % 2 == 1 {
@@ -237,23 +242,23 @@ fn test_thin_context_only_affects_first_third() {
} else {
format!("Assistant message {}", i)
};
let role = if i % 2 == 1 {
MessageRole::User
} else {
MessageRole::Assistant
};
context.add_message(Message::new(role, content));
}
context.used_tokens = 5000;
let (summary, _chars_saved) = context.thin_context();
// First third is 4 messages (indices 0-3), so only indices 1 and 3 should be thinned
// That's 2 tool results
assert!(summary.contains("2 tool results"));
// Check that messages after the first third are NOT thinned
let first_third_end = context.conversation_history.len() / 3;
for i in first_third_end..context.conversation_history.len() {
@@ -261,8 +266,11 @@ fn test_thin_context_only_affects_first_third() {
if matches!(msg.role, MessageRole::User) && msg.content.starts_with("Tool result:") {
// These should still be large (not thinned)
if i % 2 == 1 {
assert!(msg.content.len() > 1000,
"Message at index {} should not have been thinned", i);
assert!(
msg.content.len() > 1000,
"Message at index {} should not have been thinned",
i
);
}
}
}

View File

@@ -6,28 +6,34 @@ use serial_test::serial;
#[serial]
fn test_todo_read_results_not_thinned() {
let mut context = ContextWindow::new(10000);
// Add a todo_read tool call
context.add_message(Message::new(MessageRole::Assistant, r#"{"tool": "todo_read", "args": {}}"#.to_string()));
context.add_message(Message::new(
MessageRole::Assistant,
r#"{"tool": "todo_read", "args": {}}"#.to_string(),
));
// Add a large TODO result (> 500 chars)
let large_todo_result = format!(
"Tool result: 📝 TODO list:\n{}",
"- [ ] Task with long description\n".repeat(50)
);
context.add_message(Message::new(MessageRole::User, large_todo_result.clone()));
// Add more messages to ensure we have enough for "first third" logic
for i in 0..6 {
context.add_message(Message::new(MessageRole::Assistant, format!("Response {}", i)))
context.add_message(Message::new(
MessageRole::Assistant,
format!("Response {}", i),
))
}
// Trigger thinning at 50%
context.used_tokens = 5000;
let (summary, _chars_saved) = context.thin_context();
println!("Thinning summary: {}", summary);
// Check that the TODO result was NOT thinned
let first_third_end = context.conversation_history.len() / 3;
for i in 0..first_third_end {
@@ -53,29 +59,38 @@ fn test_todo_read_results_not_thinned() {
#[serial]
fn test_todo_write_results_not_thinned() {
let mut context = ContextWindow::new(10000);
// Add a todo_write tool call
let large_content = "- [ ] Task\n".repeat(100);
context.add_message(Message::new(MessageRole::Assistant, format!(r#"{{"tool": "todo_write", "args": {{"content": "{}"}}}}"#, large_content)));
context.add_message(Message::new(
MessageRole::Assistant,
format!(
r#"{{"tool": "todo_write", "args": {{"content": "{}"}}}}"#,
large_content
),
));
// Add a large TODO write result
let large_todo_result = format!(
"Tool result: ✅ TODO list updated ({} chars) and saved to todo.g3.md",
large_content.len()
);
context.add_message(Message::new(MessageRole::User, large_todo_result.clone()));
// Add more messages
for i in 0..6 {
context.add_message(Message::new(MessageRole::Assistant, format!("Response {}", i)))
context.add_message(Message::new(
MessageRole::Assistant,
format!("Response {}", i),
))
}
// Trigger thinning at 50%
context.used_tokens = 5000;
let (summary, _chars_saved) = context.thin_context();
println!("Thinning summary: {}", summary);
// Check that the TODO write result was NOT thinned
let first_third_end = context.conversation_history.len() / 3;
for i in 0..first_third_end {
@@ -99,31 +114,37 @@ fn test_todo_write_results_not_thinned() {
#[serial]
fn test_non_todo_results_still_thinned() {
let mut context = ContextWindow::new(10000);
// Add a non-TODO tool call (e.g., read_file)
context.add_message(Message::new(MessageRole::Assistant, r#"{"tool": "read_file", "args": {"file_path": "test.txt"}}"#.to_string()));
context.add_message(Message::new(
MessageRole::Assistant,
r#"{"tool": "read_file", "args": {"file_path": "test.txt"}}"#.to_string(),
));
// Add a large read_file result (> 500 chars)
let large_result = format!("Tool result: {}", "x".repeat(1500));
context.add_message(Message::new(MessageRole::User, large_result));
// Add more messages
for i in 0..6 {
context.add_message(Message::new(MessageRole::Assistant, format!("Response {}", i)))
context.add_message(Message::new(
MessageRole::Assistant,
format!("Response {}", i),
))
}
// Trigger thinning at 50%
context.used_tokens = 5000;
let (summary, _chars_saved) = context.thin_context();
println!("Thinning summary: {}", summary);
// Should have thinned the non-TODO result
assert!(
summary.contains("1 tool result") || summary.contains("chars saved"),
"Non-TODO results should be thinned"
);
// Check that the result was actually thinned
let first_third_end = context.conversation_history.len() / 3;
for i in 0..first_third_end {
@@ -143,26 +164,29 @@ fn test_non_todo_results_still_thinned() {
#[serial]
fn test_todo_read_with_spaces_in_tool_name() {
let mut context = ContextWindow::new(10000);
// Add a todo_read tool call with spaces (JSON formatting variation)
context.add_message(Message::new(MessageRole::Assistant, r#"{"tool": "todo_read", "args": {}}"#.to_string()));
context.add_message(Message::new(
MessageRole::Assistant,
r#"{"tool": "todo_read", "args": {}}"#.to_string(),
));
// Add a large TODO result
let large_todo_result = format!(
"Tool result: 📝 TODO list:\n{}",
"- [ ] Task\n".repeat(50)
);
let large_todo_result = format!("Tool result: 📝 TODO list:\n{}", "- [ ] Task\n".repeat(50));
context.add_message(Message::new(MessageRole::User, large_todo_result.clone()));
// Add more messages
for i in 0..6 {
context.add_message(Message::new(MessageRole::Assistant, format!("Response {}", i)))
context.add_message(Message::new(
MessageRole::Assistant,
format!("Response {}", i),
))
}
// Trigger thinning
context.used_tokens = 5000;
let (_summary, _chars_saved) = context.thin_context();
// Verify TODO result was not thinned
let first_third_end = context.conversation_history.len() / 3;
for i in 0..first_third_end {

View File

@@ -1,20 +1,19 @@
use g3_core::Agent;
use g3_core::ui_writer::NullUiWriter;
use g3_core::Agent;
use serial_test::serial;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
/// Helper to create a test agent in a temporary directory
async fn create_test_agent_in_dir(temp_dir: &TempDir) -> Agent<NullUiWriter> {
// Change to temp directory
std::env::set_current_dir(temp_dir.path()).unwrap();
// Create a minimal config
let config = g3_config::Config::default();
let ui_writer = NullUiWriter;
Agent::new(config, ui_writer).await.unwrap()
}
@@ -29,10 +28,10 @@ async fn test_todo_write_creates_file() {
let temp_dir = TempDir::new().unwrap();
let mut agent = create_test_agent_in_dir(&temp_dir).await;
let todo_path = get_todo_path(&temp_dir);
// Initially, todo.g3.md should not exist
assert!(!todo_path.exists(), "todo.g3.md should not exist initially");
// Create a tool call to write TODO
let tool_call = g3_core::ToolCall {
tool: "todo_write".to_string(),
@@ -40,17 +39,21 @@ async fn test_todo_write_creates_file() {
"content": "- [ ] Task 1\n- [ ] Task 2\n- [x] Task 3"
}),
};
// Execute the tool
let result = agent.execute_tool(&tool_call).await.unwrap();
// Should report success
assert!(result.contains(""), "Should report success: {}", result);
assert!(result.contains("todo.g3.md"), "Should mention todo.g3.md: {}", result);
assert!(
result.contains("todo.g3.md"),
"Should mention todo.g3.md: {}",
result
);
// File should now exist
assert!(todo_path.exists(), "todo.g3.md should exist after write");
// File should contain the correct content
let content = fs::read_to_string(&todo_path).unwrap();
assert_eq!(content, "- [ ] Task 1\n- [ ] Task 2\n- [x] Task 3");
@@ -61,27 +64,39 @@ async fn test_todo_write_creates_file() {
async fn test_todo_read_from_file() {
let temp_dir = TempDir::new().unwrap();
let todo_path = get_todo_path(&temp_dir);
// Pre-create a todo.g3.md file
let test_content = "# My TODO\n\n- [ ] First task\n- [x] Completed task";
fs::write(&todo_path, test_content).unwrap();
// Create agent (should load from file)
let mut agent = create_test_agent_in_dir(&temp_dir).await;
// Create a tool call to read TODO
let tool_call = g3_core::ToolCall {
tool: "todo_read".to_string(),
args: serde_json::json!({}),
};
// Execute the tool
let result = agent.execute_tool(&tool_call).await.unwrap();
// Should contain the TODO content
assert!(result.contains("📝 TODO list:"), "Should have TODO list header: {}", result);
assert!(result.contains("First task"), "Should contain first task: {}", result);
assert!(result.contains("Completed task"), "Should contain completed task: {}", result);
assert!(
result.contains("📝 TODO list:"),
"Should have TODO list header: {}",
result
);
assert!(
result.contains("First task"),
"Should contain first task: {}",
result
);
assert!(
result.contains("Completed task"),
"Should contain completed task: {}",
result
);
}
#[tokio::test]
@@ -89,16 +104,16 @@ async fn test_todo_read_from_file() {
async fn test_todo_read_empty_file() {
let temp_dir = TempDir::new().unwrap();
let mut agent = create_test_agent_in_dir(&temp_dir).await;
// Create a tool call to read TODO (file doesn't exist)
let tool_call = g3_core::ToolCall {
tool: "todo_read".to_string(),
args: serde_json::json!({}),
};
// Execute the tool
let result = agent.execute_tool(&tool_call).await.unwrap();
// Should report empty
assert!(result.contains("empty"), "Should report empty: {}", result);
}
@@ -108,7 +123,7 @@ async fn test_todo_read_empty_file() {
async fn test_todo_persistence_across_agents() {
let temp_dir = TempDir::new().unwrap();
let todo_path = get_todo_path(&temp_dir);
// Agent 1: Write TODO
{
let mut agent = create_test_agent_in_dir(&temp_dir).await;
@@ -120,10 +135,13 @@ async fn test_todo_persistence_across_agents() {
};
agent.execute_tool(&tool_call).await.unwrap();
}
// Verify file exists
assert!(todo_path.exists(), "todo.g3.md should persist after agent drops");
assert!(
todo_path.exists(),
"todo.g3.md should persist after agent drops"
);
// Agent 2: Read TODO (new agent instance)
{
let mut agent = create_test_agent_in_dir(&temp_dir).await;
@@ -132,10 +150,18 @@ async fn test_todo_persistence_across_agents() {
args: serde_json::json!({}),
};
let result = agent.execute_tool(&tool_call).await.unwrap();
// Should read the persisted content
assert!(result.contains("Persistent task"), "Should read persisted task: {}", result);
assert!(result.contains("Done task"), "Should read done task: {}", result);
assert!(
result.contains("Persistent task"),
"Should read persisted task: {}",
result
);
assert!(
result.contains("Done task"),
"Should read done task: {}",
result
);
}
}
@@ -145,7 +171,7 @@ async fn test_todo_update_preserves_file() {
let temp_dir = TempDir::new().unwrap();
let mut agent = create_test_agent_in_dir(&temp_dir).await;
let todo_path = get_todo_path(&temp_dir);
// Write initial TODO
let write_call = g3_core::ToolCall {
tool: "todo_write".to_string(),
@@ -154,7 +180,7 @@ async fn test_todo_update_preserves_file() {
}),
};
agent.execute_tool(&write_call).await.unwrap();
// Update TODO
let update_call = g3_core::ToolCall {
tool: "todo_write".to_string(),
@@ -163,7 +189,7 @@ async fn test_todo_update_preserves_file() {
}),
};
agent.execute_tool(&update_call).await.unwrap();
// Verify file has updated content
let content = fs::read_to_string(&todo_path).unwrap();
assert_eq!(content, "- [x] Task 1\n- [ ] Task 2\n- [ ] Task 3");
@@ -175,23 +201,30 @@ async fn test_todo_handles_large_content() {
let temp_dir = TempDir::new().unwrap();
let mut agent = create_test_agent_in_dir(&temp_dir).await;
let todo_path = get_todo_path(&temp_dir);
// Create a large TODO (but under the 50k limit)
let mut large_content = String::from("# Large TODO\n\n");
for i in 0..100 {
large_content.push_str(&format!("- [ ] Task {} with a long description that exceeds normal line lengths\n", i));
large_content.push_str(&format!(
"- [ ] Task {} with a long description that exceeds normal line lengths\n",
i
));
}
let tool_call = g3_core::ToolCall {
tool: "todo_write".to_string(),
args: serde_json::json!({
"content": large_content
}),
};
let result = agent.execute_tool(&tool_call).await.unwrap();
assert!(result.contains(""), "Should handle large content: {}", result);
assert!(
result.contains(""),
"Should handle large content: {}",
result
);
// Verify file contains all content
let file_content = fs::read_to_string(&todo_path).unwrap();
assert_eq!(file_content, large_content);
@@ -203,22 +236,30 @@ async fn test_todo_handles_large_content() {
async fn test_todo_respects_size_limit() {
let temp_dir = TempDir::new().unwrap();
let mut agent = create_test_agent_in_dir(&temp_dir).await;
// Create content that exceeds the default 50k limit
let huge_content = "x".repeat(60_000);
let tool_call = g3_core::ToolCall {
tool: "todo_write".to_string(),
args: serde_json::json!({
"content": huge_content
}),
};
let result = agent.execute_tool(&tool_call).await.unwrap();
// Should reject content that's too large
assert!(result.contains(""), "Should reject oversized content: {}", result);
assert!(result.contains("too large"), "Should mention size limit: {}", result);
assert!(
result.contains(""),
"Should reject oversized content: {}",
result
);
assert!(
result.contains("too large"),
"Should mention size limit: {}",
result
);
}
#[tokio::test]
@@ -226,22 +267,26 @@ async fn test_todo_respects_size_limit() {
async fn test_todo_agent_initialization_loads_file() {
let temp_dir = TempDir::new().unwrap();
let todo_path = get_todo_path(&temp_dir);
// Pre-create todo.g3.md before agent initialization
let initial_content = "- [ ] Pre-existing task";
fs::write(&todo_path, initial_content).unwrap();
// Create agent - should load the file during initialization
let mut agent = create_test_agent_in_dir(&temp_dir).await;
// Read TODO - should return the pre-existing content
let tool_call = g3_core::ToolCall {
tool: "todo_read".to_string(),
args: serde_json::json!({}),
};
let result = agent.execute_tool(&tool_call).await.unwrap();
assert!(result.contains("Pre-existing task"), "Should load file on init: {}", result);
assert!(
result.contains("Pre-existing task"),
"Should load file on init: {}",
result
);
}
#[tokio::test]
@@ -250,33 +295,41 @@ async fn test_todo_handles_unicode_content() {
let temp_dir = TempDir::new().unwrap();
let mut agent = create_test_agent_in_dir(&temp_dir).await;
let todo_path = get_todo_path(&temp_dir);
// Create TODO with unicode characters
let unicode_content = "- [ ] 日本語タスク\n- [ ] Émoji task 🚀\n- [x] Ελληνικά task";
let tool_call = g3_core::ToolCall {
tool: "todo_write".to_string(),
args: serde_json::json!({
"content": unicode_content
}),
};
agent.execute_tool(&tool_call).await.unwrap();
// Verify file preserves unicode
let file_content = fs::read_to_string(&todo_path).unwrap();
assert_eq!(file_content, unicode_content);
// Verify reading back works
let read_call = g3_core::ToolCall {
tool: "todo_read".to_string(),
args: serde_json::json!({}),
};
let result = agent.execute_tool(&read_call).await.unwrap();
assert!(result.contains("日本語"), "Should preserve Japanese: {}", result);
assert!(
result.contains("日本語"),
"Should preserve Japanese: {}",
result
);
assert!(result.contains("🚀"), "Should preserve emoji: {}", result);
assert!(result.contains("Ελληνικά"), "Should preserve Greek: {}", result);
assert!(
result.contains("Ελληνικά"),
"Should preserve Greek: {}",
result
);
}
#[tokio::test]
@@ -285,7 +338,7 @@ async fn test_todo_empty_content_creates_empty_file() {
let temp_dir = TempDir::new().unwrap();
let mut agent = create_test_agent_in_dir(&temp_dir).await;
let todo_path = get_todo_path(&temp_dir);
// Write empty TODO
let tool_call = g3_core::ToolCall {
tool: "todo_write".to_string(),
@@ -293,9 +346,9 @@ async fn test_todo_empty_content_creates_empty_file() {
"content": ""
}),
};
agent.execute_tool(&tool_call).await.unwrap();
// File should exist but be empty
assert!(todo_path.exists(), "Empty todo.g3.md should create file");
let content = fs::read_to_string(&todo_path).unwrap();
@@ -307,7 +360,7 @@ async fn test_todo_empty_content_creates_empty_file() {
async fn test_todo_whitespace_only_content() {
let temp_dir = TempDir::new().unwrap();
let mut agent = create_test_agent_in_dir(&temp_dir).await;
// Write whitespace-only TODO
let tool_call = g3_core::ToolCall {
tool: "todo_write".to_string(),
@@ -315,17 +368,21 @@ async fn test_todo_whitespace_only_content() {
"content": " \n\n \t \n"
}),
};
agent.execute_tool(&tool_call).await.unwrap();
// Read it back
let read_call = g3_core::ToolCall {
tool: "todo_read".to_string(),
args: serde_json::json!({}),
};
let result = agent.execute_tool(&read_call).await.unwrap();
// Should report as empty (whitespace is trimmed)
assert!(result.contains("empty"), "Whitespace-only should be empty: {}", result);
assert!(
result.contains("empty"),
"Whitespace-only should be empty: {}",
result
);
}

View File

@@ -4,7 +4,7 @@ use g3_providers::Usage;
#[test]
fn test_token_accumulation() {
let mut window = ContextWindow::new(10000);
// First API call: 100 prompt + 50 completion = 150 total
let usage1 = Usage {
prompt_tokens: 100,
@@ -22,7 +22,10 @@ fn test_token_accumulation() {
total_tokens: 275,
};
window.update_usage_from_response(&usage2);
assert_eq!(window.used_tokens, 425, "Second call should accumulate to 425 tokens");
assert_eq!(
window.used_tokens, 425,
"Second call should accumulate to 425 tokens"
);
assert_eq!(window.cumulative_tokens, 425, "Cumulative should be 425");
// Third API call with SMALLER token count: 50 prompt + 25 completion = 75 total
@@ -32,27 +35,33 @@ fn test_token_accumulation() {
total_tokens: 75,
};
window.update_usage_from_response(&usage3);
assert_eq!(window.used_tokens, 500, "Third call should accumulate to 500 tokens");
assert_eq!(
window.used_tokens, 500,
"Third call should accumulate to 500 tokens"
);
assert_eq!(window.cumulative_tokens, 500, "Cumulative should be 500");
// Verify tokens never decrease
assert!(window.used_tokens >= 425, "Token count should never decrease!");
assert!(
window.used_tokens >= 425,
"Token count should never decrease!"
);
}
#[test]
fn test_add_streaming_tokens() {
let mut window = ContextWindow::new(10000);
// Add some streaming tokens
window.add_streaming_tokens(100);
assert_eq!(window.used_tokens, 100);
assert_eq!(window.cumulative_tokens, 100);
// Add more
window.add_streaming_tokens(50);
assert_eq!(window.used_tokens, 150);
assert_eq!(window.cumulative_tokens, 150);
// Now update from provider response
let usage = Usage {
prompt_tokens: 80,
@@ -60,7 +69,7 @@ fn test_add_streaming_tokens() {
total_tokens: 120,
};
window.update_usage_from_response(&usage);
// Should ADD to existing, not replace
assert_eq!(window.used_tokens, 270, "Should add 120 to existing 150");
assert_eq!(window.cumulative_tokens, 270);
@@ -69,7 +78,7 @@ fn test_add_streaming_tokens() {
#[test]
fn test_percentage_calculation() {
let mut window = ContextWindow::new(1000);
// Add tokens via provider response
let usage = Usage {
prompt_tokens: 150,
@@ -77,10 +86,10 @@ fn test_percentage_calculation() {
total_tokens: 250,
};
window.update_usage_from_response(&usage);
assert_eq!(window.percentage_used(), 25.0);
assert_eq!(window.remaining_tokens(), 750);
// Add more tokens
let usage2 = Usage {
prompt_tokens: 300,
@@ -88,7 +97,7 @@ fn test_percentage_calculation() {
total_tokens: 500,
};
window.update_usage_from_response(&usage2);
assert_eq!(window.percentage_used(), 75.0);
assert_eq!(window.remaining_tokens(), 250);
}

View File

@@ -1,9 +1,9 @@
use g3_core::{Agent, ToolCall};
use g3_core::ui_writer::UiWriter;
use g3_config::Config;
use g3_core::ui_writer::UiWriter;
use g3_core::{Agent, ToolCall};
use serial_test::serial;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
use serial_test::serial;
// Mock UI Writer for testing
#[derive(Clone)]
@@ -47,7 +47,10 @@ impl UiWriter for MockUiWriter {
}
fn print_system_prompt(&self, _prompt: &str) {}
fn print_context_status(&self, message: &str) {
self.output.lock().unwrap().push(format!("STATUS: {}", message));
self.output
.lock()
.unwrap()
.push(format!("STATUS: {}", message));
}
fn print_context_thinning(&self, _message: &str) {}
fn print_tool_header(&self, _tool_name: &str) {}
@@ -61,13 +64,21 @@ impl UiWriter for MockUiWriter {
fn print_agent_response(&self, _content: &str) {}
fn notify_sse_received(&self) {}
fn flush(&self) {}
fn wants_full_output(&self) -> bool { false }
fn wants_full_output(&self) -> bool {
false
}
fn prompt_user_yes_no(&self, message: &str) -> bool {
self.output.lock().unwrap().push(format!("PROMPT: {}", message));
self.output
.lock()
.unwrap()
.push(format!("PROMPT: {}", message));
self.prompt_responses.lock().unwrap().pop().unwrap_or(true)
}
fn prompt_user_choice(&self, message: &str, options: &[&str]) -> usize {
self.output.lock().unwrap().push(format!("CHOICE: {} Options: {:?}", message, options));
self.output
.lock()
.unwrap()
.push(format!("CHOICE: {} Options: {:?}", message, options));
self.choice_responses.lock().unwrap().pop().unwrap_or(0)
}
}
@@ -80,7 +91,10 @@ async fn test_todo_staleness_check_matching_sha() {
std::env::set_current_dir(&temp_dir).unwrap();
let sha = "abc123hash";
let content = format!("{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1", sha);
let content = format!(
"{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1",
sha
);
std::fs::write(&todo_path, content).unwrap();
let mut config = Config::default();
@@ -109,7 +123,10 @@ async fn test_todo_staleness_check_mismatch_sha_ignore() {
let sha_file = "old_sha";
let sha_req = "new_sha";
let content = format!("{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1", sha_file);
let content = format!(
"{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1",
sha_file
);
std::fs::write(&todo_path, content).unwrap();
let mut config = Config::default();
@@ -139,7 +156,10 @@ async fn test_todo_staleness_check_mismatch_sha_mark_stale() {
let sha_file = "old_sha";
let sha_req = "new_sha";
let content = format!("{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1", sha_file);
let content = format!(
"{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1",
sha_file
);
std::fs::write(&todo_path, content).unwrap();
let mut config = Config::default();
@@ -173,7 +193,10 @@ async fn test_todo_staleness_check_disabled() {
let sha_file = "old_sha";
let sha_req = "new_sha";
let content = format!("{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1", sha_file);
let content = format!(
"{{{{Based on the requirements file with SHA256: {}}}}}\n- [ ] Task 1",
sha_file
);
std::fs::write(&todo_path, content).unwrap();
let mut config = Config::default();