1use super::claude::{self, ClaudeRunner};
23use super::{
24 shell_single_quote, AgentError, AgentRunner, ParsedResponse, PermissionRules, StreamEvent,
25};
26
27pub struct GrokRunner;
29
30impl GrokRunner {
31 fn parser() -> ClaudeRunner {
32 ClaudeRunner::new("grok")
33 }
34}
35
36impl AgentRunner for GrokRunner {
37 #[cfg(test)]
38 fn name(&self) -> &str {
39 "grok"
40 }
41
42 fn stream_events(&self, event: &serde_json::Value) -> Vec<StreamEvent> {
43 claude::stream_events(event)
44 }
45
46 fn router_transcript(&self) -> super::RouterTranscript {
47 super::RouterTranscript::Messages
48 }
49
50 fn extract_text(&self, raw: &str) -> Result<String, AgentError> {
51 Self::parser().extract_text(raw)
52 }
53
54 fn build_command(
55 &self,
56 model: Option<&str>,
57 timeout_cmd: &str,
58 sys_file: &str,
59 msg_file: &str,
60 permissions: &PermissionRules,
61 ) -> String {
62 let sq_sys = shell_single_quote(sys_file);
63 let sq_msg = shell_single_quote(msg_file);
64 let mut args = Vec::new();
65 if !timeout_cmd.is_empty() {
66 args.push(timeout_cmd.to_string());
67 }
68 args.push("grok".to_string());
69 args.push("--no-plan".to_string());
70 args.push("--output-format".to_string());
71 args.push("streaming-messages-json".to_string());
72 args.push("--permission-mode".to_string());
73 args.push(permission_mode(permissions).to_string());
74 args.push("--sandbox".to_string());
75 args.push(sandbox_profile(permissions).to_string());
76 if let Some(model) = model.filter(|m| !m.is_empty()) {
77 args.push("--model".to_string());
78 args.push(shell_single_quote(model));
79 }
80 args.extend(permission_args(permissions));
81 args.push("--system-prompt-override".to_string());
82 args.push(format!("\"$(cat {sq_sys})\""));
83 args.push("--prompt-file".to_string());
84 args.push(sq_msg);
85 args.join(" ")
86 }
87
88 fn parse_response(&self, raw: &str) -> Result<ParsedResponse, AgentError> {
89 Self::parser().parse_response(raw)
90 }
91
92 fn classify_error(&self, exit_code: i32, stdout: &str, stderr: &str) -> AgentError {
93 let extracted = startup_errors(stdout);
94 for message in &extracted {
95 if let Some(err) = classify_grok_message(message) {
96 return err;
97 }
98 }
99 let stderr = if extracted.is_empty() {
100 stderr.to_string()
101 } else {
102 format!("{stderr}\n{}", extracted.join("\n"))
103 };
104 Self::parser().classify_error(exit_code, stdout, &stderr)
105 }
106
107 fn router_command(
108 &self,
109 prompt: &str,
110 model: Option<&str>,
111 ) -> anyhow::Result<tokio::process::Command> {
112 let mut cmd = tokio::process::Command::new("grok");
115 cmd.arg("--no-plan")
116 .arg("--no-subagents")
117 .arg("--disable-web-search")
118 .arg("--max-turns")
119 .arg("1")
120 .arg("--output-format")
121 .arg("streaming-messages-json")
122 .arg("--permission-mode")
123 .arg("dontAsk")
124 .arg("--disallowed-tools")
125 .arg("read_file,grep,list_dir,search_replace,write,run_terminal_cmd,web_search,web_fetch,task");
126 if let Some(model) = model.filter(|m| !m.is_empty()) {
127 cmd.arg("--model").arg(model);
128 }
129 cmd.arg("-p").arg(prompt);
130 Ok(cmd)
131 }
132}
133
134pub fn find_grok_result(ndjson: &str) -> Option<super::AgentResult> {
136 let mut result = claude::find_claude_result(ndjson)?;
137 let Some((line, _)) = claude::find_ndjson_result_line(ndjson.trim()) else {
138 return Some(result);
139 };
140 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
141 return Some(result);
142 };
143 if result.cost_usd.is_none() {
144 result.cost_usd = val.get("total_cost_usd").and_then(|v| v.as_f64());
145 }
146 if val.get("stop_reason").and_then(|v| v.as_str()) == Some("max_tokens") {
147 result.truncated_by_length = true;
148 }
149 Some(result)
150}
151
152fn permission_mode(permissions: &PermissionRules) -> &'static str {
153 if permissions.autonomous {
154 "bypassPermissions"
155 } else if permissions.deny_read_only {
156 "dontAsk"
157 } else {
158 "acceptEdits"
159 }
160}
161
162fn sandbox_profile(permissions: &PermissionRules) -> &'static str {
163 if permissions.deny_read_only {
164 "read-only"
165 } else {
166 "off"
167 }
168}
169
170fn grok_tool_id(name: &str) -> Option<&'static str> {
172 Some(match name {
173 "Read" => "read_file",
174 "Grep" => "grep",
175 "Glob" => "list_dir",
176 "Edit" => "search_replace",
177 "Write" => "write",
178 "Bash" => "run_terminal_cmd",
179 "WebFetch" => "web_fetch",
180 "WebSearch" => "web_search",
181 "Task" => "task",
182 "TodoWrite" => "todo_write",
183 _ => return None,
184 })
185}
186
187fn permission_args(permissions: &PermissionRules) -> Vec<String> {
188 if !permissions.allowed_tools.is_empty() {
189 return allow_args(permissions);
190 }
191 deny_args(&permissions.disallowed_tools)
192}
193
194fn allow_args(permissions: &PermissionRules) -> Vec<String> {
195 let mut tools = Vec::new();
196 let mut allows = Vec::new();
197 for item in &permissions.allowed_tools {
198 if let Some(id) = grok_tool_id(item) {
199 if !tools.contains(&id) {
200 tools.push(id);
201 }
202 if (item == "Edit" || item == "Write") && !permissions.allowed_edit_paths.is_empty() {
203 for path in &permissions.allowed_edit_paths {
204 allows.push(format!("{item}({}/**)", path.display()));
205 }
206 }
207 } else if item.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
208 allows.push(format!("Bash({item} *)"));
211 }
212 }
213 let mut args = Vec::new();
215 if !tools.is_empty() {
216 args.push("--tools".to_string());
217 args.push(shell_single_quote(&tools.join(",")));
218 }
219 for rule in allows {
220 args.push("--allow".to_string());
221 args.push(shell_single_quote(&rule));
222 }
223 args.extend(deny_args(&permissions.disallowed_tools));
224 args
225}
226
227fn deny_args(rules: &[String]) -> Vec<String> {
228 let mut remove = Vec::new();
229 let mut denies = Vec::new();
230 for rule in rules {
231 match rule.as_str() {
232 "Read" | "Grep" | "Glob" | "Edit" | "Write" | "Bash" => {
233 if let Some(id) = grok_tool_id(rule) {
234 if !remove.contains(&id) {
235 remove.push(id);
236 }
237 }
238 }
239 _ => denies.push(rule.as_str()),
240 }
241 }
242 let mut args = Vec::new();
243 if !remove.is_empty() {
244 args.push("--disallowed-tools".to_string());
245 args.push(shell_single_quote(&remove.join(",")));
246 }
247 for rule in denies {
248 args.push("--deny".to_string());
249 args.push(shell_single_quote(rule));
250 }
251 args
252}
253
254fn startup_errors(stdout: &str) -> Vec<String> {
256 let mut out = Vec::new();
257 for line in stdout.lines() {
258 let line = line.trim();
259 if line.is_empty() {
260 continue;
261 }
262 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
263 continue;
264 };
265 if val.get("type").and_then(|t| t.as_str()) != Some("error") {
266 continue;
267 }
268 if let Some(message) = val.get("message").and_then(|m| m.as_str()) {
269 if !message.is_empty() {
270 out.push(message.to_string());
271 }
272 }
273 }
274 out
275}
276
277fn classify_grok_message(message: &str) -> Option<AgentError> {
279 let lower = message.to_lowercase();
280 if lower.contains("not logged in")
281 || lower.contains("please log in")
282 || lower.contains("grok login")
283 || lower.contains("xai_api_key")
284 || lower.contains("authentication failed")
285 || lower.contains("could not refresh")
286 {
287 return Some(AgentError::Auth {
288 message: message.to_string(),
289 });
290 }
291 if lower.contains("permission required") {
292 return Some(AgentError::PermissionDenied {
293 message: message.to_string(),
294 });
295 }
296 None
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::engine::runner::agents::SandboxLevel;
303
304 fn runner() -> GrokRunner {
305 GrokRunner
306 }
307
308 fn perms(autonomous: bool, disallowed: &[&str], allowed: &[&str]) -> PermissionRules {
309 PermissionRules {
310 autonomous,
311 sandbox: SandboxLevel::WorkspaceWrite,
312 disallowed_tools: disallowed.iter().map(|s| (*s).to_string()).collect(),
313 allowed_tools: allowed.iter().map(|s| (*s).to_string()).collect(),
314 allowed_edit_paths: vec![],
315 deny_read_only: false,
316 extra_writable_dirs: vec![],
317 }
318 }
319
320 #[test]
321 fn build_command_autonomous_denies_rm_and_passes_files() {
322 let cmd = runner().build_command(
323 Some("fast"),
324 "timeout 1800",
325 "/tmp/sys.txt",
326 "/tmp/msg.txt",
327 &perms(true, &["Bash(rm *)", "Bash(git push*)"], &[]),
328 );
329 assert!(cmd.starts_with("timeout 1800 grok "), "{cmd}");
330 assert!(cmd.contains("--model 'fast'"), "{cmd}");
331 assert!(
332 cmd.contains("--output-format streaming-messages-json"),
333 "{cmd}"
334 );
335 assert!(cmd.contains("--permission-mode bypassPermissions"), "{cmd}");
336 assert!(cmd.contains("--sandbox off"), "{cmd}");
337 assert!(cmd.contains("--no-plan"), "{cmd}");
338 assert!(cmd.contains("--deny 'Bash(rm *)'"), "{cmd}");
339 assert!(cmd.contains("--deny 'Bash(git push*)'"), "{cmd}");
340 assert!(cmd.contains("--prompt-file '/tmp/msg.txt'"), "{cmd}");
341 assert!(
342 cmd.contains("--system-prompt-override \"$(cat '/tmp/sys.txt')\""),
343 "{cmd}"
344 );
345 assert!(!cmd.contains("< "), "{cmd}");
346 }
347
348 #[test]
349 fn build_command_omits_empty_model() {
350 let cmd = runner().build_command(
351 Some(""),
352 "",
353 "/tmp/sys.txt",
354 "/tmp/msg.txt",
355 &perms(true, &[], &[]),
356 );
357 assert!(!cmd.contains("--model"), "{cmd}");
358 }
359
360 #[test]
361 fn build_command_supervised_accepts_edits() {
362 let cmd = runner().build_command(
363 None,
364 "",
365 "/tmp/sys.txt",
366 "/tmp/msg.txt",
367 &perms(false, &[], &[]),
368 );
369 assert!(cmd.contains("--permission-mode acceptEdits"), "{cmd}");
370 }
371
372 #[test]
373 fn build_command_read_only_removes_read_tools() {
374 let rules = PermissionRules::deny_read_only();
375 let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &rules);
376 assert!(cmd.contains("--sandbox read-only"), "{cmd}");
377 assert!(cmd.contains("--permission-mode bypassPermissions"), "{cmd}");
378 assert!(cmd.contains("read_file"), "{cmd}");
379 assert!(cmd.contains("grep"), "{cmd}");
380 assert!(cmd.contains("list_dir"), "{cmd}");
381 assert!(cmd.contains("--deny 'Bash(cat *)'"), "{cmd}");
382 }
383
384 #[test]
385 fn build_command_allowlist_keeps_destructive_denies() {
386 let mut rules = perms(true, &["Bash(rm *)"], &["Edit", "Write", "Bash", "git"]);
387 rules.allowed_edit_paths = vec![std::path::PathBuf::from("/work/tree")];
388 let cmd = runner().build_command(None, "", "/tmp/sys.txt", "/tmp/msg.txt", &rules);
389 assert!(
390 cmd.contains("--tools 'search_replace,write,run_terminal_cmd'"),
391 "{cmd}"
392 );
393 assert!(cmd.contains("--allow 'Edit(/work/tree/**)'"), "{cmd}");
394 assert!(cmd.contains("--allow 'Write(/work/tree/**)'"), "{cmd}");
395 assert!(cmd.contains("--allow 'Bash(git *)'"), "{cmd}");
396 assert!(cmd.contains("--deny 'Bash(rm *)'"), "{cmd}");
397 }
398
399 #[test]
400 fn parse_response_reads_messages_result() {
401 let raw = concat!(
402 r#"{"type":"system","subtype":"init","model":"test"}"#,
403 "\n",
404 r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"shipped\",\"accomplished\":[\"a\"],\"remaining\":[],\"files\":[]}","usage":{"input_tokens":8,"output_tokens":3},"total_cost_usd":0.02,"duration_ms":40,"stop_reason":"end_turn"}"#,
405 "\n",
406 );
407 let parsed = runner().parse_response(raw).expect("parse");
408 assert_eq!(parsed.response.status, "done");
409 assert_eq!(parsed.response.summary, "shipped");
410 assert_eq!(parsed.input_tokens, Some(8));
411 assert_eq!(parsed.output_tokens, Some(3));
412 }
413
414 #[test]
415 fn find_result_reads_cost_and_max_tokens() {
416 let raw = r#"{"type":"result","subtype":"success","is_error":false,"result":"partial","usage":{"input_tokens":1,"output_tokens":2},"total_cost_usd":1.5,"stop_reason":"max_tokens"}"#;
417 let result = find_grok_result(raw).expect("result");
418 assert_eq!(result.result_text, "partial");
419 assert_eq!(result.cost_usd, Some(1.5));
420 assert!(result.truncated_by_length);
421 assert_eq!(result.input_tokens, Some(1));
422 }
423
424 #[test]
425 fn classify_error_rate_limit_from_error_event() {
426 let stdout = r#"{"type":"error","message":"429 Too Many Requests"}"#;
427 let err = runner().classify_error(1, stdout, "");
428 assert!(matches!(err, AgentError::RateLimit { .. }), "{err}");
429 }
430
431 #[test]
432 fn classify_error_model_unavailable() {
433 let stdout = r#"{"type":"error","message":"The model `missing` is not supported"}"#;
434 let err = runner().classify_error(1, stdout, "");
435 match err {
436 AgentError::ModelUnavailable { model, .. } => assert_eq!(model, "missing"),
437 other => panic!("expected model unavailable, got {other}"),
438 }
439 }
440
441 #[test]
442 fn classify_error_login_is_auth() {
443 let stdout = r#"{"type":"error","message":"Not logged in. Run grok login."}"#;
444 let err = runner().classify_error(1, stdout, "");
445 assert!(matches!(err, AgentError::Auth { .. }), "{err}");
446 }
447
448 #[test]
449 fn classify_error_permission_required() {
450 let stdout =
451 r#"{"type":"error","message":"permission required: Edit calc.py was not run"}"#;
452 let err = runner().classify_error(1, stdout, "");
453 assert!(matches!(err, AgentError::PermissionDenied { .. }), "{err}");
454 }
455}