use std::path::PathBuf;use codex_protocol::ThreadId;use codex_protocol::items::HookPromptFragment;use codex_protocol::protocol::HookCompletedEvent;use codex_protocol::protocol::HookEventName;use codex_protocol::protocol::HookOutputEntry;use codex_protocol::protocol::HookOutputEntryKind;use codex_protocol::protocol::HookRunStatus;use codex_protocol::protocol::HookRunSummary;use codex_utils_absolute_path::AbsolutePathBuf;use super::common;use crate::engine::CommandShell;use crate::engine::ConfiguredHandler;use crate::engine::command_runner::CommandRunResult;use crate::engine::dispatcher;use crate::engine::output_parser;use crate::schema::NullableString;use crate::schema::StopCommandInput;use crate::schema::SubagentStopCommandInput;#[derive(Debug, Clone)]pub struct StopRequest { pub session_id: ThreadId, pub turn_id: String, pub cwd: AbsolutePathBuf, pub transcript_path: Option<PathBuf>, pub model: String, pub permission_mode: String, pub stop_hook_active: bool, pub last_assistant_message: Option<String>, pub target: StopHookTarget,}#[derive(Debug, Clone)]pub enum StopHookTarget { Stop, SubagentStop { agent_id: String, agent_type: String, agent_transcript_path: Option<PathBuf>, },}impl StopHookTarget { fn event_name(&self) -> HookEventName { match self { Self::Stop => HookEventName::Stop, Self::SubagentStop { .. } => HookEventName::SubagentStop, } } fn matcher_input(&self) -> Option<&str> { match self { Self::Stop => None, Self::SubagentStop { agent_type, .. } => Some(agent_type.as_str()), } }}#[derive(Debug, Default)]pub struct StopOutcome { pub hook_events: Vec<HookCompletedEvent>, pub should_stop: bool, pub stop_reason: Option<String>, pub should_block: bool, pub block_reason: Option<String>, pub continuation_fragments: Vec<HookPromptFragment>,}#[derive(Debug, Default, PartialEq, Eq)]struct StopHandlerData { should_stop: bool, stop_reason: Option<String>, should_block: bool, block_reason: Option<String>, continuation_fragments: Vec<HookPromptFragment>,}pub(crate) fn preview( handlers: &[ConfiguredHandler], request: &StopRequest,) -> Vec<HookRunSummary> { dispatcher::select_handlers( handlers, request.target.event_name(), request.target.matcher_input(), ) .into_iter() .map(|handler| dispatcher::running_summary(&handler)) .collect()}pub(crate) async fn run( handlers: &[ConfiguredHandler], shell: &CommandShell, request: StopRequest,) -> StopOutcome { let matched = dispatcher::select_handlers( handlers, request.target.event_name(), request.target.matcher_input(), ); if matched.is_empty() { return StopOutcome { hook_events: Vec::new(), should_stop: false, stop_reason: None, should_block: false, block_reason: None, continuation_fragments: Vec::new(), }; } let input_json = match request.target { StopHookTarget::Stop => { let input = StopCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), transcript_path: NullableString::from_path(request.transcript_path.clone()), cwd: request.cwd.display().to_string(), hook_event_name: "Stop".to_string(), model: request.model.clone(), permission_mode: request.permission_mode.clone(), stop_hook_active: request.stop_hook_active, last_assistant_message: NullableString::from_string( request.last_assistant_message.clone(), ), }; match serde_json::to_string(&input) { Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( matched, Some(request.turn_id), format!("failed to serialize stop hook input: {error}"), ), ); } } } StopHookTarget::SubagentStop { agent_id, agent_type, agent_transcript_path, } => { let input = SubagentStopCommandInput { session_id: request.session_id.to_string(), turn_id: request.turn_id.clone(), transcript_path: NullableString::from_path(request.transcript_path.clone()), agent_transcript_path: NullableString::from_path(agent_transcript_path), cwd: request.cwd.display().to_string(), hook_event_name: "SubagentStop".to_string(), model: request.model.clone(), permission_mode: request.permission_mode.clone(), stop_hook_active: request.stop_hook_active, agent_id, agent_type, last_assistant_message: NullableString::from_string( request.last_assistant_message.clone(), ), }; match serde_json::to_string(&input) { Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome( common::serialization_failure_hook_events( matched, Some(request.turn_id), format!("failed to serialize subagent stop hook input: {error}"), ), ); } } } }; let results = dispatcher::execute_handlers( shell, matched, input_json, request.cwd.as_path(), Some(request.turn_id), parse_completed, ) .await; let aggregate = aggregate_results(results.iter().map(|result| &result.data)); StopOutcome { hook_events: results.into_iter().map(|result| result.completed).collect(), should_stop: aggregate.should_stop, stop_reason: aggregate.stop_reason, should_block: aggregate.should_block, block_reason: aggregate.block_reason, continuation_fragments: aggregate.continuation_fragments, }}fn parse_completed( handler: &ConfiguredHandler, run_result: CommandRunResult, turn_id: Option<String>,) -> dispatcher::ParsedHandler<StopHandlerData> { let mut entries = Vec::new(); let mut status = HookRunStatus::Completed; let mut should_stop = false; let mut stop_reason = None; let mut should_block = false; let mut block_reason = None; let mut continuation_prompt = None; let hook_event_name = match handler.event_name { HookEventName::Stop | HookEventName::SubagentStop => handler.event_name, event_name => { panic!("expected stop hook event, got {event_name:?}"); } }; match run_result.error.as_deref() { Some(error) => { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, text: error.to_string(), }); } None => match run_result.exit_code { Some(0) => { let trimmed_stdout = run_result.stdout.trim(); if trimmed_stdout.is_empty() { } else if let Some(parsed) = match hook_event_name { HookEventName::Stop => output_parser::parse_stop(&run_result.stdout), HookEventName::SubagentStop => { output_parser::parse_subagent_stop(&run_result.stdout) } _ => unreachable!("validated stop hook event"), } { if let Some(system_message) = parsed.universal.system_message { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Warning, text: system_message, }); } let _ = parsed.universal.suppress_output; if !parsed.universal.continue_processing { status = HookRunStatus::Stopped; should_stop = true; stop_reason = parsed.universal.stop_reason.clone(); if let Some(stop_reason_text) = parsed.universal.stop_reason { entries.push(HookOutputEntry { kind: HookOutputEntryKind::Stop, text: stop_reason_text, }); } } else if let Some(invalid_block_reason) = parsed.invalid_block_reason { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, text: invalid_block_reason, }); } else if parsed.should_block { if let Some(reason) = parsed.reason.as_deref().and_then(common::trimmed_non_empty) { status = HookRunStatus::Blocked; should_block = true; block_reason = Some(reason.clone()); continuation_prompt = Some(reason.clone()); entries.push(HookOutputEntry { kind: HookOutputEntryKind::Feedback, text: reason, }); } else { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, text: match hook_event_name { HookEventName::Stop => "Stop hook returned decision:block without a non-empty reason", HookEventName::SubagentStop => "SubagentStop hook returned decision:block without a non-empty reason", _ => unreachable!("validated stop hook event"), } .to_string(), }); } } } else { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, text: match hook_event_name { HookEventName::Stop => "hook returned invalid stop hook JSON output", HookEventName::SubagentStop => { "hook returned invalid subagent stop hook JSON output" } _ => unreachable!("validated stop hook event"), } .to_string(), }); } } Some(2) => { if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) { status = HookRunStatus::Blocked; should_block = true; block_reason = Some(reason.clone()); continuation_prompt = Some(reason.clone()); entries.push(HookOutputEntry { kind: HookOutputEntryKind::Feedback, text: reason, }); } else { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, text: match hook_event_name { HookEventName::Stop => { "Stop hook exited with code 2 but did not write a continuation prompt to stderr" } HookEventName::SubagentStop => { "SubagentStop hook exited with code 2 but did not write a continuation prompt to stderr" } _ => unreachable!("validated stop hook event"), } .to_string(), }); } } Some(exit_code) => { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, text: format!("hook exited with code {exit_code}"), }); } None => { status = HookRunStatus::Failed; entries.push(HookOutputEntry { kind: HookOutputEntryKind::Error, text: "hook exited without a status code".to_string(), }); } }, } let completed = HookCompletedEvent { turn_id, run: dispatcher::completed_summary(handler, &run_result, status, entries), }; let continuation_fragments = continuation_prompt .map(|prompt| { vec![HookPromptFragment::from_single_hook( prompt, completed.run.id.clone(), )] }) .unwrap_or_default(); dispatcher::ParsedHandler { completed, data: StopHandlerData { should_stop, stop_reason, should_block, block_reason, continuation_fragments, }, completion_order: 0, }}fn aggregate_results<'a>( results: impl IntoIterator<Item = &'a StopHandlerData>,) -> StopHandlerData { let results = results.into_iter().collect::<Vec<_>>(); let should_stop = results.iter().any(|result| result.should_stop); let stop_reason = results.iter().find_map(|result| result.stop_reason.clone()); let should_block = !should_stop && results.iter().any(|result| result.should_block); let block_reason = if should_block { common::join_text_chunks( results .iter() .filter_map(|result| result.block_reason.clone()) .collect(), ) } else { None }; let continuation_fragments = if should_block { results .iter() .filter(|result| result.should_block) .flat_map(|result| result.continuation_fragments.clone()) .collect() } else { Vec::new() }; StopHandlerData { should_stop, stop_reason, should_block, block_reason, continuation_fragments, }}fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> StopOutcome { StopOutcome { hook_events, should_stop: false, stop_reason: None, should_block: false, block_reason: None, continuation_fragments: Vec::new(), }}#[cfg(test)]mod tests { use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use codex_protocol::items::HookPromptFragment; use super::StopHandlerData; use super::aggregate_results; use super::parse_completed; use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; #[test] fn block_decision_with_reason_sets_continuation_prompt() { let parsed = parse_completed( &handler(), run_result( Some(0), r#"{"decision":"block","reason":"retry with tests"}"#, "", ), Some("turn-1".to_string()), ); assert_eq!( parsed.data, StopHandlerData { should_stop: false, stop_reason: None, should_block: true, block_reason: Some("retry with tests".to_string()), continuation_fragments: vec![HookPromptFragment { text: "retry with tests".to_string(), hook_run_id: parsed.completed.run.id.clone(), }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); } #[test] fn block_decision_without_reason_is_invalid() { let parsed = parse_completed( &handler(), run_result(Some(0), r#"{"decision":"block"}"#, ""), Some("turn-1".to_string()), ); assert_eq!(parsed.data, StopHandlerData::default()); assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); assert_eq!( parsed.completed.run.entries, vec![HookOutputEntry { kind: HookOutputEntryKind::Error, text: "Stop hook returned decision:block without a non-empty reason".to_string(), }] ); } #[test] fn continue_false_overrides_block_decision() { let parsed = parse_completed( &handler(), run_result( Some(0), r#"{"continue":false,"stopReason":"done","decision":"block","reason":"keep going"}"#, "", ), Some("turn-1".to_string()), ); assert_eq!( parsed.data, StopHandlerData { should_stop: true, stop_reason: Some("done".to_string()), should_block: false, block_reason: None, continuation_fragments: Vec::new(), } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped); } #[test] fn exit_code_two_uses_stderr_feedback_only() { let parsed = parse_completed( &handler(), run_result(Some(2), "ignored stdout", "retry with tests"), Some("turn-1".to_string()), ); assert_eq!( parsed.data, StopHandlerData { should_stop: false, stop_reason: None, should_block: true, block_reason: Some("retry with tests".to_string()), continuation_fragments: vec![HookPromptFragment { text: "retry with tests".to_string(), hook_run_id: parsed.completed.run.id.clone(), }], } ); assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked); } #[test] fn exit_code_two_without_stderr_does_not_block() { let parsed = parse_completed( &handler(), run_result(Some(2), "", " "), /*turn_id*/ None, ); assert_eq!(parsed.data, StopHandlerData::default()); assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); assert_eq!( parsed.completed.run.entries, vec![HookOutputEntry { kind: HookOutputEntryKind::Error, text: "Stop hook exited with code 2 but did not write a continuation prompt to stderr" .to_string(), }] ); } #[test] fn block_decision_with_blank_reason_fails_instead_of_blocking() { let parsed = parse_completed( &handler(), run_result(Some(0), "{\"decision\":\"block\",\"reason\":\" \"}", ""), Some("turn-1".to_string()), ); assert_eq!(parsed.data, StopHandlerData::default()); assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); assert_eq!( parsed.completed.run.entries, vec![HookOutputEntry { kind: HookOutputEntryKind::Error, text: "Stop hook returned decision:block without a non-empty reason".to_string(), }] ); } #[test] fn invalid_stdout_fails_instead_of_silently_nooping() { let parsed = parse_completed( &handler(), run_result(Some(0), "not json", ""), Some("turn-1".to_string()), ); assert_eq!(parsed.data, StopHandlerData::default()); assert_eq!(parsed.completed.run.status, HookRunStatus::Failed); assert_eq!( parsed.completed.run.entries, vec![HookOutputEntry { kind: HookOutputEntryKind::Error, text: "hook returned invalid stop hook JSON output".to_string(), }] ); } #[test] fn aggregate_results_concatenates_blocking_reasons_in_declaration_order() { let aggregate = aggregate_results([ &StopHandlerData { should_stop: false, stop_reason: None, should_block: true, block_reason: Some("first".to_string()), continuation_fragments: vec![HookPromptFragment::from_single_hook( "first", "run-1", )], }, &StopHandlerData { should_stop: false, stop_reason: None, should_block: true, block_reason: Some("second".to_string()), continuation_fragments: vec![HookPromptFragment::from_single_hook( "second", "run-2", )], }, ]); assert_eq!( aggregate, StopHandlerData { should_stop: false, stop_reason: None, should_block: true, block_reason: Some("first\n\nsecond".to_string()), continuation_fragments: vec![ HookPromptFragment::from_single_hook("first", "run-1"), HookPromptFragment::from_single_hook("second", "run-2"), ], } ); } fn handler() -> ConfiguredHandler { ConfiguredHandler { event_name: HookEventName::Stop, matcher: None, command: "echo hook".to_string(), timeout_sec: 600, status_message: None, source_path: test_path_buf("/tmp/hooks.json").abs(), source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), } } fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult { CommandRunResult { started_at: 1, completed_at: 2, duration_ms: 1, exit_code, stdout: stdout.to_string(), stderr: stderr.to_string(), error: None, } }}