1use std::sync::Arc;
2
3use chrono::DateTime;
4use chrono::SecondsFormat;
5use chrono::Utc;
6use codex_protocol::ThreadId;
7use codex_utils_absolute_path::AbsolutePathBuf;
8use futures::future::BoxFuture;
9use serde::Serialize;
10use serde::Serializer;
11
12pub type HookFn = Arc<dyn for<'a> Fn(&'a HookPayload) -> BoxFuture<'a, HookResult> + Send + Sync>;
13
14#[derive(Debug)]
15pub enum HookResult {
16 Success,
18 FailedContinue(Box<dyn std::error::Error + Send + Sync + 'static>),
21 FailedAbort(Box<dyn std::error::Error + Send + Sync + 'static>),
24}
25
26impl HookResult {
27 pub fn should_abort_operation(&self) -> bool {
28 matches!(self, Self::FailedAbort(_))
29 }
30}
31
32#[derive(Debug)]
33pub struct HookResponse {
34 pub hook_name: String,
35 pub result: HookResult,
36}
37
38#[derive(Clone)]
39pub struct Hook {
40 pub name: String,
41 pub func: HookFn,
42}
43
44impl Default for Hook {
45 fn default() -> Self {
46 Self {
47 name: "default".to_string(),
48 func: Arc::new(|_| Box::pin(async { HookResult::Success })),
49 }
50 }
51}
52
53impl Hook {
54 pub async fn execute(&self, payload: &HookPayload) -> HookResponse {
55 HookResponse {
56 hook_name: self.name.clone(),
57 result: (self.func)(payload).await,
58 }
59 }
60}
61
62#[derive(Debug, Serialize, Clone)]
63#[serde(rename_all = "snake_case")]
64pub struct HookPayload {
65 pub session_id: ThreadId,
66 pub cwd: AbsolutePathBuf,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub client: Option<String>,
69 #[serde(serialize_with = "serialize_triggered_at")]
70 pub triggered_at: DateTime<Utc>,
71 pub hook_event: HookEvent,
72}
73
74#[derive(Debug, Clone, Serialize)]
75#[serde(rename_all = "snake_case")]
76pub struct HookEventAfterAgent {
77 pub thread_id: ThreadId,
78 pub turn_id: String,
79 pub input_messages: Vec<String>,
80 pub last_assistant_message: Option<String>,
81}
82
83fn serialize_triggered_at<S>(value: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
84where
85 S: Serializer,
86{
87 serializer.serialize_str(&value.to_rfc3339_opts(SecondsFormat::Secs, true))
88}
89
90#[derive(Debug, Clone, Serialize)]
91#[serde(tag = "event_type", rename_all = "snake_case")]
92pub enum HookEvent {
93 AfterAgent {
94 #[serde(flatten)]
95 event: HookEventAfterAgent,
96 },
97}
98
99#[cfg(test)]
100mod tests {
101 use chrono::TimeZone;
102 use chrono::Utc;
103 use codex_protocol::ThreadId;
104 use codex_utils_absolute_path::test_support::PathBufExt;
105 use codex_utils_absolute_path::test_support::test_path_buf;
106 use pretty_assertions::assert_eq;
107 use serde_json::json;
108
109 use super::HookEvent;
110 use super::HookEventAfterAgent;
111 use super::HookPayload;
112
113 #[test]
114 fn hook_payload_serializes_stable_wire_shape() {
115 let session_id = ThreadId::new();
116 let thread_id = ThreadId::new();
117 let cwd = test_path_buf("/tmp").abs();
118 let payload = HookPayload {
119 session_id,
120 cwd: cwd.clone(),
121 client: None,
122 triggered_at: Utc
123 .with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
124 .single()
125 .expect("valid timestamp"),
126 hook_event: HookEvent::AfterAgent {
127 event: HookEventAfterAgent {
128 thread_id,
129 turn_id: "turn-1".to_string(),
130 input_messages: vec!["hello".to_string()],
131 last_assistant_message: Some("hi".to_string()),
132 },
133 },
134 };
135
136 let actual = serde_json::to_value(payload).expect("serialize hook payload");
137 let expected = json!({
138 "session_id": session_id.to_string(),
139 "cwd": cwd.display().to_string(),
140 "triggered_at": "2025-01-01T00:00:00Z",
141 "hook_event": {
142 "event_type": "after_agent",
143 "thread_id": thread_id.to_string(),
144 "turn_id": "turn-1",
145 "input_messages": ["hello"],
146 "last_assistant_message": "hi",
147 },
148 });
149
150 assert_eq!(actual, expected);
151 }
152}