Skip to main content

codex_hooks/events/
stop.rs

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