codex_hooks/
legacy_notify.rs1use 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#[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(¬ification)?;
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}