Skip to main content

codex_hooks/
legacy_notify.rs

1use std::process::Stdio;
2use std::sync::Arc;
3
4use serde::Serialize;
5
6use crate::Hook;
7use crate::HookEvent;
8use crate::HookPayload;
9use crate::HookResult;
10use crate::command_from_argv;
11
12/// Legacy notify payload appended as the final argv argument for backward compatibility.
13#[derive(Debug, Clone, PartialEq, Serialize)]
14#[serde(tag = "type", rename_all = "kebab-case")]
15enum UserNotification {
16    #[serde(rename_all = "kebab-case")]
17    AgentTurnComplete {
18        thread_id: String,
19        turn_id: String,
20        cwd: String,
21        #[serde(skip_serializing_if = "Option::is_none")]
22        client: Option<String>,
23        input_messages: Vec<String>,
24        last_assistant_message: Option<String>,
25    },
26}
27
28pub fn legacy_notify_json(payload: &HookPayload) -> Result<String, serde_json::Error> {
29    match &payload.hook_event {
30        HookEvent::AfterAgent { event } => {
31            serde_json::to_string(&UserNotification::AgentTurnComplete {
32                thread_id: event.thread_id.to_string(),
33                turn_id: event.turn_id.clone(),
34                cwd: payload.cwd.display().to_string(),
35                client: payload.client.clone(),
36                input_messages: event.input_messages.clone(),
37                last_assistant_message: event.last_assistant_message.clone(),
38            })
39        }
40    }
41}
42
43pub fn notify_hook(argv: Vec<String>) -> Hook {
44    let argv = Arc::new(argv);
45    Hook {
46        name: "legacy_notify".to_string(),
47        func: Arc::new(move |payload: &HookPayload| {
48            let argv = Arc::clone(&argv);
49            Box::pin(async move {
50                let mut command = match command_from_argv(&argv) {
51                    Some(command) => command,
52                    None => return HookResult::Success,
53                };
54                if let Ok(notify_payload) = legacy_notify_json(payload) {
55                    command.arg(notify_payload);
56                }
57
58                command
59                    .stdin(Stdio::null())
60                    .stdout(Stdio::null())
61                    .stderr(Stdio::null());
62
63                match command.spawn() {
64                    Ok(_) => HookResult::Success,
65                    Err(err) => HookResult::FailedContinue(err.into()),
66                }
67            })
68        }),
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use anyhow::Result;
75    use codex_protocol::ThreadId;
76    use codex_utils_absolute_path::test_support::PathBufExt;
77    use codex_utils_absolute_path::test_support::test_path_buf;
78    use pretty_assertions::assert_eq;
79    use serde_json::Value;
80    use serde_json::json;
81
82    use super::*;
83    use crate::HookEventAfterAgent;
84
85    fn expected_notification_json() -> Value {
86        let cwd = test_path_buf("/Users/example/project");
87        json!({
88            "type": "agent-turn-complete",
89            "thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
90            "turn-id": "12345",
91            "cwd": cwd.display().to_string(),
92            "client": "codex-tui",
93            "input-messages": ["Rename `foo` to `bar` and update the callsites."],
94            "last-assistant-message": "Rename complete and verified `cargo build` succeeds.",
95        })
96    }
97
98    #[test]
99    fn test_user_notification() -> Result<()> {
100        let notification = UserNotification::AgentTurnComplete {
101            thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(),
102            turn_id: "12345".to_string(),
103            cwd: test_path_buf("/Users/example/project")
104                .display()
105                .to_string(),
106            client: Some("codex-tui".to_string()),
107            input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
108            last_assistant_message: Some(
109                "Rename complete and verified `cargo build` succeeds.".to_string(),
110            ),
111        };
112        let serialized = serde_json::to_string(&notification)?;
113        let actual: Value = serde_json::from_str(&serialized)?;
114        assert_eq!(actual, expected_notification_json());
115        Ok(())
116    }
117
118    #[test]
119    fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> {
120        let payload = HookPayload {
121            session_id: ThreadId::new(),
122            cwd: test_path_buf("/Users/example/project").abs(),
123            client: Some("codex-tui".to_string()),
124            triggered_at: chrono::Utc::now(),
125            hook_event: HookEvent::AfterAgent {
126                event: HookEventAfterAgent {
127                    thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666")
128                        .expect("valid thread id"),
129                    turn_id: "12345".to_string(),
130                    input_messages: vec![
131                        "Rename `foo` to `bar` and update the callsites.".to_string(),
132                    ],
133                    last_assistant_message: Some(
134                        "Rename complete and verified `cargo build` succeeds.".to_string(),
135                    ),
136                },
137            },
138        };
139
140        let serialized = legacy_notify_json(&payload)?;
141        let actual: Value = serde_json::from_str(&serialized)?;
142        assert_eq!(actual, expected_notification_json());
143
144        Ok(())
145    }
146}