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 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}