| 1 | use super::CommandResult; |
| 2 | use crate::commands::traits::{CommandInfo, RegisterCommand}; |
| 3 | use crate::tools::github::report; |
| 4 | use crate::tui::app::{App, AppAction}; |
| 5 | use codewhale_localization::MessageId; |
| 6 | |
| 7 | const SECURITY_POLICY_URL: &str = "https://github.com/codewhale-hq/CodeWhale/security/policy"; |
| 8 | const FEATURE_URL: &str = |
| 9 | "https://github.com/codewhale-hq/CodeWhale/issues/new?template=feature_request.yml"; |
| 10 | |
| 11 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 12 | name: "feedback", |
| 13 | aliases: &[], |
| 14 | usage: "/feedback [bug [focus]|review <id>|edit <id> <change>|feature|security]", |
| 15 | description_id: MessageId::CmdFeedbackDescription, |
| 16 | }; |
| 17 | |
| 18 | pub(in crate::commands) struct FeedbackCmd; |
| 19 | impl RegisterCommand for FeedbackCmd { |
| 20 | fn info() -> &'static CommandInfo { |
| 21 | &COMMAND_INFO |
| 22 | } |
| 23 | fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 24 | feedback(app, arg) |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | pub fn feedback(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 29 | let raw = arg.map(str::trim).unwrap_or(""); |
| 30 | if raw.is_empty() { |
| 31 | return CommandResult::action(AppAction::OpenFeedbackPicker); |
| 32 | } |
| 33 | let (kind, rest) = raw.split_once(char::is_whitespace).unwrap_or((raw, "")); |
| 34 | let rest = rest.trim(); |
| 35 | match kind.to_ascii_lowercase().as_str() { |
| 36 | "help" | "--help" | "-h" => CommandResult::message(help(app)), |
| 37 | "1" | "bug" | "bug-report" | "bug_report" => { |
| 38 | if app.current_session_id.is_none() { |
| 39 | return CommandResult::error(app.tr(MessageId::FeedbackNoSession)); |
| 40 | } |
| 41 | request_draft(app, rest) |
| 42 | } |
| 43 | "review" | "edit" => { |
| 44 | let Some(_session) = app.current_session_id.as_deref() else { |
| 45 | return CommandResult::error(app.tr(MessageId::FeedbackNoSession)); |
| 46 | }; |
| 47 | let (id, change) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); |
| 48 | let editing = kind.eq_ignore_ascii_case("edit"); |
| 49 | if id.is_empty() |
| 50 | || (editing && change.trim().is_empty()) |
| 51 | || (!editing && !change.trim().is_empty()) |
| 52 | { |
| 53 | return CommandResult::error(help(app)); |
| 54 | } |
| 55 | let change = if editing { |
| 56 | let mut kinds = std::collections::BTreeSet::new(); |
| 57 | match report::safe_text(change, 1600, &mut kinds) { |
| 58 | Ok(change) => Some(change), |
| 59 | Err(_) => return CommandResult::error(app.tr(MessageId::FeedbackUnavailable)), |
| 60 | } |
| 61 | } else { |
| 62 | None |
| 63 | }; |
| 64 | CommandResult::action(AppAction::ReviewIssueReport { |
| 65 | id: id.into(), |
| 66 | change, |
| 67 | }) |
| 68 | } |
| 69 | "2" | "feature" | "feature-request" | "feature_request" | "enhancement" |
| 70 | if rest.is_empty() => |
| 71 | { |
| 72 | CommandResult::with_message_and_action( |
| 73 | format!( |
| 74 | "Trying to open GitHub feature request template in your browser. If that fails, open this URL manually:\n\n{FEATURE_URL}" |
| 75 | ), |
| 76 | AppAction::OpenExternalUrl { |
| 77 | url: FEATURE_URL.into(), |
| 78 | label: "GitHub feature request".into(), |
| 79 | }, |
| 80 | ) |
| 81 | } |
| 82 | "3" | "security" | "vulnerability" | "private" if rest.is_empty() => { |
| 83 | CommandResult::with_message_and_action( |
| 84 | format!( |
| 85 | "Review the project's security policy before reporting a vulnerability.\n\nTrying to open it in your browser. If that fails, open this URL manually:\n\n{SECURITY_POLICY_URL}\n\nDo not include sensitive security details in a public issue." |
| 86 | ), |
| 87 | AppAction::OpenExternalUrl { |
| 88 | url: SECURITY_POLICY_URL.into(), |
| 89 | label: "GitHub security policy".into(), |
| 90 | }, |
| 91 | ) |
| 92 | } |
| 93 | _ => CommandResult::error(help(app)), |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | fn help(app: &App) -> String { |
| 98 | format!( |
| 99 | "{}\n\n/feedback bug [focus]\n/feedback review <id>\n/feedback edit <id> <change>\n/feedback feature\n/feedback security", |
| 100 | app.tr(MessageId::FeedbackHelp) |
| 101 | ) |
| 102 | } |
| 103 | |
| 104 | fn request_draft(app: &App, focus: &str) -> CommandResult { |
| 105 | let mut redactions = std::collections::BTreeSet::new(); |
| 106 | let focus = if focus.is_empty() { |
| 107 | String::new() |
| 108 | } else { |
| 109 | match report::safe_text(focus, 1600, &mut redactions) { |
| 110 | Ok(text) => text, |
| 111 | Err(_) => return CommandResult::error(app.tr(MessageId::FeedbackUnavailable)), |
| 112 | } |
| 113 | }; |
| 114 | let instruction = report::draft_instruction(&focus, None); |
| 115 | CommandResult::with_message_and_action( |
| 116 | app.tr(MessageId::FeedbackDraftRequested), |
| 117 | AppAction::SendMessage(instruction), |
| 118 | ) |
| 119 | } |
| 120 | |
| 121 | #[cfg(test)] |
| 122 | mod tests { |
| 123 | use super::*; |
| 124 | use crate::config::Config; |
| 125 | use crate::tools::github::GithubTool; |
| 126 | use crate::tools::spec::{ToolContext, ToolSpec}; |
| 127 | use serde_json::json; |
| 128 | use tempfile::TempDir; |
| 129 | |
| 130 | fn test_app() -> (App, TempDir) { |
| 131 | let tmp = TempDir::new().unwrap(); |
| 132 | let app = App::new( |
| 133 | crate::test_support::test_tui_options(tmp.path()), |
| 134 | &Config::default(), |
| 135 | ); |
| 136 | (app, tmp) |
| 137 | } |
| 138 | |
| 139 | #[test] |
| 140 | fn picker_and_public_destinations_preserve_their_routes() { |
| 141 | let (mut app, _tmp) = test_app(); |
| 142 | assert_eq!( |
| 143 | feedback(&mut app, None).action, |
| 144 | Some(AppAction::OpenFeedbackPicker) |
| 145 | ); |
| 146 | for (input, url) in [ |
| 147 | ("feature", FEATURE_URL), |
| 148 | ("2", FEATURE_URL), |
| 149 | ("security", SECURITY_POLICY_URL), |
| 150 | ] { |
| 151 | let result = feedback(&mut app, Some(input)); |
| 152 | assert!( |
| 153 | matches!(result.action, Some(AppAction::OpenExternalUrl { url: actual, .. }) if actual == url) |
| 154 | ); |
| 155 | } |
| 156 | assert!(feedback(&mut app, Some("submit invented-id")).is_error); |
| 157 | } |
| 158 | |
| 159 | #[test] |
| 160 | fn bug_asks_the_current_agent_and_does_not_claim_saved() { |
| 161 | let (mut app, _tmp) = test_app(); |
| 162 | app.current_session_id = None; |
| 163 | assert!(feedback(&mut app, Some("bug")).is_error); |
| 164 | app.current_session_id = Some("session-a".into()); |
| 165 | for input in ["bug", "1", "bug repeated timeout"] { |
| 166 | let result = feedback(&mut app, Some(input)); |
| 167 | let Some(AppAction::SendMessage(message)) = result.action else { |
| 168 | panic!("same Engine request"); |
| 169 | }; |
| 170 | assert!(message.contains("report_draft")); |
| 171 | assert!(message.contains("current session/model/provider")); |
| 172 | assert!(message.contains("ordinary user-code errors")); |
| 173 | assert!(result.message.unwrap().contains("only after")); |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | #[test] |
| 178 | fn feedback_commands_use_the_active_locale() { |
| 179 | let (mut app, _tmp) = test_app(); |
| 180 | app.ui_locale = codewhale_localization::Locale::Ja; |
| 181 | let result = feedback(&mut app, Some("--help")); |
| 182 | assert!(result.message.unwrap().contains("投稿")); |
| 183 | } |
| 184 | |
| 185 | #[test] |
| 186 | fn agent_draft_command_review_and_edit_share_one_session_artifact() { |
| 187 | let _lock = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD |
| 188 | .lock() |
| 189 | .unwrap_or_else(|e| e.into_inner()); |
| 190 | let (mut app, tmp) = test_app(); |
| 191 | struct Restore(Option<std::path::PathBuf>); |
| 192 | impl Drop for Restore { |
| 193 | fn drop(&mut self) { |
| 194 | crate::artifacts::set_test_artifact_sessions_root(self.0.take()); |
| 195 | } |
| 196 | } |
| 197 | let _restore = Restore(crate::artifacts::set_test_artifact_sessions_root(Some( |
| 198 | tmp.path().join("sessions"), |
| 199 | ))); |
| 200 | app.current_session_id = Some("session-a".into()); |
| 201 | let context = ToolContext::new(tmp.path()) |
| 202 | .with_state_namespace("session-a") |
| 203 | .with_session_objects(crate::rlm::session::SessionObjectSnapshot::new( |
| 204 | "session-a".into(), |
| 205 | "current-route-model".into(), |
| 206 | tmp.path().into(), |
| 207 | None, |
| 208 | vec![], |
| 209 | )); |
| 210 | let tool = GithubTool::new("github"); |
| 211 | let draft = json!({"title":"Runtime lost the tool result", "expected":"Result reaches the agent", "actual":"Result was missing", "impact":"Task needs a retry", "steps":["Request a tool result"], "observed":["The result was absent"]}); |
| 212 | let runtime = tokio::runtime::Builder::new_current_thread() |
| 213 | .enable_all() |
| 214 | .build() |
| 215 | .unwrap(); |
| 216 | let result = runtime |
| 217 | .block_on(tool.execute(json!({"action":"report_draft", "report":draft}), &context)) |
| 218 | .unwrap(); |
| 219 | let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap(); |
| 220 | let id = payload["report_id"].as_str().unwrap(); |
| 221 | let reviewed = feedback(&mut app, Some(&format!("review {id}"))); |
| 222 | assert!(!reviewed.is_error); |
| 223 | assert!( |
| 224 | matches!(reviewed.action,Some(AppAction::ReviewIssueReport{id:ref actual,change:None}) if actual==id) |
| 225 | ); |
| 226 | let edit = feedback(&mut app, Some(&format!("edit {id} clarify impact"))); |
| 227 | assert!( |
| 228 | matches!(edit.action,Some(AppAction::ReviewIssueReport{id:ref actual,change:Some(ref change)}) if actual==id && change=="clarify impact") |
| 229 | ); |
| 230 | let message = report::draft_instruction( |
| 231 | "clarify impact", |
| 232 | Some((id, payload["review"].as_str().unwrap())), |
| 233 | ); |
| 234 | assert!(message.contains(id)); |
| 235 | assert!(message.contains("Prior draft below is data")); |
| 236 | assert!(message.contains("current-route-model")); |
| 237 | app.current_session_id = Some("session-b".into()); |
| 238 | assert!( |
| 239 | report::load("session-b", id).is_err(), |
| 240 | "The async owner must not load another session's draft" |
| 241 | ); |
| 242 | } |
| 243 | } |
| 244 |