Skip to main content

codex_hooks/events/
session_end.rs

1use std::path::PathBuf;
2
3use codex_protocol::ThreadId;
4use codex_protocol::protocol::HookCompletedEvent;
5use codex_protocol::protocol::HookEventName;
6use codex_protocol::protocol::HookOutputEntry;
7use codex_protocol::protocol::HookOutputEntryKind;
8use codex_protocol::protocol::HookRunStatus;
9use codex_protocol::protocol::HookRunSummary;
10use codex_utils_absolute_path::AbsolutePathBuf;
11
12use super::common;
13use crate::engine::CommandShell;
14use crate::engine::ConfiguredHandler;
15use crate::engine::command_runner::CommandRunResult;
16use crate::engine::dispatcher;
17use crate::schema::NullableString;
18use crate::schema::SessionEndCommandInput;
19
20pub(crate) const SESSION_END_DEFAULT_TIMEOUT_SEC: u64 = 1;
21/// Keep below app-server's in-process `SHUTDOWN_TIMEOUT`: SessionEnd runs during
22/// teardown and must leave headroom within the existing five-second bound.
23pub(crate) const SESSION_END_MAX_TIMEOUT_SEC: u64 = 3;
24const SESSION_END_REASON: &str = "other";
25
26#[derive(Debug, Clone)]
27pub struct SessionEndRequest {
28    pub session_id: ThreadId,
29    pub turn_id: String,
30    pub cwd: AbsolutePathBuf,
31    pub transcript_path: Option<PathBuf>,
32}
33
34#[derive(Debug, Default)]
35pub struct SessionEndOutcome {
36    pub hook_events: Vec<HookCompletedEvent>,
37}
38
39pub(crate) fn preview(handlers: &[ConfiguredHandler]) -> Vec<HookRunSummary> {
40    dispatcher::select_handlers(
41        handlers,
42        HookEventName::SessionEnd,
43        Some(SESSION_END_REASON),
44    )
45    .into_iter()
46    .map(|handler| dispatcher::running_summary(&handler))
47    .collect()
48}
49
50pub(crate) async fn run(
51    handlers: &[ConfiguredHandler],
52    shell: &CommandShell,
53    request: SessionEndRequest,
54) -> SessionEndOutcome {
55    let matched = dispatcher::select_handlers(
56        handlers,
57        HookEventName::SessionEnd,
58        Some(SESSION_END_REASON),
59    );
60    if matched.is_empty() {
61        return SessionEndOutcome::default();
62    }
63
64    let input_json = match serde_json::to_string(&SessionEndCommandInput {
65        session_id: request.session_id.to_string(),
66        transcript_path: NullableString::from_path(request.transcript_path.clone()),
67        cwd: request.cwd.display().to_string(),
68        hook_event_name: "SessionEnd".to_string(),
69        reason: SESSION_END_REASON.to_string(),
70    }) {
71        Ok(input_json) => input_json,
72        Err(error) => {
73            return SessionEndOutcome {
74                hook_events: common::serialization_failure_hook_events(
75                    matched,
76                    Some(request.turn_id.clone()),
77                    format!("failed to serialize session end hook input: {error}"),
78                ),
79            };
80        }
81    };
82
83    let results = dispatcher::execute_handlers(
84        shell,
85        matched,
86        input_json,
87        request.cwd.as_path(),
88        Some(request.turn_id),
89        parse_completed,
90    )
91    .await;
92    SessionEndOutcome {
93        hook_events: results.into_iter().map(|result| result.completed).collect(),
94    }
95}
96
97fn parse_completed(
98    handler: &ConfiguredHandler,
99    run_result: CommandRunResult,
100    turn_id: Option<String>,
101) -> dispatcher::ParsedHandler<()> {
102    let (status, entries) = match (run_result.error.as_deref(), run_result.exit_code) {
103        (Some(error), _) => (
104            HookRunStatus::Failed,
105            vec![HookOutputEntry {
106                kind: HookOutputEntryKind::Error,
107                text: error.to_string(),
108            }],
109        ),
110        (None, Some(0)) => (HookRunStatus::Completed, Vec::new()),
111        (None, Some(code)) => (
112            HookRunStatus::Failed,
113            vec![HookOutputEntry {
114                kind: HookOutputEntryKind::Error,
115                text: common::trimmed_non_empty(&run_result.stderr)
116                    .unwrap_or_else(|| format!("hook exited with code {code}")),
117            }],
118        ),
119        (None, None) => (
120            HookRunStatus::Failed,
121            vec![HookOutputEntry {
122                kind: HookOutputEntryKind::Error,
123                text: "hook process terminated without an exit code".to_string(),
124            }],
125        ),
126    };
127
128    dispatcher::ParsedHandler {
129        completed: HookCompletedEvent {
130            turn_id,
131            run: dispatcher::completed_summary(handler, &run_result, status, entries),
132        },
133        data: (),
134        completion_order: 0,
135    }
136}
137
138#[cfg(test)]
139#[path = "session_end_tests.rs"]
140mod tests;