codex_hooks/events/
session_end.rs1use 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;
21pub(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;