Skip to main content

codex_hooks/
types.rs

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: hook completed successfully.
17    Success,
18    /// FailedContinue: hook failed, but other subsequent hooks should still execute and the
19    /// operation should continue.
20    FailedContinue(Box<dyn std::error::Error + Send + Sync + 'static>),
21    /// FailedAbort: hook failed, other subsequent hooks should not execute, and the operation
22    /// should be aborted.
23    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}