diff --git a/crates/g3-cli/src/machine_ui_writer.rs b/crates/g3-cli/src/machine_ui_writer.rs index 5d236b4..6b70837 100644 --- a/crates/g3-cli/src/machine_ui_writer.rs +++ b/crates/g3-cli/src/machine_ui_writer.rs @@ -98,4 +98,11 @@ impl UiWriter for MachineUiWriter { println!("PROMPT_USER_YES_NO: {}", message); true } + + fn prompt_user_choice(&self, message: &str, options: &[&str]) -> usize { + println!("PROMPT_USER_CHOICE: {}", message); + println!("OPTIONS: {:?}", options); + // Default to first option (index 0) for automation + 0 + } } diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index 8dfa145..f9c844f 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -356,5 +356,27 @@ impl UiWriter for ConsoleUiWriter { false } } + + fn prompt_user_choice(&self, message: &str, options: &[&str]) -> usize { + println!("{} ", message); + for (i, option) in options.iter().enumerate() { + println!(" [{}] {}", i + 1, option); + } + print!("Select an option (1-{}): ", options.len()); + let _ = io::stdout().flush(); + + loop { + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_ok() { + if let Ok(choice) = input.trim().parse::() { + if choice > 0 && choice <= options.len() { + return choice - 1; + } + } + } + print!("Invalid choice. Please select (1-{}): ", options.len()); + let _ = io::stdout().flush(); + } + } } diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index 28688e0..bc467b0 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -4276,8 +4276,25 @@ impl Agent { print!("\x07\x07\x07\x07\x07\x07"); let _ = std::io::stdout().flush(); - if !self.ui_writer.prompt_user_yes_no("Requirements have changed! Continue?") { - return Ok("❌ User aborted due to stale TODO list.".to_string()); + let options = ["Ignore and Continue", "Mark as Stale", "Quit Application"]; + let choice = self.ui_writer.prompt_user_choice("Requirements have changed! What would you like to do?", &options); + + match choice { + 0 => { + // Ignore and Continue + self.ui_writer.print_context_status("⚠️ Ignoring staleness warning."); + } + 1 => { + // Mark as Stale + // We return a message to the agent so it knows to regenerate/fix it. + return Ok("⚠️ TODO list is stale (requirements changed). Please regenerate the TODO list to match the new requirements.".to_string()); + } + 2 => { + // Quit Application + self.ui_writer.print_context_status("❌ Quitting application as requested."); + std::process::exit(0); + } + _ => unreachable!(), } } } diff --git a/crates/g3-core/src/ui_writer.rs b/crates/g3-core/src/ui_writer.rs index 8fc9959..e817b49 100644 --- a/crates/g3-core/src/ui_writer.rs +++ b/crates/g3-core/src/ui_writer.rs @@ -59,6 +59,10 @@ pub trait UiWriter: Send + Sync { /// Prompt the user for a yes/no confirmation fn prompt_user_yes_no(&self, message: &str) -> bool; + + /// Prompt the user to choose from a list of options + /// Returns the index of the selected option + fn prompt_user_choice(&self, message: &str, options: &[&str]) -> usize; } /// A no-op implementation for when UI output is not needed @@ -84,4 +88,5 @@ impl UiWriter for NullUiWriter { fn flush(&self) {} fn wants_full_output(&self) -> bool { false } fn prompt_user_yes_no(&self, _message: &str) -> bool { true } + fn prompt_user_choice(&self, _message: &str, _options: &[&str]) -> usize { 0 } } \ No newline at end of file diff --git a/crates/g3-core/tests/todo_staleness_test.rs b/crates/g3-core/tests/todo_staleness_test.rs index ae2244d..6e54855 100644 --- a/crates/g3-core/tests/todo_staleness_test.rs +++ b/crates/g3-core/tests/todo_staleness_test.rs @@ -10,6 +10,7 @@ use serial_test::serial; struct MockUiWriter { output: Arc>>, prompt_responses: Arc>>, + choice_responses: Arc>>, } impl MockUiWriter { @@ -17,6 +18,7 @@ impl MockUiWriter { Self { output: Arc::new(Mutex::new(Vec::new())), prompt_responses: Arc::new(Mutex::new(Vec::new())), + choice_responses: Arc::new(Mutex::new(Vec::new())), } } @@ -24,6 +26,10 @@ impl MockUiWriter { 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 { self.output.lock().unwrap().clone() } @@ -60,6 +66,10 @@ impl UiWriter for MockUiWriter { 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) + } } #[tokio::test] @@ -92,7 +102,7 @@ async fn test_todo_staleness_check_matching_sha() { #[tokio::test] #[serial] -async fn test_todo_staleness_check_mismatch_sha_abort() { +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(); @@ -106,38 +116,7 @@ async fn test_todo_staleness_check_mismatch_sha_abort() { config.agent.check_todo_staleness = true; let ui_writer = MockUiWriter::new(); - ui_writer.set_prompt_response(false); // Abort - - 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("❌ User aborted due to stale TODO list.")); -} - -#[tokio::test] -#[serial] -async fn test_todo_staleness_check_mismatch_sha_continue() { - 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_prompt_response(true); // Continue - let output_handle = ui_writer.clone(); // Clone to keep handle + 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()); @@ -149,12 +128,42 @@ async fn test_todo_staleness_check_mismatch_sha_continue() { let result = agent.execute_tool(&tool_call).await.unwrap(); assert!(result.contains("📝 TODO list:")); - - let output = output_handle.get_output(); - let has_warning = output.iter().any(|s| s.contains("⚠️ TODO list is stale")); - assert!(has_warning, "Should have printed warning to UI"); } +#[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() {