Codex Handbook
hooks/src/events/session_start.rs 540 lines
use std::path::PathBuf;use codex_protocol::ThreadId;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::SessionStartCommandInput;use crate::schema::SubagentStartCommandInput;#[derive(Debug, Clone, Copy)]pub enum SessionStartSource {    Startup,    Resume,    Clear,    Compact,}impl SessionStartSource {    pub fn as_str(self) -> &'static str {        match self {            Self::Startup => "startup",            Self::Resume => "resume",            Self::Clear => "clear",            Self::Compact => "compact",        }    }}#[derive(Debug, Clone)]pub struct SessionStartRequest {    pub session_id: ThreadId,    pub cwd: AbsolutePathBuf,    pub transcript_path: Option<PathBuf>,    pub model: String,    pub permission_mode: String,    pub target: StartHookTarget,}#[derive(Debug, Clone)]pub enum StartHookTarget {    SessionStart {        source: SessionStartSource,    },    SubagentStart {        turn_id: String,        agent_id: String,        agent_type: String,    },}impl StartHookTarget {    fn event_name(&self) -> HookEventName {        match self {            Self::SessionStart { .. } => HookEventName::SessionStart,            Self::SubagentStart { .. } => HookEventName::SubagentStart,        }    }    fn matcher_input(&self) -> &str {        match self {            Self::SessionStart { source } => source.as_str(),            Self::SubagentStart { agent_type, .. } => agent_type.as_str(),        }    }}#[derive(Debug)]pub struct SessionStartOutcome {    pub hook_events: Vec<HookCompletedEvent>,    pub should_stop: bool,    pub stop_reason: Option<String>,    pub additional_contexts: Vec<String>,}#[derive(Debug, PartialEq, Eq)]struct SessionStartHandlerData {    should_stop: bool,    stop_reason: Option<String>,    additional_contexts_for_model: Vec<String>,}pub(crate) fn preview(    handlers: &[ConfiguredHandler],    request: &SessionStartRequest,) -> Vec<HookRunSummary> {    dispatcher::select_handlers(        handlers,        request.target.event_name(),        Some(request.target.matcher_input()),    )    .into_iter()    .map(|handler| dispatcher::running_summary(&handler))    .collect()}pub(crate) async fn run(    handlers: &[ConfiguredHandler],    shell: &CommandShell,    request: SessionStartRequest,    turn_id: Option<String>,) -> SessionStartOutcome {    let matched = dispatcher::select_handlers(        handlers,        request.target.event_name(),        Some(request.target.matcher_input()),    );    if matched.is_empty() {        return SessionStartOutcome {            hook_events: Vec::new(),            should_stop: false,            stop_reason: None,            additional_contexts: Vec::new(),        };    }    let (input_json, turn_id) = match request.target {        StartHookTarget::SessionStart { source } => {            let input_json = match serde_json::to_string(&SessionStartCommandInput::new(                request.session_id.to_string(),                request.transcript_path.clone(),                request.cwd.display().to_string(),                request.model.clone(),                request.permission_mode.clone(),                source.as_str().to_string(),            )) {                Ok(input_json) => input_json,                Err(error) => {                    return serialization_failure_outcome(                        common::serialization_failure_hook_events(                            matched,                            turn_id,                            format!("failed to serialize session start hook input: {error}"),                        ),                    );                }            };            (input_json, turn_id)        }        StartHookTarget::SubagentStart {            turn_id: subagent_turn_id,            agent_id,            agent_type,        } => {            let input = SubagentStartCommandInput {                session_id: request.session_id.to_string(),                turn_id: subagent_turn_id.clone(),                transcript_path: NullableString::from_path(request.transcript_path.clone()),                cwd: request.cwd.display().to_string(),                hook_event_name: "SubagentStart".to_string(),                model: request.model.clone(),                permission_mode: request.permission_mode.clone(),                agent_id,                agent_type,            };            let input_json = match serde_json::to_string(&input) {                Ok(input_json) => input_json,                Err(error) => {                    return serialization_failure_outcome(                        common::serialization_failure_hook_events(                            matched,                            Some(subagent_turn_id),                            format!("failed to serialize subagent start hook input: {error}"),                        ),                    );                }            };            (input_json, Some(subagent_turn_id))        }    };    let results = dispatcher::execute_handlers(        shell,        matched,        input_json,        request.cwd.as_path(),        turn_id,        parse_completed,    )    .await;    let should_stop = results.iter().any(|result| result.data.should_stop);    let stop_reason = results        .iter()        .find_map(|result| result.data.stop_reason.clone());    let additional_contexts = common::flatten_additional_contexts(        results            .iter()            .map(|result| result.data.additional_contexts_for_model.as_slice()),    );    SessionStartOutcome {        hook_events: results.into_iter().map(|result| result.completed).collect(),        should_stop,        stop_reason,        additional_contexts,    }}/// Interprets completed `SessionStart` and `SubagentStart` hook runs.////// The two events have different input payloads but share most output/// handling: hook JSON can emit warnings/context, invalid JSON-looking stdout/// fails, and plain stdout becomes model context. Only `SessionStart` honors/// `continue:false`; `SubagentStart` stays context-injection-only.fn parse_completed(    handler: &ConfiguredHandler,    run_result: CommandRunResult,    turn_id: Option<String>,) -> dispatcher::ParsedHandler<SessionStartHandlerData> {    let mut entries = Vec::new();    let mut status = HookRunStatus::Completed;    let mut should_stop = false;    let mut stop_reason = None;    let mut additional_contexts_for_model = Vec::new();    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 handler.event_name {                    HookEventName::SessionStart => {                        output_parser::parse_session_start(&run_result.stdout)                    }                    HookEventName::SubagentStart => {                        output_parser::parse_subagent_start(&run_result.stdout)                    }                    event_name => {                        panic!("expected start hook event, got {event_name:?}")                    }                } {                    if let Some(system_message) = parsed.universal.system_message {                        entries.push(HookOutputEntry {                            kind: HookOutputEntryKind::Warning,                            text: system_message,                        });                    }                    if let Some(additional_context) = parsed.additional_context {                        common::append_additional_context(                            &mut entries,                            &mut additional_contexts_for_model,                            additional_context,                        );                    }                    let _ = parsed.universal.suppress_output;                    if handler.event_name == HookEventName::SessionStart                        && !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 output_parser::looks_like_json(&run_result.stdout) {                    status = HookRunStatus::Failed;                    entries.push(HookOutputEntry {                        kind: HookOutputEntryKind::Error,                        text: match handler.event_name {                            HookEventName::SessionStart => {                                "hook returned invalid session start JSON output"                            }                            HookEventName::SubagentStart => {                                "hook returned invalid subagent start JSON output"                            }                            event_name => {                                panic!("expected start hook event, got {event_name:?}")                            }                        }                        .to_string(),                    });                } else {                    let additional_context = trimmed_stdout.to_string();                    common::append_additional_context(                        &mut entries,                        &mut additional_contexts_for_model,                        additional_context,                    );                }            }            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),    };    dispatcher::ParsedHandler {        completed,        data: SessionStartHandlerData {            should_stop,            stop_reason,            additional_contexts_for_model,        },        completion_order: 0,    }}fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> SessionStartOutcome {    SessionStartOutcome {        hook_events,        should_stop: false,        stop_reason: None,        additional_contexts: 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 super::SessionStartHandlerData;    use super::parse_completed;    use crate::engine::ConfiguredHandler;    use crate::engine::command_runner::CommandRunResult;    #[test]    fn plain_stdout_becomes_model_context() {        let parsed = parse_completed(            &handler(),            run_result(Some(0), "hello from hook\n", ""),            /*turn_id*/ None,        );        assert_eq!(            parsed.data,            SessionStartHandlerData {                should_stop: false,                stop_reason: None,                additional_contexts_for_model: vec!["hello from hook".to_string()],            }        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);        assert_eq!(            parsed.completed.run.entries,            vec![HookOutputEntry {                kind: HookOutputEntryKind::Context,                text: "hello from hook".to_string(),            }]        );    }    #[test]    fn continue_false_preserves_context_for_later_turns() {        let parsed = parse_completed(            &handler(),            run_result(                Some(0),                r#"{"continue":false,"stopReason":"pause","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"do not inject"}}"#,                "",            ),            /*turn_id*/ None,        );        assert_eq!(            parsed.data,            SessionStartHandlerData {                should_stop: true,                stop_reason: Some("pause".to_string()),                additional_contexts_for_model: vec!["do not inject".to_string()],            }        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);        assert_eq!(            parsed.completed.run.entries,            vec![                HookOutputEntry {                    kind: HookOutputEntryKind::Context,                    text: "do not inject".to_string(),                },                HookOutputEntry {                    kind: HookOutputEntryKind::Stop,                    text: "pause".to_string(),                },            ]        );    }    #[test]    fn invalid_json_like_stdout_fails_instead_of_becoming_model_context() {        let parsed = parse_completed(            &handler(),            run_result(                Some(0),                r#"{"hookSpecificOutput":{"hookEventName":"SessionStart""#,                "",            ),            /*turn_id*/ None,        );        assert_eq!(            parsed.data,            SessionStartHandlerData {                should_stop: false,                stop_reason: None,                additional_contexts_for_model: Vec::new(),            }        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);        assert_eq!(            parsed.completed.run.entries,            vec![HookOutputEntry {                kind: HookOutputEntryKind::Error,                text: "hook returned invalid session start JSON output".to_string(),            }]        );    }    #[test]    fn subagent_start_plain_stdout_becomes_model_context() {        let parsed = parse_completed(            &handler_for(HookEventName::SubagentStart),            run_result(Some(0), "hello from subagent hook\n", ""),            /*turn_id*/ Some("turn-1".to_string()),        );        assert_eq!(            parsed.data,            SessionStartHandlerData {                should_stop: false,                stop_reason: None,                additional_contexts_for_model: vec!["hello from subagent hook".to_string()],            }        );        assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1"));        assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);        assert_eq!(            parsed.completed.run.entries,            vec![HookOutputEntry {                kind: HookOutputEntryKind::Context,                text: "hello from subagent hook".to_string(),            }]        );    }    #[test]    fn subagent_start_continue_false_is_ignored() {        let parsed = parse_completed(            &handler_for(HookEventName::SubagentStart),            run_result(                Some(0),                r#"{"continue":false,"stopReason":"skip child","hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child context"}}"#,                "",            ),            /*turn_id*/ Some("turn-1".to_string()),        );        assert_eq!(            parsed.data,            SessionStartHandlerData {                should_stop: false,                stop_reason: None,                additional_contexts_for_model: vec!["child context".to_string()],            }        );        assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1"));        assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);        assert_eq!(            parsed.completed.run.entries,            vec![HookOutputEntry {                kind: HookOutputEntryKind::Context,                text: "child context".to_string(),            }]        );    }    fn handler() -> ConfiguredHandler {        handler_for(HookEventName::SessionStart)    }    fn handler_for(event_name: HookEventName) -> ConfiguredHandler {        ConfiguredHandler {            event_name,            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,        }    }}