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