- Add ToolParsingHint enum (Detected/Active/Complete) for UI feedback - New UiWriter methods: print_tool_streaming_hint(), print_tool_streaming_active() - Refactor ConsoleUiWriter state to use atomics in ParsingHintState - Add tool_call_streaming field to CompletionChunk for provider hints - Anthropic provider sends streaming hints when tool name detected - New streaming helpers: make_tool_streaming_hint(), make_tool_streaming_active() Parser improvements: - Add is_json_invalidated() to detect false positive tool patterns - Fix tool result poisoning when file contents contain partial JSON - Unescaped newlines in strings or prose after JSON invalidates detection User sees ' ● tool_name |' immediately when tool call starts streaming, with blinking indicator while args are received.
219 lines
6.9 KiB
Rust
219 lines
6.9 KiB
Rust
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;
|
|
|
|
// Mock UI Writer for testing
|
|
#[derive(Clone)]
|
|
struct MockUiWriter {
|
|
output: Arc<Mutex<Vec<String>>>,
|
|
prompt_responses: Arc<Mutex<Vec<bool>>>,
|
|
choice_responses: Arc<Mutex<Vec<usize>>>,
|
|
}
|
|
|
|
impl MockUiWriter {
|
|
fn new() -> Self {
|
|
Self {
|
|
output: Arc::new(Mutex::new(Vec::new())),
|
|
prompt_responses: Arc::new(Mutex::new(Vec::new())),
|
|
choice_responses: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
|
|
fn set_prompt_response(&self, response: bool) {
|
|
self.prompt_responses.lock().unwrap().push(response);
|
|
}
|
|
|
|
fn set_choice_response(&self, response: usize) {
|
|
self.choice_responses.lock().unwrap().push(response);
|
|
}
|
|
|
|
fn get_output(&self) -> Vec<String> {
|
|
self.output.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
impl UiWriter for MockUiWriter {
|
|
fn print(&self, message: &str) {
|
|
self.output.lock().unwrap().push(message.to_string());
|
|
}
|
|
fn println(&self, message: &str) {
|
|
self.output.lock().unwrap().push(message.to_string());
|
|
}
|
|
fn print_inline(&self, message: &str) {
|
|
self.output.lock().unwrap().push(message.to_string());
|
|
}
|
|
fn print_system_prompt(&self, _prompt: &str) {}
|
|
fn print_context_status(&self, message: &str) {
|
|
self.output
|
|
.lock()
|
|
.unwrap()
|
|
.push(format!("STATUS: {}", message));
|
|
}
|
|
fn print_context_thinning(&self, _message: &str) {}
|
|
fn print_tool_header(&self, _tool_name: &str, _tool_args: Option<&serde_json::Value>) {}
|
|
fn print_tool_arg(&self, _key: &str, _value: &str) {}
|
|
fn print_tool_output_header(&self) {}
|
|
fn update_tool_output_line(&self, _line: &str) {}
|
|
fn print_tool_output_line(&self, _line: &str) {}
|
|
fn print_tool_output_summary(&self, _hidden_count: usize) {}
|
|
fn print_tool_timing(&self, _duration_str: &str, _tokens_delta: u32, _context_percentage: f32) {}
|
|
fn print_agent_prompt(&self) {}
|
|
fn print_agent_response(&self, _content: &str) {}
|
|
fn notify_sse_received(&self) {}
|
|
fn flush(&self) {}
|
|
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.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.choice_responses.lock().unwrap().pop().unwrap_or(0)
|
|
}
|
|
fn print_tool_streaming_hint(&self, _tool_name: &str) {}
|
|
fn print_tool_streaming_active(&self) {}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_todo_staleness_check_matching_sha() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let todo_path = temp_dir.path().join("todo.g3.md");
|
|
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
|
|
);
|
|
std::fs::write(&todo_path, content).unwrap();
|
|
|
|
let mut config = Config::default();
|
|
config.agent.check_todo_staleness = true;
|
|
|
|
let ui_writer = MockUiWriter::new();
|
|
let mut agent = Agent::new_autonomous(config, ui_writer).await.unwrap();
|
|
agent.set_requirements_sha(sha.to_string());
|
|
|
|
let tool_call = ToolCall {
|
|
tool: "todo_read".to_string(),
|
|
args: serde_json::json!({}),
|
|
};
|
|
let result = agent.execute_tool(&tool_call).await.unwrap();
|
|
|
|
assert!(result.contains("📝 TODO list:"));
|
|
assert!(!result.contains("⚠️ TODO list is stale"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_todo_staleness_check_mismatch_sha_ignore() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let todo_path = temp_dir.path().join("todo.g3.md");
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
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
|
|
);
|
|
std::fs::write(&todo_path, content).unwrap();
|
|
|
|
let mut config = Config::default();
|
|
config.agent.check_todo_staleness = true;
|
|
|
|
let ui_writer = MockUiWriter::new();
|
|
ui_writer.set_choice_response(0); // Ignore
|
|
|
|
let mut agent = Agent::new_autonomous(config, ui_writer).await.unwrap();
|
|
agent.set_requirements_sha(sha_req.to_string());
|
|
|
|
let tool_call = ToolCall {
|
|
tool: "todo_read".to_string(),
|
|
args: serde_json::json!({}),
|
|
};
|
|
let result = agent.execute_tool(&tool_call).await.unwrap();
|
|
|
|
assert!(result.contains("📝 TODO list:"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_todo_staleness_check_mismatch_sha_mark_stale() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let todo_path = temp_dir.path().join("todo.g3.md");
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
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
|
|
);
|
|
std::fs::write(&todo_path, content).unwrap();
|
|
|
|
let mut config = Config::default();
|
|
config.agent.check_todo_staleness = true;
|
|
|
|
let ui_writer = MockUiWriter::new();
|
|
ui_writer.set_choice_response(1); // Mark as Stale
|
|
|
|
let mut agent = Agent::new_autonomous(config, ui_writer).await.unwrap();
|
|
agent.set_requirements_sha(sha_req.to_string());
|
|
|
|
let tool_call = ToolCall {
|
|
tool: "todo_read".to_string(),
|
|
args: serde_json::json!({}),
|
|
};
|
|
let result = agent.execute_tool(&tool_call).await.unwrap();
|
|
|
|
assert!(result.contains("⚠️ TODO list is stale"));
|
|
assert!(result.contains("Please regenerate"));
|
|
}
|
|
|
|
// Note: We cannot easily test "Quit" (index 2) because it calls std::process::exit(0)
|
|
// which would kill the test runner. We skip that test case here.
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn test_todo_staleness_check_disabled() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let todo_path = temp_dir.path().join("todo.g3.md");
|
|
std::env::set_current_dir(&temp_dir).unwrap();
|
|
|
|
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
|
|
);
|
|
std::fs::write(&todo_path, content).unwrap();
|
|
|
|
let mut config = Config::default();
|
|
config.agent.check_todo_staleness = false;
|
|
|
|
let ui_writer = MockUiWriter::new();
|
|
let mut agent = Agent::new_autonomous(config, ui_writer).await.unwrap();
|
|
agent.set_requirements_sha(sha_req.to_string());
|
|
|
|
let tool_call = ToolCall {
|
|
tool: "todo_read".to_string(),
|
|
args: serde_json::json!({}),
|
|
};
|
|
let result = agent.execute_tool(&tool_call).await.unwrap();
|
|
|
|
assert!(result.contains("📝 TODO list:"));
|
|
}
|