diff --git a/AGENTS.md b/AGENTS.md index 00315f7f..94ec2889 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Environment variables: - `WS_SERVER_URL` - WebSocket endpoint (default: `ws://localhost:8080/graphql/v2`) ### Testing -- During interactive bug-fix verification, make the requested code changes first and run only `cargo run --bin galaxy-oss` for the user to verify. Leave the app running for the user; do not stop it or treat the command timeout as a failure. Do not run `cargo fmt`, `cargo check`, `cargo clippy`, presubmit, or other validation commands until the user confirms the fix. +- During interactive feature and bug-fix verification, make the requested code changes first and run only `cargo run --bin galaxy-oss` for the user to verify. Leave the app running for the user; do not stop it or treat the command timeout as a failure. Do not run `cargo fmt`, `cargo check`, `cargo test`, `cargo clippy`, presubmit, or other validation commands until the user confirms the behavior. Run formatting, tests, and linting only as the final cleanup step after interactive approval. - `cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2` - Run tests with nextest - `cargo nextest run -p galaxy_completer --features v2` - Run completer tests with v2 features - `cargo test --doc` - Run doc tests diff --git a/app/src/ai/runtime/rig_request.rs b/app/src/ai/runtime/rig_request.rs index a7b07b96..b7113c8f 100644 --- a/app/src/ai/runtime/rig_request.rs +++ b/app/src/ai/runtime/rig_request.rs @@ -807,7 +807,7 @@ fn build_system_prompt( match mode { RigRequestMode::Normal => {} RigRequestMode::Plan => prompt.push_str( - "## Plan Mode\nInspect and produce an implementation-ready plan. Do not edit files or perform state-changing actions.\n\n", + "## Plan Mode\nInspect and produce an implementation-ready plan. Do not edit files or perform state-changing actions. Research as needed, then finish by calling `create_plan` to write the plan with the built-in planning tools. If a plan document already exists for this task, call `edit_plan` instead. Do not return the plan only as prose, and do not claim completion until the plan tool succeeds.\n\n", ), RigRequestMode::Orchestrate => prompt.push_str( "## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n", diff --git a/app/src/ai/runtime/rig_request_tests.rs b/app/src/ai/runtime/rig_request_tests.rs index cb4b15b0..d16c5877 100644 --- a/app/src/ai/runtime/rig_request_tests.rs +++ b/app/src/ai/runtime/rig_request_tests.rs @@ -200,6 +200,42 @@ fn normal_turn_advertises_plan_creation_and_corrects_false_unavailability_claims assert!(prompt.contains("do not implement it until they approve")); } +#[test] +fn plan_turn_requires_a_plan_tool_result() { + let mut params = RequestParams::new_for_test(); + let mut input = user_query("Design the implementation"); + let AIAgentInput::UserQuery { + user_query_mode, .. + } = &mut input + else { + unreachable!("user_query returns a user query input"); + }; + *user_query_mode = UserQueryMode::Plan; + params.input = vec![input]; + + let prepared = prepare_rig_turn( + &config(), + params, + vec![ToolType::CreateDocuments, ToolType::EditDocuments], + Vec::new(), + ); + let prompt = prepared.request.system_prompt.expect("system prompt"); + + assert!(prepared + .request + .tools + .iter() + .any(|tool| tool.name == "create_plan")); + assert!(prepared + .request + .tools + .iter() + .any(|tool| tool.name == "edit_plan")); + assert!(prompt.contains("finish by calling `create_plan`")); + assert!(prompt.contains("call `edit_plan` instead")); + assert!(prompt.contains("Do not return the plan only as prose")); +} + #[test] fn no_tools_turn_flattens_historical_tool_protocol_messages() { let mut params = RequestParams::new_for_test(); diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index be4d799f..ab193188 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -294,13 +294,44 @@ impl AIAgentActionResultType { | Self::FetchConversation(_) | Self::StartAgent(_) | Self::SendMessageToAgent(_) - | Self::AskUserQuestion(_) | Self::WaitForEvents(_) => self.to_string(), + Self::AskUserQuestion(result) => ask_user_question_result_content(result), Self::RunAgents(result) => result.model_content(), } } } +fn ask_user_question_result_content(result: &AskUserQuestionResult) -> String { + match result { + AskUserQuestionResult::Success { answers } => { + let answers = answers + .iter() + .map(|answer| match answer { + AskUserQuestionAnswerItem::Answered { + question_id, + selected_options, + other_text, + } => serde_json::json!({ + "question_id": question_id, + "selected_options": selected_options, + "other_text": other_text, + }), + AskUserQuestionAnswerItem::Skipped { question_id } => serde_json::json!({ + "question_id": question_id, + "skipped": true, + }), + }) + .collect::>(); + serde_json::json!({ "answers": answers }).to_string() + } + AskUserQuestionResult::SkippedByAutoApprove { question_ids } => serde_json::json!({ + "skipped_by_auto_approve": question_ids, + }) + .to_string(), + AskUserQuestionResult::Error(_) | AskUserQuestionResult::Cancelled => result.to_string(), + } +} + fn command_result_content(command: Option<&str>, output: &str, exit_code: i32) -> String { let command = command .map(|command| format!("Command: {command}\n")) diff --git a/crates/ai/src/agent/action_result/mod_tests.rs b/crates/ai/src/agent/action_result/mod_tests.rs index b661f522..82643d13 100644 --- a/crates/ai/src/agent/action_result/mod_tests.rs +++ b/crates/ai/src/agent/action_result/mod_tests.rs @@ -1,9 +1,26 @@ use super::{ - AIAgentActionResultType, RequestCommandOutputResult, RunAgentsAgentOutcome, - RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode, RunAgentsResult, StartAgentResult, - StartAgentVersion, + AIAgentActionResultType, AskUserQuestionAnswerItem, AskUserQuestionResult, + RequestCommandOutputResult, RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, + RunAgentsLaunchedExecutionMode, RunAgentsResult, StartAgentResult, StartAgentVersion, }; +#[test] +fn ask_user_question_model_content_includes_selected_answers() { + let result = AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Success { + answers: vec![AskUserQuestionAnswerItem::Answered { + question_id: "scope".to_string(), + selected_options: vec!["Provider-neutral pane plus agent tool".to_string()], + other_text: String::new(), + }], + }); + + let content: serde_json::Value = serde_json::from_str(&result.model_content()).unwrap(); + assert_eq!( + content["answers"][0]["selected_options"][0], + "Provider-neutral pane plus agent tool" + ); +} + #[test] fn shell_execution_error_is_failed_but_not_cancelled() { let result =