Skip to main content

codex_hooks/events/
compact.rs

1use std::path::PathBuf;
2
3use codex_protocol::ThreadId;
4use codex_protocol::protocol::HookCompletedEvent;
5use codex_protocol::protocol::HookEventName;
6use codex_protocol::protocol::HookOutputEntry;
7use codex_protocol::protocol::HookOutputEntryKind;
8use codex_protocol::protocol::HookRunStatus;
9use codex_protocol::protocol::HookRunSummary;
10use codex_utils_absolute_path::AbsolutePathBuf;
11
12use super::common;
13use crate::engine::CommandShell;
14use crate::engine::ConfiguredHandler;
15use crate::engine::command_runner::CommandRunResult;
16use crate::engine::dispatcher;
17use crate::engine::output_parser;
18use crate::schema::PostCompactCommandInput;
19use crate::schema::PreCompactCommandInput;
20use crate::schema::SubagentCommandInputFields;
21
22#[derive(Debug, Clone)]
23pub struct PreCompactRequest {
24    pub session_id: ThreadId,
25    pub turn_id: String,
26    pub subagent: Option<common::SubagentHookContext>,
27    pub cwd: AbsolutePathBuf,
28    pub transcript_path: Option<PathBuf>,
29    pub model: String,
30    pub trigger: String,
31}
32
33#[derive(Debug, Clone)]
34pub struct PostCompactRequest {
35    pub session_id: ThreadId,
36    pub turn_id: String,
37    pub subagent: Option<common::SubagentHookContext>,
38    pub cwd: AbsolutePathBuf,
39    pub transcript_path: Option<PathBuf>,
40    pub model: String,
41    pub trigger: String,
42}
43
44#[derive(Debug)]
45pub struct StatelessHookOutcome {
46    pub hook_events: Vec<HookCompletedEvent>,
47    pub should_stop: bool,
48    pub stop_reason: Option<String>,
49}
50
51#[derive(Debug)]
52pub struct PreCompactOutcome {
53    pub hook_events: Vec<HookCompletedEvent>,
54    pub should_stop: bool,
55    pub stop_reason: Option<String>,
56}
57
58pub(crate) fn preview_pre(
59    handlers: &[ConfiguredHandler],
60    request: &PreCompactRequest,
61) -> Vec<HookRunSummary> {
62    dispatcher::select_handlers(
63        handlers,
64        HookEventName::PreCompact,
65        Some(request.trigger.as_str()),
66    )
67    .into_iter()
68    .map(|handler| dispatcher::running_summary(&handler))
69    .collect()
70}
71
72pub(crate) async fn run_pre(
73    handlers: &[ConfiguredHandler],
74    shell: &CommandShell,
75    request: PreCompactRequest,
76) -> PreCompactOutcome {
77    let matched = dispatcher::select_handlers(
78        handlers,
79        HookEventName::PreCompact,
80        Some(request.trigger.as_str()),
81    );
82    if matched.is_empty() {
83        return PreCompactOutcome {
84            hook_events: Vec::new(),
85            should_stop: false,
86            stop_reason: None,
87        };
88    }
89
90    let input_json = match pre_command_input_json(&request) {
91        Ok(input_json) => input_json,
92        Err(error) => {
93            return PreCompactOutcome {
94                hook_events: common::serialization_failure_hook_events(
95                    matched,
96                    Some(request.turn_id),
97                    format!("failed to serialize pre compact hook input: {error}"),
98                ),
99                should_stop: false,
100                stop_reason: None,
101            };
102        }
103    };
104
105    let results = dispatcher::execute_handlers(
106        shell,
107        matched,
108        input_json,
109        request.cwd.as_path(),
110        Some(request.turn_id),
111        parse_pre_completed,
112    )
113    .await;
114    let should_stop = results.iter().any(|result| result.data.should_stop);
115    let stop_reason = results
116        .iter()
117        .find_map(|result| result.data.stop_reason.clone());
118    PreCompactOutcome {
119        hook_events: results.into_iter().map(|result| result.completed).collect(),
120        should_stop,
121        stop_reason,
122    }
123}
124
125fn pre_command_input_json(request: &PreCompactRequest) -> Result<String, serde_json::Error> {
126    let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
127    serde_json::to_string(&PreCompactCommandInput {
128        session_id: request.session_id.to_string(),
129        turn_id: request.turn_id.clone(),
130        agent_id: subagent.agent_id,
131        agent_type: subagent.agent_type,
132        transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
133        cwd: request.cwd.display().to_string(),
134        hook_event_name: "PreCompact".to_string(),
135        model: request.model.clone(),
136        trigger: request.trigger.clone(),
137    })
138}
139
140pub(crate) fn preview_post(
141    handlers: &[ConfiguredHandler],
142    request: &PostCompactRequest,
143) -> Vec<HookRunSummary> {
144    dispatcher::select_handlers(
145        handlers,
146        HookEventName::PostCompact,
147        Some(request.trigger.as_str()),
148    )
149    .into_iter()
150    .map(|handler| dispatcher::running_summary(&handler))
151    .collect()
152}
153
154pub(crate) async fn run_post(
155    handlers: &[ConfiguredHandler],
156    shell: &CommandShell,
157    request: PostCompactRequest,
158) -> StatelessHookOutcome {
159    let matched = dispatcher::select_handlers(
160        handlers,
161        HookEventName::PostCompact,
162        Some(request.trigger.as_str()),
163    );
164    if matched.is_empty() {
165        return StatelessHookOutcome {
166            hook_events: Vec::new(),
167            should_stop: false,
168            stop_reason: None,
169        };
170    }
171
172    let input_json = match post_command_input_json(&request) {
173        Ok(input_json) => input_json,
174        Err(error) => {
175            return StatelessHookOutcome {
176                hook_events: common::serialization_failure_hook_events(
177                    matched,
178                    Some(request.turn_id),
179                    format!("failed to serialize post compact hook input: {error}"),
180                ),
181                should_stop: false,
182                stop_reason: None,
183            };
184        }
185    };
186
187    let results = dispatcher::execute_handlers(
188        shell,
189        matched,
190        input_json,
191        request.cwd.as_path(),
192        Some(request.turn_id),
193        parse_post_completed,
194    )
195    .await;
196    let should_stop = results.iter().any(|result| result.data.should_stop);
197    let stop_reason = results
198        .iter()
199        .find_map(|result| result.data.stop_reason.clone());
200    StatelessHookOutcome {
201        hook_events: results.into_iter().map(|result| result.completed).collect(),
202        should_stop,
203        stop_reason,
204    }
205}
206
207fn post_command_input_json(request: &PostCompactRequest) -> Result<String, serde_json::Error> {
208    let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
209    serde_json::to_string(&PostCompactCommandInput {
210        session_id: request.session_id.to_string(),
211        turn_id: request.turn_id.clone(),
212        agent_id: subagent.agent_id,
213        agent_type: subagent.agent_type,
214        transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
215        cwd: request.cwd.display().to_string(),
216        hook_event_name: "PostCompact".to_string(),
217        model: request.model.clone(),
218        trigger: request.trigger.clone(),
219    })
220}
221
222#[derive(Default)]
223struct CompactHandlerData {
224    should_stop: bool,
225    stop_reason: Option<String>,
226}
227
228fn parse_pre_completed(
229    handler: &ConfiguredHandler,
230    run_result: CommandRunResult,
231    turn_id: Option<String>,
232) -> dispatcher::ParsedHandler<CompactHandlerData> {
233    let mut entries = Vec::new();
234    let mut status = HookRunStatus::Completed;
235    let mut should_stop = false;
236    let mut stop_reason = None;
237
238    match run_result.error.as_deref() {
239        Some(error) => {
240            status = HookRunStatus::Failed;
241            entries.push(HookOutputEntry {
242                kind: HookOutputEntryKind::Error,
243                text: error.to_string(),
244            });
245        }
246        None => match run_result.exit_code {
247            Some(0) => {
248                let trimmed_stdout = run_result.stdout.trim();
249                if trimmed_stdout.is_empty() {
250                } else if let Some(parsed) = output_parser::parse_pre_compact(&run_result.stdout) {
251                    if let Some(system_message) = parsed.universal.system_message {
252                        entries.push(HookOutputEntry {
253                            kind: HookOutputEntryKind::Warning,
254                            text: system_message,
255                        });
256                    }
257                    let _ = parsed.universal.suppress_output;
258                    if !parsed.universal.continue_processing {
259                        status = HookRunStatus::Stopped;
260                        should_stop = true;
261                        stop_reason = parsed.universal.stop_reason.clone();
262                        entries.push(HookOutputEntry {
263                            kind: HookOutputEntryKind::Stop,
264                            text: parsed
265                                .universal
266                                .stop_reason
267                                .unwrap_or_else(|| "PreCompact hook stopped execution".to_string()),
268                        });
269                    } else if let Some(invalid_reason) = parsed.invalid_reason {
270                        status = HookRunStatus::Failed;
271                        entries.push(HookOutputEntry {
272                            kind: HookOutputEntryKind::Error,
273                            text: invalid_reason,
274                        });
275                    }
276                } else if output_parser::looks_like_json(&run_result.stdout) {
277                    status = HookRunStatus::Failed;
278                    entries.push(HookOutputEntry {
279                        kind: HookOutputEntryKind::Error,
280                        text: "hook returned invalid PreCompact hook JSON output".to_string(),
281                    });
282                }
283            }
284            Some(code) => {
285                status = HookRunStatus::Failed;
286                entries.push(HookOutputEntry {
287                    kind: HookOutputEntryKind::Error,
288                    text: common::trimmed_non_empty(&run_result.stderr)
289                        .unwrap_or_else(|| format!("hook exited with code {code}")),
290                });
291            }
292            None => {
293                status = HookRunStatus::Failed;
294                entries.push(HookOutputEntry {
295                    kind: HookOutputEntryKind::Error,
296                    text: "hook process terminated without an exit code".to_string(),
297                });
298            }
299        },
300    }
301
302    dispatcher::ParsedHandler {
303        completed: HookCompletedEvent {
304            turn_id,
305            run: dispatcher::completed_summary(handler, &run_result, status, entries),
306        },
307        data: CompactHandlerData {
308            should_stop,
309            stop_reason,
310        },
311        completion_order: 0,
312    }
313}
314
315fn parse_post_completed(
316    handler: &ConfiguredHandler,
317    run_result: CommandRunResult,
318    turn_id: Option<String>,
319) -> dispatcher::ParsedHandler<CompactHandlerData> {
320    parse_completed(
321        handler,
322        run_result,
323        turn_id,
324        "PostCompact",
325        output_parser::parse_post_compact,
326    )
327}
328
329fn parse_completed(
330    handler: &ConfiguredHandler,
331    run_result: CommandRunResult,
332    turn_id: Option<String>,
333    event_label: &'static str,
334    parse_output: fn(&str) -> Option<output_parser::StatelessHookOutput>,
335) -> dispatcher::ParsedHandler<CompactHandlerData> {
336    let mut entries = Vec::new();
337    let mut status = HookRunStatus::Completed;
338    let mut should_stop = false;
339    let mut stop_reason = None;
340
341    match run_result.error.as_deref() {
342        Some(error) => {
343            status = HookRunStatus::Failed;
344            entries.push(HookOutputEntry {
345                kind: HookOutputEntryKind::Error,
346                text: error.to_string(),
347            });
348        }
349        None => match run_result.exit_code {
350            Some(0) => {
351                let trimmed_stdout = run_result.stdout.trim();
352                if trimmed_stdout.is_empty() {
353                } else if let Some(parsed) = parse_output(&run_result.stdout) {
354                    if let Some(system_message) = parsed.universal.system_message {
355                        entries.push(HookOutputEntry {
356                            kind: HookOutputEntryKind::Warning,
357                            text: system_message,
358                        });
359                    }
360                    let _ = parsed.universal.suppress_output;
361                    if !parsed.universal.continue_processing {
362                        status = HookRunStatus::Stopped;
363                        should_stop = true;
364                        stop_reason = parsed.universal.stop_reason.clone();
365                        entries.push(HookOutputEntry {
366                            kind: HookOutputEntryKind::Stop,
367                            text: parsed
368                                .universal
369                                .stop_reason
370                                .unwrap_or_else(|| format!("{event_label} hook stopped execution")),
371                        });
372                    } else if let Some(invalid_reason) = parsed.invalid_reason {
373                        status = HookRunStatus::Failed;
374                        entries.push(HookOutputEntry {
375                            kind: HookOutputEntryKind::Error,
376                            text: invalid_reason,
377                        });
378                    }
379                } else if output_parser::looks_like_json(&run_result.stdout) {
380                    status = HookRunStatus::Failed;
381                    entries.push(HookOutputEntry {
382                        kind: HookOutputEntryKind::Error,
383                        text: format!("hook returned invalid {event_label} hook JSON output"),
384                    });
385                }
386            }
387            Some(code) => {
388                status = HookRunStatus::Failed;
389                entries.push(HookOutputEntry {
390                    kind: HookOutputEntryKind::Error,
391                    text: common::trimmed_non_empty(&run_result.stderr)
392                        .unwrap_or_else(|| format!("hook exited with code {code}")),
393                });
394            }
395            None => {
396                status = HookRunStatus::Failed;
397                entries.push(HookOutputEntry {
398                    kind: HookOutputEntryKind::Error,
399                    text: "hook process terminated without an exit code".to_string(),
400                });
401            }
402        },
403    }
404
405    dispatcher::ParsedHandler {
406        completed: HookCompletedEvent {
407            turn_id,
408            run: dispatcher::completed_summary(handler, &run_result, status, entries),
409        },
410        data: CompactHandlerData {
411            should_stop,
412            stop_reason,
413        },
414        completion_order: 0,
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use codex_protocol::ThreadId;
421    use codex_protocol::protocol::HookEventName;
422    use codex_protocol::protocol::HookOutputEntry;
423    use codex_protocol::protocol::HookOutputEntryKind;
424    use codex_protocol::protocol::HookRunStatus;
425    use codex_utils_absolute_path::test_support::PathBufExt;
426    use codex_utils_absolute_path::test_support::test_path_buf;
427    use pretty_assertions::assert_eq;
428    use serde_json::json;
429
430    use super::parse_post_completed;
431    use super::parse_pre_completed;
432    use super::post_command_input_json;
433    use super::pre_command_input_json;
434    use crate::engine::ConfiguredHandler;
435    use crate::engine::command_runner::CommandRunResult;
436
437    #[test]
438    fn pre_compact_input_includes_lifecycle_metadata() {
439        let input_json = pre_command_input_json(&pre_request()).expect("serialize command input");
440        let input: serde_json::Value =
441            serde_json::from_str(&input_json).expect("parse command input");
442
443        assert_eq!(
444            input,
445            json!({
446                "session_id": pre_request().session_id.to_string(),
447                "turn_id": "turn-1",
448                "transcript_path": null,
449                "cwd": test_path_buf("/tmp").display().to_string(),
450                "hook_event_name": "PreCompact",
451                "model": "gpt-test",
452                "trigger": "manual",
453            })
454        );
455    }
456
457    #[test]
458    fn post_compact_input_includes_lifecycle_metadata() {
459        let input_json = post_command_input_json(&post_request()).expect("serialize command input");
460        let input: serde_json::Value =
461            serde_json::from_str(&input_json).expect("parse command input");
462
463        assert_eq!(
464            input,
465            json!({
466                "session_id": post_request().session_id.to_string(),
467                "turn_id": "turn-1",
468                "transcript_path": null,
469                "cwd": test_path_buf("/tmp").display().to_string(),
470                "hook_event_name": "PostCompact",
471                "model": "gpt-test",
472                "trigger": "manual",
473            })
474        );
475    }
476
477    #[test]
478    fn block_decision_is_not_supported_for_pre_compact() {
479        let parsed = parse_pre_completed(
480            &handler(HookEventName::PreCompact),
481            run_result(
482                Some(0),
483                r#"{"decision":"block","reason":"policy blocked compaction"}"#,
484                "",
485            ),
486            Some("turn-1".to_string()),
487        );
488
489        assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
490        assert_eq!(
491            parsed.completed.run.entries,
492            vec![HookOutputEntry {
493                kind: HookOutputEntryKind::Error,
494                text: "hook returned invalid PreCompact hook JSON output".to_string(),
495            }]
496        );
497    }
498
499    #[test]
500    fn continue_false_stops_before_compaction() {
501        let parsed = parse_pre_completed(
502            &handler(HookEventName::PreCompact),
503            run_result(Some(0), r#"{"continue":false,"stopReason":"nope"}"#, ""),
504            Some("turn-1".to_string()),
505        );
506
507        assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
508        assert_eq!(parsed.data.should_stop, true);
509        assert_eq!(parsed.data.stop_reason, Some("nope".to_string()));
510        assert_eq!(
511            parsed.completed.run.entries,
512            vec![HookOutputEntry {
513                kind: HookOutputEntryKind::Stop,
514                text: "nope".to_string(),
515            }]
516        );
517    }
518
519    #[test]
520    fn post_compact_continue_false_stops_after_compaction() {
521        let parsed = parse_post_completed(
522            &handler(HookEventName::PostCompact),
523            run_result(
524                Some(0),
525                r#"{"continue":false,"stopReason":"pause after compact"}"#,
526                "",
527            ),
528            Some("turn-1".to_string()),
529        );
530
531        assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
532        assert_eq!(parsed.data.should_stop, true);
533        assert_eq!(
534            parsed.data.stop_reason,
535            Some("pause after compact".to_string())
536        );
537        assert_eq!(
538            parsed.completed.run.entries,
539            vec![HookOutputEntry {
540                kind: HookOutputEntryKind::Stop,
541                text: "pause after compact".to_string(),
542            }]
543        );
544    }
545
546    #[test]
547    fn pre_compact_ignores_plain_stdout() {
548        let parsed = parse_pre_completed(
549            &handler(HookEventName::PreCompact),
550            run_result(Some(0), "checking compact policy\n", ""),
551            Some("turn-1".to_string()),
552        );
553
554        assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
555        assert_eq!(parsed.completed.run.entries, Vec::new());
556    }
557
558    #[test]
559    fn post_compact_ignores_plain_stdout() {
560        let parsed = parse_post_completed(
561            &handler(HookEventName::PostCompact),
562            run_result(Some(0), "logged compact summary\n", ""),
563            Some("turn-1".to_string()),
564        );
565
566        assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
567        assert_eq!(parsed.completed.run.entries, Vec::new());
568    }
569
570    fn pre_request() -> super::PreCompactRequest {
571        super::PreCompactRequest {
572            session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000001")
573                .expect("valid thread id"),
574            turn_id: "turn-1".to_string(),
575            subagent: None,
576            cwd: test_path_buf("/tmp").abs(),
577            transcript_path: None,
578            model: "gpt-test".to_string(),
579            trigger: "manual".to_string(),
580        }
581    }
582
583    fn post_request() -> super::PostCompactRequest {
584        super::PostCompactRequest {
585            session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000002")
586                .expect("valid thread id"),
587            turn_id: "turn-1".to_string(),
588            subagent: None,
589            cwd: test_path_buf("/tmp").abs(),
590            transcript_path: None,
591            model: "gpt-test".to_string(),
592            trigger: "manual".to_string(),
593        }
594    }
595
596    fn handler(event_name: HookEventName) -> ConfiguredHandler {
597        ConfiguredHandler {
598            event_name,
599            matcher: None,
600            command: "python3 compact_hook.py".to_string(),
601            timeout_sec: 5,
602            status_message: Some("running compact hook".to_string()),
603            additional_context_limit: Default::default(),
604            source_path: test_path_buf("/tmp/hooks.json").abs(),
605            source: codex_protocol::protocol::HookSource::User,
606            display_order: 0,
607            env: std::collections::HashMap::new(),
608        }
609    }
610
611    fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
612        CommandRunResult {
613            started_at: 1_700_000_000,
614            completed_at: 1_700_000_001,
615            duration_ms: 12,
616            exit_code,
617            stdout: stdout.to_string(),
618            stderr: stderr.to_string(),
619            error: None,
620        }
621    }
622}