agcodex_exec/
event_processor_with_human_output.rs

1use agcodex_common::elapsed::format_duration;
2use agcodex_common::elapsed::format_elapsed;
3use agcodex_core::config::Config;
4use agcodex_core::plan_tool::UpdatePlanArgs;
5use agcodex_core::protocol::AgentMessageDeltaEvent;
6use agcodex_core::protocol::AgentMessageEvent;
7use agcodex_core::protocol::AgentReasoningDeltaEvent;
8use agcodex_core::protocol::AgentReasoningRawContentDeltaEvent;
9use agcodex_core::protocol::AgentReasoningRawContentEvent;
10use agcodex_core::protocol::BackgroundEventEvent;
11use agcodex_core::protocol::ErrorEvent;
12use agcodex_core::protocol::Event;
13use agcodex_core::protocol::EventMsg;
14use agcodex_core::protocol::ExecCommandBeginEvent;
15use agcodex_core::protocol::ExecCommandEndEvent;
16use agcodex_core::protocol::FileChange;
17use agcodex_core::protocol::McpInvocation;
18use agcodex_core::protocol::McpToolCallBeginEvent;
19use agcodex_core::protocol::McpToolCallEndEvent;
20use agcodex_core::protocol::PatchApplyBeginEvent;
21use agcodex_core::protocol::PatchApplyEndEvent;
22use agcodex_core::protocol::SessionConfiguredEvent;
23use agcodex_core::protocol::TaskCompleteEvent;
24use agcodex_core::protocol::TurnAbortReason;
25use agcodex_core::protocol::TurnDiffEvent;
26use owo_colors::OwoColorize;
27use owo_colors::Style;
28use shlex::try_join;
29use std::collections::HashMap;
30use std::io::Write;
31use std::path::PathBuf;
32use std::time::Instant;
33
34use crate::event_processor::CodexStatus;
35use crate::event_processor::EventProcessor;
36use crate::event_processor::handle_last_message;
37use agcodex_common::create_config_summary_entries;
38
39/// This should be configurable. When used in CI, users may not want to impose
40/// a limit so they can see the full transcript.
41const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20;
42pub(crate) struct EventProcessorWithHumanOutput {
43    call_id_to_command: HashMap<String, ExecCommandBegin>,
44    call_id_to_patch: HashMap<String, PatchApplyBegin>,
45
46    // To ensure that --color=never is respected, ANSI escapes _must_ be added
47    // using .style() with one of these fields. If you need a new style, add a
48    // new field here.
49    bold: Style,
50    italic: Style,
51    dimmed: Style,
52
53    magenta: Style,
54    red: Style,
55    green: Style,
56    cyan: Style,
57
58    /// Whether to include `AgentReasoning` events in the output.
59    show_agent_reasoning: bool,
60    show_raw_agent_reasoning: bool,
61    answer_started: bool,
62    reasoning_started: bool,
63    raw_reasoning_started: bool,
64    last_message_path: Option<PathBuf>,
65}
66
67impl EventProcessorWithHumanOutput {
68    pub(crate) fn create_with_ansi(
69        with_ansi: bool,
70        config: &Config,
71        last_message_path: Option<PathBuf>,
72    ) -> Self {
73        let call_id_to_command = HashMap::new();
74        let call_id_to_patch = HashMap::new();
75
76        if with_ansi {
77            Self {
78                call_id_to_command,
79                call_id_to_patch,
80                bold: Style::new().bold(),
81                italic: Style::new().italic(),
82                dimmed: Style::new().dimmed(),
83                magenta: Style::new().magenta(),
84                red: Style::new().red(),
85                green: Style::new().green(),
86                cyan: Style::new().cyan(),
87                show_agent_reasoning: !config.hide_agent_reasoning,
88                show_raw_agent_reasoning: config.show_raw_agent_reasoning,
89                answer_started: false,
90                reasoning_started: false,
91                raw_reasoning_started: false,
92                last_message_path,
93            }
94        } else {
95            Self {
96                call_id_to_command,
97                call_id_to_patch,
98                bold: Style::new(),
99                italic: Style::new(),
100                dimmed: Style::new(),
101                magenta: Style::new(),
102                red: Style::new(),
103                green: Style::new(),
104                cyan: Style::new(),
105                show_agent_reasoning: !config.hide_agent_reasoning,
106                show_raw_agent_reasoning: config.show_raw_agent_reasoning,
107                answer_started: false,
108                reasoning_started: false,
109                raw_reasoning_started: false,
110                last_message_path,
111            }
112        }
113    }
114}
115
116struct ExecCommandBegin {
117    command: Vec<String>,
118}
119
120struct PatchApplyBegin {
121    start_time: Instant,
122    auto_approved: bool,
123}
124
125// Timestamped println helper. The timestamp is styled with self.dimmed.
126#[macro_export]
127macro_rules! ts_println {
128    ($self:ident, $($arg:tt)*) => {{
129        let now = chrono::Utc::now();
130        let formatted = now.format("[%Y-%m-%dT%H:%M:%S]");
131        print!("{} ", formatted.style($self.dimmed));
132        println!($($arg)*);
133    }};
134}
135
136impl EventProcessor for EventProcessorWithHumanOutput {
137    /// Print a concise summary of the effective configuration that will be used
138    /// for the session. This mirrors the information shown in the TUI welcome
139    /// screen.
140    fn print_config_summary(&mut self, config: &Config, prompt: &str) {
141        const VERSION: &str = env!("CARGO_PKG_VERSION");
142        ts_println!(
143            self,
144            "OpenAI Codex v{} (research preview)\n--------",
145            VERSION
146        );
147
148        let entries = create_config_summary_entries(config);
149
150        for (key, value) in entries {
151            println!("{} {}", format!("{key}:").style(self.bold), value);
152        }
153
154        println!("--------");
155
156        // Echo the prompt that will be sent to the agent so it is visible in the
157        // transcript/logs before any events come in. Note the prompt may have been
158        // read from stdin, so it may not be visible in the terminal otherwise.
159        ts_println!(
160            self,
161            "{}\n{}",
162            "User instructions:".style(self.bold).style(self.cyan),
163            prompt
164        );
165    }
166
167    fn process_event(&mut self, event: Event) -> CodexStatus {
168        let Event { id: _, msg } = event;
169        match msg {
170            EventMsg::Error(ErrorEvent { message }) => {
171                let prefix = "ERROR:".style(self.red);
172                ts_println!(self, "{prefix} {message}");
173            }
174            EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
175                ts_println!(self, "{}", message.style(self.dimmed));
176            }
177            EventMsg::TaskStarted => {
178                // Ignore.
179            }
180            EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
181                if let Some(output_file) = self.last_message_path.as_deref() {
182                    handle_last_message(last_agent_message.as_deref(), output_file);
183                }
184                return CodexStatus::InitiateShutdown;
185            }
186            EventMsg::TokenCount(token_usage) => {
187                ts_println!(self, "tokens used: {}", token_usage.blended_total());
188            }
189            EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
190                if !self.answer_started {
191                    ts_println!(
192                        self,
193                        "{}\n",
194                        "agcodex".style(self.italic).style(self.magenta)
195                    );
196                    self.answer_started = true;
197                }
198                print!("{delta}");
199                #[expect(clippy::expect_used)]
200                std::io::stdout().flush().expect("could not flush stdout");
201            }
202            EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
203                if !self.show_agent_reasoning {
204                    return CodexStatus::Running;
205                }
206                if !self.reasoning_started {
207                    ts_println!(
208                        self,
209                        "{}\n",
210                        "thinking".style(self.italic).style(self.magenta),
211                    );
212                    self.reasoning_started = true;
213                }
214                print!("{delta}");
215                #[expect(clippy::expect_used)]
216                std::io::stdout().flush().expect("could not flush stdout");
217            }
218            EventMsg::AgentReasoningSectionBreak(_) => {
219                if !self.show_agent_reasoning {
220                    return CodexStatus::Running;
221                }
222                println!();
223                #[expect(clippy::expect_used)]
224                std::io::stdout().flush().expect("could not flush stdout");
225            }
226            EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
227                if !self.show_raw_agent_reasoning {
228                    return CodexStatus::Running;
229                }
230                if !self.raw_reasoning_started {
231                    print!("{text}");
232                    #[expect(clippy::expect_used)]
233                    std::io::stdout().flush().expect("could not flush stdout");
234                } else {
235                    println!();
236                    self.raw_reasoning_started = false;
237                }
238            }
239            EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
240                delta,
241            }) => {
242                if !self.show_raw_agent_reasoning {
243                    return CodexStatus::Running;
244                }
245                if !self.raw_reasoning_started {
246                    self.raw_reasoning_started = true;
247                }
248                print!("{delta}");
249                #[expect(clippy::expect_used)]
250                std::io::stdout().flush().expect("could not flush stdout");
251            }
252            EventMsg::AgentMessage(AgentMessageEvent { message }) => {
253                // if answer_started is false, this means we haven't received any
254                // delta. Thus, we need to print the message as a new answer.
255                if !self.answer_started {
256                    ts_println!(
257                        self,
258                        "{}\n{}",
259                        "agcodex".style(self.italic).style(self.magenta),
260                        message,
261                    );
262                } else {
263                    println!();
264                    self.answer_started = false;
265                }
266            }
267            EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
268                call_id,
269                command,
270                cwd,
271                parsed_cmd: _,
272            }) => {
273                self.call_id_to_command.insert(
274                    call_id.clone(),
275                    ExecCommandBegin {
276                        command: command.clone(),
277                    },
278                );
279                ts_println!(
280                    self,
281                    "{} {} in {}",
282                    "exec".style(self.magenta),
283                    escape_command(&command).style(self.bold),
284                    cwd.to_string_lossy(),
285                );
286            }
287            EventMsg::ExecCommandOutputDelta(_) => {}
288            EventMsg::ExecCommandEnd(ExecCommandEndEvent {
289                call_id,
290                stdout,
291                stderr,
292                duration,
293                exit_code,
294            }) => {
295                let exec_command = self.call_id_to_command.remove(&call_id);
296                let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command
297                {
298                    (
299                        format!(" in {}", format_duration(duration)),
300                        format!("{}", escape_command(&command).style(self.bold)),
301                    )
302                } else {
303                    ("".to_string(), format!("exec('{call_id}')"))
304                };
305
306                let output = if exit_code == 0 { stdout } else { stderr };
307                let truncated_output = output
308                    .lines()
309                    .take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL)
310                    .collect::<Vec<_>>()
311                    .join("\n");
312                match exit_code {
313                    0 => {
314                        let title = format!("{call} succeeded{duration}:");
315                        ts_println!(self, "{}", title.style(self.green));
316                    }
317                    _ => {
318                        let title = format!("{call} exited {exit_code}{duration}:");
319                        ts_println!(self, "{}", title.style(self.red));
320                    }
321                }
322                println!("{}", truncated_output.style(self.dimmed));
323            }
324            EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
325                call_id: _,
326                invocation,
327            }) => {
328                ts_println!(
329                    self,
330                    "{} {}",
331                    "tool".style(self.magenta),
332                    format_mcp_invocation(&invocation).style(self.bold),
333                );
334            }
335            EventMsg::McpToolCallEnd(tool_call_end_event) => {
336                let is_success = tool_call_end_event.is_success();
337                let McpToolCallEndEvent {
338                    call_id: _,
339                    result,
340                    invocation,
341                    duration,
342                } = tool_call_end_event;
343
344                let duration = format!(" in {}", format_duration(duration));
345
346                let status_str = if is_success { "success" } else { "failed" };
347                let title_style = if is_success { self.green } else { self.red };
348                let title = format!(
349                    "{} {status_str}{duration}:",
350                    format_mcp_invocation(&invocation)
351                );
352
353                ts_println!(self, "{}", title.style(title_style));
354
355                if let Ok(res) = result {
356                    let val: serde_json::Value = res.into();
357                    let pretty =
358                        serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string());
359
360                    for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) {
361                        println!("{}", line.style(self.dimmed));
362                    }
363                }
364            }
365            EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
366                call_id,
367                auto_approved,
368                changes,
369            }) => {
370                // Store metadata so we can calculate duration later when we
371                // receive the corresponding PatchApplyEnd event.
372                self.call_id_to_patch.insert(
373                    call_id.clone(),
374                    PatchApplyBegin {
375                        start_time: Instant::now(),
376                        auto_approved,
377                    },
378                );
379
380                ts_println!(
381                    self,
382                    "{} auto_approved={}:",
383                    "apply_patch".style(self.magenta),
384                    auto_approved,
385                );
386
387                // Pretty-print the patch summary with colored diff markers so
388                // it's easy to scan in the terminal output.
389                for (path, change) in changes.iter() {
390                    match change {
391                        FileChange::Add { content } => {
392                            let header = format!(
393                                "{} {}",
394                                format_file_change(change),
395                                path.to_string_lossy()
396                            );
397                            println!("{}", header.style(self.magenta));
398                            for line in content.lines() {
399                                println!("{}", line.style(self.green));
400                            }
401                        }
402                        FileChange::Delete => {
403                            let header = format!(
404                                "{} {}",
405                                format_file_change(change),
406                                path.to_string_lossy()
407                            );
408                            println!("{}", header.style(self.magenta));
409                        }
410                        FileChange::Update {
411                            unified_diff,
412                            move_path,
413                        } => {
414                            let header = if let Some(dest) = move_path {
415                                format!(
416                                    "{} {} -> {}",
417                                    format_file_change(change),
418                                    path.to_string_lossy(),
419                                    dest.to_string_lossy()
420                                )
421                            } else {
422                                format!("{} {}", format_file_change(change), path.to_string_lossy())
423                            };
424                            println!("{}", header.style(self.magenta));
425
426                            // Colorize diff lines. We keep file header lines
427                            // (--- / +++) without extra coloring so they are
428                            // still readable.
429                            for diff_line in unified_diff.lines() {
430                                if diff_line.starts_with('+') && !diff_line.starts_with("+++") {
431                                    println!("{}", diff_line.style(self.green));
432                                } else if diff_line.starts_with('-')
433                                    && !diff_line.starts_with("---")
434                                {
435                                    println!("{}", diff_line.style(self.red));
436                                } else {
437                                    println!("{diff_line}");
438                                }
439                            }
440                        }
441                    }
442                }
443            }
444            EventMsg::PatchApplyEnd(PatchApplyEndEvent {
445                call_id,
446                stdout,
447                stderr,
448                success,
449                ..
450            }) => {
451                let patch_begin = self.call_id_to_patch.remove(&call_id);
452
453                // Compute duration and summary label similar to exec commands.
454                let (duration, label) = if let Some(PatchApplyBegin {
455                    start_time,
456                    auto_approved,
457                }) = patch_begin
458                {
459                    (
460                        format!(" in {}", format_elapsed(start_time)),
461                        format!("apply_patch(auto_approved={auto_approved})"),
462                    )
463                } else {
464                    (String::new(), format!("apply_patch('{call_id}')"))
465                };
466
467                let (exit_code, output, title_style) = if success {
468                    (0, stdout, self.green)
469                } else {
470                    (1, stderr, self.red)
471                };
472
473                let title = format!("{label} exited {exit_code}{duration}:");
474                ts_println!(self, "{}", title.style(title_style));
475                for line in output.lines() {
476                    println!("{}", line.style(self.dimmed));
477                }
478            }
479            EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
480                ts_println!(self, "{}", "turn diff:".style(self.magenta));
481                println!("{unified_diff}");
482            }
483            EventMsg::ExecApprovalRequest(_) => {
484                // Should we exit?
485            }
486            EventMsg::ApplyPatchApprovalRequest(_) => {
487                // Should we exit?
488            }
489            EventMsg::AgentReasoning(agent_reasoning_event) => {
490                if self.show_agent_reasoning {
491                    if !self.reasoning_started {
492                        ts_println!(
493                            self,
494                            "{}\n{}",
495                            "agcodex".style(self.italic).style(self.magenta),
496                            agent_reasoning_event.text,
497                        );
498                    } else {
499                        println!();
500                        self.reasoning_started = false;
501                    }
502                }
503            }
504            EventMsg::SessionConfigured(session_configured_event) => {
505                let SessionConfiguredEvent {
506                    session_id,
507                    model,
508                    history_log_id: _,
509                    history_entry_count: _,
510                } = session_configured_event;
511
512                ts_println!(
513                    self,
514                    "{} {}",
515                    "codex session".style(self.magenta).style(self.bold),
516                    session_id.to_string().style(self.dimmed)
517                );
518
519                ts_println!(self, "model: {}", model);
520                println!();
521            }
522            EventMsg::PlanUpdate(plan_update_event) => {
523                let UpdatePlanArgs { explanation, plan } = plan_update_event;
524                ts_println!(self, "explanation: {explanation:?}");
525                ts_println!(self, "plan: {plan:?}");
526            }
527            EventMsg::GetHistoryEntryResponse(_) => {
528                // Currently ignored in exec output.
529            }
530            EventMsg::McpListToolsResponse(_) => {
531                // Currently ignored in exec output.
532            }
533            EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
534                TurnAbortReason::Interrupted => {
535                    ts_println!(self, "task interrupted");
536                }
537                TurnAbortReason::Replaced => {
538                    ts_println!(self, "task aborted: replaced by a new task");
539                }
540            },
541            EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
542        }
543        CodexStatus::Running
544    }
545}
546
547fn escape_command(command: &[String]) -> String {
548    try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" "))
549}
550
551const fn format_file_change(change: &FileChange) -> &'static str {
552    match change {
553        FileChange::Add { .. } => "A",
554        FileChange::Delete => "D",
555        FileChange::Update {
556            move_path: Some(_), ..
557        } => "R",
558        FileChange::Update {
559            move_path: None, ..
560        } => "M",
561    }
562}
563
564fn format_mcp_invocation(invocation: &McpInvocation) -> String {
565    // Build fully-qualified tool name: server.tool
566    let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool);
567
568    // Format arguments as compact JSON so they fit on one line.
569    let args_str = invocation
570        .arguments
571        .as_ref()
572        .map(|v: &serde_json::Value| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()))
573        .unwrap_or_default();
574
575    if args_str.is_empty() {
576        format!("{fq_tool_name}()")
577    } else {
578        format!("{fq_tool_name}({args_str})")
579    }
580}