1use std::path::PathBuf;
2
3use async_trait::async_trait;
4
5use crate::config::{PermissionMode, TaskConfig};
6use crate::error::{Error, Result};
7use crate::event::*;
8use crate::process::{spawn_and_stream, StreamHandle};
9use crate::runner::AgentRunner;
10
11pub struct CodexRunner;
25
26#[async_trait]
27impl AgentRunner for CodexRunner {
28 fn name(&self) -> &str {
29 "codex"
30 }
31
32 fn is_available(&self) -> bool {
33 crate::runner::is_any_binary_available(crate::config::AgentKind::Codex)
34 }
35
36 fn binary_path(&self, config: &TaskConfig) -> Result<PathBuf> {
37 crate::runner::resolve_binary(crate::config::AgentKind::Codex, config)
38 }
39
40 fn build_args(&self, config: &TaskConfig) -> Vec<String> {
41 let mut args = vec!["exec".to_string(), "--json".to_string()];
42
43 if let Some(ref model) = config.model {
44 args.push("--model".to_string());
45 args.push(model.clone());
46 }
47
48 match config.permission_mode {
50 PermissionMode::FullAccess => {
51 args.push("--sandbox".to_string());
52 args.push("danger-full-access".to_string());
53 args.push("--dangerously-bypass-approvals-and-sandbox".to_string());
54 }
55 PermissionMode::ReadOnly => {
56 args.push("--sandbox".to_string());
57 args.push("read-only".to_string());
58 }
59 }
60
61 args.extend(config.extra_args.iter().cloned());
62
63 args.push(config.prompt.clone());
65 args
66 }
67
68 fn build_env(&self, _config: &TaskConfig) -> Vec<(String, String)> {
69 vec![]
71 }
72
73 async fn run(
74 &self,
75 config: &TaskConfig,
76 cancel_token: Option<tokio_util::sync::CancellationToken>,
77 ) -> Result<StreamHandle> {
78 spawn_and_stream(self, config, parse_codex_line, cancel_token).await
79 }
80
81 fn capabilities(&self) -> crate::runner::AgentCapabilities {
82 crate::runner::AgentCapabilities {
83 supports_system_prompt: false,
84 supports_budget: false,
85 supports_model: true,
86 supports_max_turns: false,
87 supports_append_system_prompt: false,
88 }
89 }
90}
91
92fn parse_codex_line(line: &str) -> Vec<Result<Event>> {
93 let value: serde_json::Value = match serde_json::from_str(line) {
94 Ok(v) => v,
95 Err(e) => return vec![Err(Error::ParseError(format!("invalid JSON: {e}: {line}")))],
96 };
97
98 let event_type = match value.get("type").and_then(|v| v.as_str()) {
99 Some(t) => t,
100 None => return vec![],
101 };
102
103 match event_type {
104 "thread.started" => {
105 let thread_id = value
106 .get("thread_id")
107 .and_then(|v| v.as_str())
108 .unwrap_or("unknown")
109 .to_string();
110 vec![Ok(Event::SessionStart(SessionStartEvent {
111 session_id: thread_id,
112 agent: "codex".to_string(),
113 model: value
114 .get("model")
115 .and_then(|v| v.as_str())
116 .map(|s| s.to_string()),
117 cwd: None,
118 timestamp_ms: 0,
119 }))]
120 }
121
122 "item.started" => {
123 let item = match value.get("item") {
124 Some(i) => i,
125 None => return vec![],
126 };
127 let item_type = match item.get("type").and_then(|v| v.as_str()) {
128 Some(t) => t,
129 None => return vec![],
130 };
131
132 match item_type {
133 "command_execution" => {
134 let call_id = item
135 .get("id")
136 .and_then(|v| v.as_str())
137 .unwrap_or("unknown")
138 .to_string();
139 let command = item
140 .get("command")
141 .and_then(|v| v.as_str())
142 .unwrap_or("")
143 .to_string();
144 vec![Ok(Event::ToolStart(ToolStartEvent {
145 call_id,
146 tool_name: "shell".to_string(),
147 input: Some(serde_json::json!({ "command": command })),
148 timestamp_ms: 0,
149 }))]
150 }
151 _ => vec![],
152 }
153 }
154
155 "item.completed" => {
156 let item = match value.get("item") {
157 Some(i) => i,
158 None => return vec![],
159 };
160 let item_type = match item.get("type").and_then(|v| v.as_str()) {
161 Some(t) => t,
162 None => return vec![],
163 };
164
165 match item_type {
166 "agent_message" | "message" => {
167 let text = item
170 .get("text")
171 .and_then(|v| v.as_str())
172 .map(String::from)
173 .or_else(|| {
174 item.get("content")
175 .and_then(|v| v.as_array())
176 .map(|arr| {
177 arr.iter()
178 .filter_map(|c| c.get("text").and_then(|v| v.as_str()))
179 .collect::<Vec<_>>()
180 .join("")
181 })
182 })
183 .or_else(|| {
184 item.get("content")
185 .and_then(|v| v.as_str())
186 .map(String::from)
187 })
188 .unwrap_or_default();
189
190 if text.is_empty() {
191 return vec![];
192 }
193
194 let role_str = item
195 .get("role")
196 .and_then(|v| v.as_str())
197 .unwrap_or("assistant");
198 let role = match role_str {
199 "user" => Role::User,
200 "system" => Role::System,
201 _ => Role::Assistant,
202 };
203
204 vec![Ok(Event::Message(MessageEvent {
205 role,
206 text,
207 usage: None,
208 timestamp_ms: 0,
209 }))]
210 }
211
212 "command_execution" | "command" | "shell" => {
213 let call_id = item
214 .get("id")
215 .and_then(|v| v.as_str())
216 .unwrap_or("unknown")
217 .to_string();
218 let command = item
219 .get("command")
220 .and_then(|v| v.as_str())
221 .unwrap_or("")
222 .to_string();
223 let exit_code = item.get("exit_code").and_then(|v| v.as_i64());
224 let success = exit_code.map(|c| c == 0).unwrap_or(true);
225 let output = item
226 .get("aggregated_output")
227 .or_else(|| item.get("output"))
228 .and_then(|v| v.as_str())
229 .map(|s| s.to_string());
230
231 vec![Ok(Event::ToolEnd(ToolEndEvent {
234 call_id: call_id.clone(),
235 tool_name: "shell".to_string(),
236 success,
237 output: output.or_else(|| Some(serde_json::json!({ "command": command }).to_string())),
238 usage: None,
239 timestamp_ms: 0,
240 }))]
241 }
242
243 "file_change" => {
244 let call_id = item
245 .get("id")
246 .and_then(|v| v.as_str())
247 .unwrap_or("unknown")
248 .to_string();
249 let path = item
250 .get("path")
251 .and_then(|v| v.as_str())
252 .unwrap_or("")
253 .to_string();
254
255 vec![
257 Ok(Event::ToolStart(ToolStartEvent {
258 call_id: call_id.clone(),
259 tool_name: "file_change".to_string(),
260 input: Some(serde_json::json!({ "path": path })),
261 timestamp_ms: 0,
262 })),
263 Ok(Event::ToolEnd(ToolEndEvent {
264 call_id,
265 tool_name: "file_change".to_string(),
266 success: true,
267 output: None,
268 usage: None,
269 timestamp_ms: 0,
270 })),
271 ]
272 }
273
274 _ => vec![],
276 }
277 }
278
279 "item.created" => {
281 let mut patched = value.clone();
283 patched["type"] = serde_json::json!("item.completed");
284 parse_codex_line(&patched.to_string())
285 }
286
287 "turn.completed" => {
288 let usage = value.get("usage").map(|u| UsageData {
290 input_tokens: u.get("input_tokens").and_then(|v| v.as_u64()),
291 output_tokens: u.get("output_tokens").and_then(|v| v.as_u64()),
292 cache_read_tokens: u.get("cached_input_tokens").and_then(|v| v.as_u64()),
293 cache_creation_tokens: None,
294 cost_usd: None,
295 });
296
297 let mut events = Vec::new();
298
299 if let Some(ref u) = usage {
300 events.push(Ok(Event::UsageDelta(UsageDeltaEvent {
301 usage: u.clone(),
302 timestamp_ms: 0,
303 })));
304 }
305
306 events.push(Ok(Event::Result(ResultEvent {
309 success: true,
310 text: String::new(),
311 session_id: String::new(),
312 duration_ms: None,
313 total_cost_usd: None,
314 usage,
315 timestamp_ms: 0,
316 })));
317
318 events
319 }
320
321 "turn.failed" => {
322 let error_msg = value
323 .get("error")
324 .and_then(|v| v.as_str())
325 .or_else(|| value.get("message").and_then(|v| v.as_str()))
326 .unwrap_or("turn failed")
327 .to_string();
328 vec![Ok(Event::Error(ErrorEvent {
329 message: error_msg,
330 code: Some("turn_failed".into()),
331 timestamp_ms: 0,
332 }))]
333 }
334
335 "thread.completed" => {
336 let text = value
337 .get("summary")
338 .or_else(|| value.get("result"))
339 .and_then(|v| v.as_str())
340 .unwrap_or("")
341 .to_string();
342 let thread_id = value
343 .get("thread_id")
344 .and_then(|v| v.as_str())
345 .unwrap_or("")
346 .to_string();
347
348 vec![Ok(Event::Result(ResultEvent {
349 success: true,
350 text,
351 session_id: thread_id,
352 duration_ms: value.get("duration_ms").and_then(|v| v.as_u64()),
353 total_cost_usd: None,
354 usage: None,
355 timestamp_ms: 0,
356 }))]
357 }
358
359 "error" => {
360 let msg = value
361 .get("message")
362 .and_then(|v| v.as_str())
363 .unwrap_or("unknown error")
364 .to_string();
365 let code = value
366 .get("code")
367 .and_then(|v| v.as_str())
368 .map(|s| s.to_string());
369 vec![Ok(Event::Error(ErrorEvent {
370 message: msg,
371 code,
372 timestamp_ms: 0,
373 }))]
374 }
375
376 _ => vec![],
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
388 fn parse_thread_started() {
389 let line = r#"{"type":"thread.started","thread_id":"th-123"}"#;
390 let events = parse_codex_line(line);
391 assert_eq!(events.len(), 1);
392 match &events[0] {
393 Ok(Event::SessionStart(s)) => {
394 assert_eq!(s.session_id, "th-123");
395 assert_eq!(s.agent, "codex");
396 }
397 other => panic!("expected SessionStart, got {other:?}"),
398 }
399 }
400
401 #[test]
402 fn parse_agent_message() {
403 let line = r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"Hello!"}}"#;
404 let events = parse_codex_line(line);
405 assert_eq!(events.len(), 1);
406 match &events[0] {
407 Ok(Event::Message(m)) => {
408 assert_eq!(m.role, Role::Assistant);
409 assert_eq!(m.text, "Hello!");
410 }
411 other => panic!("expected Message, got {other:?}"),
412 }
413 }
414
415 #[test]
416 fn parse_command_started() {
417 let line = r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/bash -lc 'ls'","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#;
418 let events = parse_codex_line(line);
419 assert_eq!(events.len(), 1);
420 assert!(matches!(&events[0], Ok(Event::ToolStart(t)) if t.tool_name == "shell" && t.call_id == "item_1"));
421 }
422
423 #[test]
424 fn parse_command_completed() {
425 let line = r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"ls","aggregated_output":"file.txt\n","exit_code":0,"status":"completed"}}"#;
426 let events = parse_codex_line(line);
427 assert_eq!(events.len(), 1);
428 match &events[0] {
429 Ok(Event::ToolEnd(t)) => {
430 assert_eq!(t.call_id, "item_1");
431 assert_eq!(t.tool_name, "shell");
432 assert!(t.success);
433 assert_eq!(t.output, Some("file.txt\n".into()));
434 }
435 other => panic!("expected ToolEnd, got {other:?}"),
436 }
437 }
438
439 #[test]
440 fn parse_command_failed() {
441 let line = r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"false","aggregated_output":"","exit_code":1,"status":"completed"}}"#;
442 let events = parse_codex_line(line);
443 assert_eq!(events.len(), 1);
444 match &events[0] {
445 Ok(Event::ToolEnd(t)) => {
446 assert!(!t.success);
447 }
448 other => panic!("expected ToolEnd, got {other:?}"),
449 }
450 }
451
452 #[test]
453 fn parse_turn_completed() {
454 let line = r#"{"type":"turn.completed","usage":{"input_tokens":8587,"cached_input_tokens":7808,"output_tokens":24}}"#;
455 let events = parse_codex_line(line);
456 assert!(events.len() >= 2);
457 assert!(events.iter().any(|e| matches!(e, Ok(Event::UsageDelta(u)) if u.usage.input_tokens == Some(8587))));
458 assert!(events.iter().any(|e| matches!(e, Ok(Event::Result(r)) if r.success)));
459 }
460
461 #[test]
462 fn parse_file_change() {
463 let line = r#"{"type":"item.completed","item":{"type":"file_change","id":"fc-1","path":"src/main.rs"}}"#;
464 let events = parse_codex_line(line);
465 assert_eq!(events.len(), 2, "expected ToolStart + ToolEnd");
466 assert!(matches!(&events[0], Ok(Event::ToolStart(t)) if t.tool_name == "file_change"));
467 assert!(matches!(&events[1], Ok(Event::ToolEnd(t)) if t.tool_name == "file_change" && t.success));
468 }
469
470 #[test]
471 fn parse_turn_failed() {
472 let line = r#"{"type":"turn.failed","error":"rate limit exceeded"}"#;
473 let events = parse_codex_line(line);
474 assert_eq!(events.len(), 1);
475 match &events[0] {
476 Ok(Event::Error(e)) => {
477 assert_eq!(e.message, "rate limit exceeded");
478 }
479 other => panic!("expected Error, got {other:?}"),
480 }
481 }
482
483 #[test]
484 fn parse_error_event() {
485 let line = r#"{"type":"error","message":"rate limit exceeded","code":"rate_limit"}"#;
486 let events = parse_codex_line(line);
487 assert_eq!(events.len(), 1);
488 match &events[0] {
489 Ok(Event::Error(e)) => {
490 assert_eq!(e.message, "rate limit exceeded");
491 assert_eq!(e.code, Some("rate_limit".into()));
492 }
493 other => panic!("expected Error, got {other:?}"),
494 }
495 }
496
497 #[test]
498 fn parse_reasoning_item_skipped() {
499 let line = r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"thinking..."}}"#;
500 let events = parse_codex_line(line);
501 assert!(events.is_empty(), "reasoning items should be skipped");
502 }
503
504 #[test]
507 fn parse_legacy_item_created_message() {
508 let line = r#"{"type":"item.created","item":{"type":"message","role":"assistant","content":[{"text":"Hello"}]}}"#;
509 let events = parse_codex_line(line);
510 assert_eq!(events.len(), 1);
511 match &events[0] {
512 Ok(Event::Message(m)) => {
513 assert_eq!(m.role, Role::Assistant);
514 assert_eq!(m.text, "Hello");
515 }
516 other => panic!("expected Message, got {other:?}"),
517 }
518 }
519
520 #[test]
521 fn parse_legacy_thread_completed() {
522 let line = r#"{"type":"thread.completed","thread_id":"th-123","summary":"All done","duration_ms":5000}"#;
523 let events = parse_codex_line(line);
524 assert_eq!(events.len(), 1);
525 match &events[0] {
526 Ok(Event::Result(r)) => {
527 assert!(r.success);
528 assert_eq!(r.text, "All done");
529 assert_eq!(r.session_id, "th-123");
530 assert_eq!(r.duration_ms, Some(5000));
531 }
532 other => panic!("expected Result, got {other:?}"),
533 }
534 }
535
536 #[test]
539 fn build_args_full_access() {
540 let config = TaskConfig::new("do it", crate::config::AgentKind::Codex);
541 let runner = CodexRunner;
542 let args = runner.build_args(&config);
543 assert!(args.contains(&"exec".to_string()));
544 assert!(args.contains(&"--json".to_string()));
545 assert!(args.contains(&"--sandbox".to_string()));
546 assert!(args.contains(&"danger-full-access".to_string()));
547 assert!(args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
548 assert_eq!(args.last().unwrap(), "do it");
549 }
550
551 #[test]
552 fn build_args_read_only() {
553 let mut config = TaskConfig::new("analyze", crate::config::AgentKind::Codex);
554 config.permission_mode = PermissionMode::ReadOnly;
555 let runner = CodexRunner;
556 let args = runner.build_args(&config);
557 assert!(args.contains(&"--sandbox".to_string()));
558 assert!(args.contains(&"read-only".to_string()));
559 assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
560 }
561
562 #[test]
563 fn build_args_with_model() {
564 let mut config = TaskConfig::new("do it", crate::config::AgentKind::Codex);
565 config.model = Some("gpt-5-codex".into());
566 let runner = CodexRunner;
567 let args = runner.build_args(&config);
568 assert!(args.contains(&"--model".to_string()));
569 assert!(args.contains(&"gpt-5-codex".to_string()));
570 }
571}