Codex Handbook
hooks/src/events/compact.rs 621 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::PostCompactCommandInput;use crate::schema::PreCompactCommandInput;use crate::schema::SubagentCommandInputFields;#[derive(Debug, Clone)]pub struct PreCompactRequest {    pub session_id: ThreadId,    pub turn_id: String,    pub subagent: Option<common::SubagentHookContext>,    pub cwd: AbsolutePathBuf,    pub transcript_path: Option<PathBuf>,    pub model: String,    pub trigger: String,}#[derive(Debug, Clone)]pub struct PostCompactRequest {    pub session_id: ThreadId,    pub turn_id: String,    pub subagent: Option<common::SubagentHookContext>,    pub cwd: AbsolutePathBuf,    pub transcript_path: Option<PathBuf>,    pub model: String,    pub trigger: String,}#[derive(Debug)]pub struct StatelessHookOutcome {    pub hook_events: Vec<HookCompletedEvent>,    pub should_stop: bool,    pub stop_reason: Option<String>,}#[derive(Debug)]pub struct PreCompactOutcome {    pub hook_events: Vec<HookCompletedEvent>,    pub should_stop: bool,    pub stop_reason: Option<String>,}pub(crate) fn preview_pre(    handlers: &[ConfiguredHandler],    request: &PreCompactRequest,) -> Vec<HookRunSummary> {    dispatcher::select_handlers(        handlers,        HookEventName::PreCompact,        Some(request.trigger.as_str()),    )    .into_iter()    .map(|handler| dispatcher::running_summary(&handler))    .collect()}pub(crate) async fn run_pre(    handlers: &[ConfiguredHandler],    shell: &CommandShell,    request: PreCompactRequest,) -> PreCompactOutcome {    let matched = dispatcher::select_handlers(        handlers,        HookEventName::PreCompact,        Some(request.trigger.as_str()),    );    if matched.is_empty() {        return PreCompactOutcome {            hook_events: Vec::new(),            should_stop: false,            stop_reason: None,        };    }    let input_json = match pre_command_input_json(&request) {        Ok(input_json) => input_json,        Err(error) => {            return PreCompactOutcome {                hook_events: common::serialization_failure_hook_events(                    matched,                    Some(request.turn_id),                    format!("failed to serialize pre compact hook input: {error}"),                ),                should_stop: false,                stop_reason: None,            };        }    };    let results = dispatcher::execute_handlers(        shell,        matched,        input_json,        request.cwd.as_path(),        Some(request.turn_id),        parse_pre_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());    PreCompactOutcome {        hook_events: results.into_iter().map(|result| result.completed).collect(),        should_stop,        stop_reason,    }}fn pre_command_input_json(request: &PreCompactRequest) -> Result<String, serde_json::Error> {    let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());    serde_json::to_string(&PreCompactCommandInput {        session_id: request.session_id.to_string(),        turn_id: request.turn_id.clone(),        agent_id: subagent.agent_id,        agent_type: subagent.agent_type,        transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),        cwd: request.cwd.display().to_string(),        hook_event_name: "PreCompact".to_string(),        model: request.model.clone(),        trigger: request.trigger.clone(),    })}pub(crate) fn preview_post(    handlers: &[ConfiguredHandler],    request: &PostCompactRequest,) -> Vec<HookRunSummary> {    dispatcher::select_handlers(        handlers,        HookEventName::PostCompact,        Some(request.trigger.as_str()),    )    .into_iter()    .map(|handler| dispatcher::running_summary(&handler))    .collect()}pub(crate) async fn run_post(    handlers: &[ConfiguredHandler],    shell: &CommandShell,    request: PostCompactRequest,) -> StatelessHookOutcome {    let matched = dispatcher::select_handlers(        handlers,        HookEventName::PostCompact,        Some(request.trigger.as_str()),    );    if matched.is_empty() {        return StatelessHookOutcome {            hook_events: Vec::new(),            should_stop: false,            stop_reason: None,        };    }    let input_json = match post_command_input_json(&request) {        Ok(input_json) => input_json,        Err(error) => {            return StatelessHookOutcome {                hook_events: common::serialization_failure_hook_events(                    matched,                    Some(request.turn_id),                    format!("failed to serialize post compact hook input: {error}"),                ),                should_stop: false,                stop_reason: None,            };        }    };    let results = dispatcher::execute_handlers(        shell,        matched,        input_json,        request.cwd.as_path(),        Some(request.turn_id),        parse_post_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());    StatelessHookOutcome {        hook_events: results.into_iter().map(|result| result.completed).collect(),        should_stop,        stop_reason,    }}fn post_command_input_json(request: &PostCompactRequest) -> Result<String, serde_json::Error> {    let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());    serde_json::to_string(&PostCompactCommandInput {        session_id: request.session_id.to_string(),        turn_id: request.turn_id.clone(),        agent_id: subagent.agent_id,        agent_type: subagent.agent_type,        transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),        cwd: request.cwd.display().to_string(),        hook_event_name: "PostCompact".to_string(),        model: request.model.clone(),        trigger: request.trigger.clone(),    })}#[derive(Default)]struct CompactHandlerData {    should_stop: bool,    stop_reason: Option<String>,}fn parse_pre_completed(    handler: &ConfiguredHandler,    run_result: CommandRunResult,    turn_id: Option<String>,) -> dispatcher::ParsedHandler<CompactHandlerData> {    let mut entries = Vec::new();    let mut status = HookRunStatus::Completed;    let mut should_stop = false;    let mut stop_reason = None;    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) = output_parser::parse_pre_compact(&run_result.stdout) {                    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();                        entries.push(HookOutputEntry {                            kind: HookOutputEntryKind::Stop,                            text: parsed                                .universal                                .stop_reason                                .unwrap_or_else(|| "PreCompact hook stopped execution".to_string()),                        });                    } else if let Some(invalid_reason) = parsed.invalid_reason {                        status = HookRunStatus::Failed;                        entries.push(HookOutputEntry {                            kind: HookOutputEntryKind::Error,                            text: invalid_reason,                        });                    }                } else if output_parser::looks_like_json(&run_result.stdout) {                    status = HookRunStatus::Failed;                    entries.push(HookOutputEntry {                        kind: HookOutputEntryKind::Error,                        text: "hook returned invalid PreCompact hook JSON output".to_string(),                    });                }            }            Some(code) => {                status = HookRunStatus::Failed;                entries.push(HookOutputEntry {                    kind: HookOutputEntryKind::Error,                    text: common::trimmed_non_empty(&run_result.stderr)                        .unwrap_or_else(|| format!("hook exited with code {code}")),                });            }            None => {                status = HookRunStatus::Failed;                entries.push(HookOutputEntry {                    kind: HookOutputEntryKind::Error,                    text: "hook process terminated without an exit code".to_string(),                });            }        },    }    dispatcher::ParsedHandler {        completed: HookCompletedEvent {            turn_id,            run: dispatcher::completed_summary(handler, &run_result, status, entries),        },        data: CompactHandlerData {            should_stop,            stop_reason,        },        completion_order: 0,    }}fn parse_post_completed(    handler: &ConfiguredHandler,    run_result: CommandRunResult,    turn_id: Option<String>,) -> dispatcher::ParsedHandler<CompactHandlerData> {    parse_completed(        handler,        run_result,        turn_id,        "PostCompact",        output_parser::parse_post_compact,    )}fn parse_completed(    handler: &ConfiguredHandler,    run_result: CommandRunResult,    turn_id: Option<String>,    event_label: &'static str,    parse_output: fn(&str) -> Option<output_parser::StatelessHookOutput>,) -> dispatcher::ParsedHandler<CompactHandlerData> {    let mut entries = Vec::new();    let mut status = HookRunStatus::Completed;    let mut should_stop = false;    let mut stop_reason = None;    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) = parse_output(&run_result.stdout) {                    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();                        entries.push(HookOutputEntry {                            kind: HookOutputEntryKind::Stop,                            text: parsed                                .universal                                .stop_reason                                .unwrap_or_else(|| format!("{event_label} hook stopped execution")),                        });                    } else if let Some(invalid_reason) = parsed.invalid_reason {                        status = HookRunStatus::Failed;                        entries.push(HookOutputEntry {                            kind: HookOutputEntryKind::Error,                            text: invalid_reason,                        });                    }                } else if output_parser::looks_like_json(&run_result.stdout) {                    status = HookRunStatus::Failed;                    entries.push(HookOutputEntry {                        kind: HookOutputEntryKind::Error,                        text: format!("hook returned invalid {event_label} hook JSON output"),                    });                }            }            Some(code) => {                status = HookRunStatus::Failed;                entries.push(HookOutputEntry {                    kind: HookOutputEntryKind::Error,                    text: common::trimmed_non_empty(&run_result.stderr)                        .unwrap_or_else(|| format!("hook exited with code {code}")),                });            }            None => {                status = HookRunStatus::Failed;                entries.push(HookOutputEntry {                    kind: HookOutputEntryKind::Error,                    text: "hook process terminated without an exit code".to_string(),                });            }        },    }    dispatcher::ParsedHandler {        completed: HookCompletedEvent {            turn_id,            run: dispatcher::completed_summary(handler, &run_result, status, entries),        },        data: CompactHandlerData {            should_stop,            stop_reason,        },        completion_order: 0,    }}#[cfg(test)]mod tests {    use codex_protocol::ThreadId;    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 serde_json::json;    use super::parse_post_completed;    use super::parse_pre_completed;    use super::post_command_input_json;    use super::pre_command_input_json;    use crate::engine::ConfiguredHandler;    use crate::engine::command_runner::CommandRunResult;    #[test]    fn pre_compact_input_includes_lifecycle_metadata() {        let input_json = pre_command_input_json(&pre_request()).expect("serialize command input");        let input: serde_json::Value =            serde_json::from_str(&input_json).expect("parse command input");        assert_eq!(            input,            json!({                "session_id": pre_request().session_id.to_string(),                "turn_id": "turn-1",                "transcript_path": null,                "cwd": test_path_buf("/tmp").display().to_string(),                "hook_event_name": "PreCompact",                "model": "gpt-test",                "trigger": "manual",            })        );    }    #[test]    fn post_compact_input_includes_lifecycle_metadata() {        let input_json = post_command_input_json(&post_request()).expect("serialize command input");        let input: serde_json::Value =            serde_json::from_str(&input_json).expect("parse command input");        assert_eq!(            input,            json!({                "session_id": post_request().session_id.to_string(),                "turn_id": "turn-1",                "transcript_path": null,                "cwd": test_path_buf("/tmp").display().to_string(),                "hook_event_name": "PostCompact",                "model": "gpt-test",                "trigger": "manual",            })        );    }    #[test]    fn block_decision_is_not_supported_for_pre_compact() {        let parsed = parse_pre_completed(            &handler(HookEventName::PreCompact),            run_result(                Some(0),                r#"{"decision":"block","reason":"policy blocked compaction"}"#,                "",            ),            Some("turn-1".to_string()),        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);        assert_eq!(            parsed.completed.run.entries,            vec![HookOutputEntry {                kind: HookOutputEntryKind::Error,                text: "hook returned invalid PreCompact hook JSON output".to_string(),            }]        );    }    #[test]    fn continue_false_stops_before_compaction() {        let parsed = parse_pre_completed(            &handler(HookEventName::PreCompact),            run_result(Some(0), r#"{"continue":false,"stopReason":"nope"}"#, ""),            Some("turn-1".to_string()),        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);        assert_eq!(parsed.data.should_stop, true);        assert_eq!(parsed.data.stop_reason, Some("nope".to_string()));        assert_eq!(            parsed.completed.run.entries,            vec![HookOutputEntry {                kind: HookOutputEntryKind::Stop,                text: "nope".to_string(),            }]        );    }    #[test]    fn post_compact_continue_false_stops_after_compaction() {        let parsed = parse_post_completed(            &handler(HookEventName::PostCompact),            run_result(                Some(0),                r#"{"continue":false,"stopReason":"pause after compact"}"#,                "",            ),            Some("turn-1".to_string()),        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);        assert_eq!(parsed.data.should_stop, true);        assert_eq!(            parsed.data.stop_reason,            Some("pause after compact".to_string())        );        assert_eq!(            parsed.completed.run.entries,            vec![HookOutputEntry {                kind: HookOutputEntryKind::Stop,                text: "pause after compact".to_string(),            }]        );    }    #[test]    fn pre_compact_ignores_plain_stdout() {        let parsed = parse_pre_completed(            &handler(HookEventName::PreCompact),            run_result(Some(0), "checking compact policy\n", ""),            Some("turn-1".to_string()),        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);        assert_eq!(parsed.completed.run.entries, Vec::new());    }    #[test]    fn post_compact_ignores_plain_stdout() {        let parsed = parse_post_completed(            &handler(HookEventName::PostCompact),            run_result(Some(0), "logged compact summary\n", ""),            Some("turn-1".to_string()),        );        assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);        assert_eq!(parsed.completed.run.entries, Vec::new());    }    fn pre_request() -> super::PreCompactRequest {        super::PreCompactRequest {            session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000001")                .expect("valid thread id"),            turn_id: "turn-1".to_string(),            subagent: None,            cwd: test_path_buf("/tmp").abs(),            transcript_path: None,            model: "gpt-test".to_string(),            trigger: "manual".to_string(),        }    }    fn post_request() -> super::PostCompactRequest {        super::PostCompactRequest {            session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000002")                .expect("valid thread id"),            turn_id: "turn-1".to_string(),            subagent: None,            cwd: test_path_buf("/tmp").abs(),            transcript_path: None,            model: "gpt-test".to_string(),            trigger: "manual".to_string(),        }    }    fn handler(event_name: HookEventName) -> ConfiguredHandler {        ConfiguredHandler {            event_name,            matcher: None,            command: "python3 compact_hook.py".to_string(),            timeout_sec: 5,            status_message: Some("running compact hook".to_string()),            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_700_000_000,            completed_at: 1_700_000_001,            duration_ms: 12,            exit_code,            stdout: stdout.to_string(),            stderr: stderr.to_string(),            error: None,        }    }}