Add hooks implementation and wire up to notify (#9691)

This introduces a `Hooks` service. It registers hooks from config and
dispatches hook events at runtime.

N.B. The hook config is not wired up to this yet. But for legacy
reasons, we wire up `notify` from config and power it using hooks now.
Nothing about the `notify` interface has changed.

I'd start by reviewing `hooks/types.rs`

Some things to note:
  - hook names subject to change
  - no hook result yet
  - stopping semantics yet to be introduced
  - additional hooks yet to be introduced
This commit is contained in:
gt-oai
2026-02-05 16:49:35 +00:00
committed by GitHub
Unverified
parent 9ee746afd6
commit 3b54fd7336
8 changed files with 608 additions and 105 deletions
+8
View File
@@ -0,0 +1,8 @@
mod registry;
mod types;
mod user_notification;
pub(crate) use registry::Hooks;
pub(crate) use types::HookEvent;
pub(crate) use types::HookEventAfterAgent;
pub(crate) use types::HookPayload;
+315
View File
@@ -0,0 +1,315 @@
use tokio::process::Command;
use super::types::Hook;
use super::types::HookEvent;
use super::types::HookOutcome;
use super::types::HookPayload;
use super::user_notification::notify_hook;
use crate::config::Config;
#[derive(Default, Clone)]
pub(crate) struct Hooks {
after_agent: Vec<Hook>,
}
fn get_notify_hook(config: &Config) -> Option<Hook> {
config
.notify
.as_ref()
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
.map(|argv| notify_hook(argv.clone()))
}
// Hooks are arbitrary, user-specified functions that are deterministically
// executed after specific events in the Codex lifecycle.
impl Hooks {
// new creates a new Hooks instance from config.
// For legacy compatibility, if config.notify is set, it will be added to
// the after_agent hooks.
pub(crate) fn new(config: &Config) -> Self {
let after_agent = get_notify_hook(config).into_iter().collect();
Self { after_agent }
}
fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] {
match hook_event {
HookEvent::AfterAgent { .. } => &self.after_agent,
}
}
pub(crate) async fn dispatch(&self, hook_payload: HookPayload) {
// TODO(gt): support interrupting program execution by returning a result here.
for hook in self.hooks_for_event(&hook_payload.hook_event) {
let outcome = hook.execute(&hook_payload).await;
if matches!(outcome, HookOutcome::Stop) {
break;
}
}
}
}
pub(super) fn command_from_argv(argv: &[String]) -> Option<Command> {
let (program, args) = argv.split_first()?;
if program.is_empty() {
return None;
}
let mut command = Command::new(program);
command.args(args);
Some(command)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use anyhow::Result;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::to_string;
use tempfile::tempdir;
use tokio::time::timeout;
use crate::config::test_config;
use super::super::types::Hook;
use super::super::types::HookEvent;
use super::super::types::HookEventAfterAgent;
use super::super::types::HookOutcome;
use super::super::types::HookPayload;
use super::Hooks;
use super::command_from_argv;
use super::get_notify_hook;
const CWD: &str = "/tmp";
const INPUT_MESSAGE: &str = "hello";
fn hook_payload(label: &str) -> HookPayload {
HookPayload {
session_id: ThreadId::new(),
cwd: PathBuf::from(CWD),
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event: HookEvent::AfterAgent {
event: HookEventAfterAgent {
thread_id: ThreadId::new(),
turn_id: format!("turn-{label}"),
input_messages: vec![INPUT_MESSAGE.to_string()],
last_assistant_message: Some("hi".to_string()),
},
},
}
}
fn counting_hook(calls: &Arc<AtomicUsize>, outcome: HookOutcome) -> Hook {
let calls = Arc::clone(calls);
Hook {
func: Arc::new(move |_| {
let calls = Arc::clone(&calls);
Box::pin(async move {
calls.fetch_add(1, Ordering::SeqCst);
outcome
})
}),
}
}
fn hooks_for_after_agent(hooks: Vec<Hook>) -> Hooks {
Hooks { after_agent: hooks }
}
#[test]
fn command_from_argv_returns_none_for_empty_args() {
assert!(command_from_argv(&[]).is_none());
assert!(command_from_argv(&["".to_string()]).is_none());
}
#[tokio::test]
async fn command_from_argv_builds_command() -> Result<()> {
let argv = if cfg!(windows) {
vec![
"cmd".to_string(),
"/C".to_string(),
"echo hello world".to_string(),
]
} else {
vec!["echo".to_string(), "hello".to_string(), "world".to_string()]
};
let mut command = command_from_argv(&argv).ok_or_else(|| anyhow::anyhow!("command"))?;
let output = command.stdout(Stdio::piped()).output().await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim_end_matches(['\r', '\n']);
assert_eq!(trimmed, "hello world");
Ok(())
}
#[test]
fn get_notify_hook_requires_program_name() {
let mut config = test_config();
config.notify = Some(vec![]);
assert!(get_notify_hook(&config).is_none());
config.notify = Some(vec!["".to_string()]);
assert!(get_notify_hook(&config).is_none());
config.notify = Some(vec!["notify-send".to_string()]);
assert!(get_notify_hook(&config).is_some());
}
#[tokio::test]
async fn dispatch_executes_hook() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = hooks_for_after_agent(vec![counting_hook(&calls, HookOutcome::Continue)]);
hooks.dispatch(hook_payload("1")).await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn default_hook_is_noop_and_continues() {
let payload = hook_payload("d");
let outcome = Hook::default().execute(&payload).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn dispatch_executes_multiple_hooks_for_same_event() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = hooks_for_after_agent(vec![
counting_hook(&calls, HookOutcome::Continue),
counting_hook(&calls, HookOutcome::Continue),
]);
hooks.dispatch(hook_payload("2")).await;
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn dispatch_stops_when_hook_returns_stop() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = hooks_for_after_agent(vec![
counting_hook(&calls, HookOutcome::Stop),
counting_hook(&calls, HookOutcome::Continue),
]);
hooks.dispatch(hook_payload("3")).await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[cfg(not(windows))]
#[tokio::test]
async fn hook_executes_program_with_payload_argument_unix() -> Result<()> {
let temp_dir = tempdir()?;
let payload_path = temp_dir.path().join("payload.json");
let payload_path_arg = payload_path.to_string_lossy().into_owned();
let hook = Hook {
func: Arc::new(move |payload: &HookPayload| {
let payload_path_arg = payload_path_arg.clone();
Box::pin(async move {
let json = to_string(payload).expect("serialize hook payload");
let mut command = command_from_argv(&[
"/bin/sh".to_string(),
"-c".to_string(),
"printf '%s' \"$2\" > \"$1\"".to_string(),
"sh".to_string(),
payload_path_arg,
json,
])
.expect("build command");
command.status().await.expect("run hook command");
HookOutcome::Continue
})
}),
};
let payload = hook_payload("4");
let expected = to_string(&payload)?;
let hooks = hooks_for_after_agent(vec![hook]);
hooks.dispatch(payload).await;
let contents = timeout(Duration::from_secs(2), async {
loop {
if let Ok(contents) = fs::read_to_string(&payload_path)
&& !contents.is_empty()
{
return contents;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
assert_eq!(contents, expected);
Ok(())
}
#[cfg(windows)]
#[tokio::test]
async fn hook_executes_program_with_payload_argument_windows() -> Result<()> {
let temp_dir = tempdir()?;
let payload_path = temp_dir.path().join("payload.json");
let payload_path_arg = payload_path.to_string_lossy().into_owned();
let script_path = temp_dir.path().join("write_payload.ps1");
fs::write(&script_path, "[IO.File]::WriteAllText($args[0], $args[1])")?;
let script_path_arg = script_path.to_string_lossy().into_owned();
let hook = Hook {
func: Arc::new(move |payload: &HookPayload| {
let payload_path_arg = payload_path_arg.clone();
let script_path_arg = script_path_arg.clone();
Box::pin(async move {
let json = to_string(payload).expect("serialize hook payload");
let powershell = crate::powershell::try_find_powershell_executable_blocking()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_else(|| "powershell.exe".to_string());
let mut command = command_from_argv(&[
powershell,
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
script_path_arg,
payload_path_arg,
json,
])
.expect("build command");
command.status().await.expect("run hook command");
HookOutcome::Continue
})
}),
};
let payload = hook_payload("4");
let expected = to_string(&payload)?;
let hooks = hooks_for_after_agent(vec![hook]);
hooks.dispatch(payload).await;
let contents = timeout(Duration::from_secs(2), async {
loop {
if let Ok(contents) = fs::read_to_string(&payload_path)
&& !contents.is_empty()
{
return contents;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
assert_eq!(contents, expected);
Ok(())
}
}
+127
View File
@@ -0,0 +1,127 @@
use std::path::PathBuf;
use std::sync::Arc;
use chrono::DateTime;
use chrono::SecondsFormat;
use chrono::Utc;
use codex_protocol::ThreadId;
use futures::future::BoxFuture;
use serde::Serialize;
use serde::Serializer;
pub(crate) type HookFn =
Arc<dyn for<'a> Fn(&'a HookPayload) -> BoxFuture<'a, HookOutcome> + Send + Sync>;
#[derive(Clone)]
pub(crate) struct Hook {
pub(crate) func: HookFn,
}
impl Default for Hook {
fn default() -> Self {
Self {
func: Arc::new(|_| Box::pin(async { HookOutcome::Continue })),
}
}
}
impl Hook {
pub(super) async fn execute(&self, payload: &HookPayload) -> HookOutcome {
(self.func)(payload).await
}
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "snake_case")]
pub(crate) struct HookPayload {
pub(crate) session_id: ThreadId,
pub(crate) cwd: PathBuf,
#[serde(serialize_with = "serialize_triggered_at")]
pub(crate) triggered_at: DateTime<Utc>,
pub(crate) hook_event: HookEvent,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct HookEventAfterAgent {
pub thread_id: ThreadId,
pub turn_id: String,
pub input_messages: Vec<String>,
pub last_assistant_message: Option<String>,
}
fn serialize_triggered_at<S>(value: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.to_rfc3339_opts(SecondsFormat::Secs, true))
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event_type", rename_all = "snake_case")]
pub(crate) enum HookEvent {
AfterAgent {
#[serde(flatten)]
event: HookEventAfterAgent,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookOutcome {
Continue,
#[allow(dead_code)]
Stop,
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::json;
use super::HookEvent;
use super::HookEventAfterAgent;
use super::HookPayload;
#[test]
fn hook_payload_serializes_stable_wire_shape() {
let session_id = ThreadId::new();
let thread_id = ThreadId::new();
let payload = HookPayload {
session_id,
cwd: PathBuf::from("tmp"),
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event: HookEvent::AfterAgent {
event: HookEventAfterAgent {
thread_id,
turn_id: "turn-1".to_string(),
input_messages: vec!["hello".to_string()],
last_assistant_message: Some("hi".to_string()),
},
},
};
let actual = serde_json::to_value(payload).expect("serialize hook payload");
let expected = json!({
"session_id": session_id.to_string(),
"cwd": "tmp",
"triggered_at": "2025-01-01T00:00:00Z",
"hook_event": {
"event_type": "after_agent",
"thread_id": thread_id.to_string(),
"turn_id": "turn-1",
"input_messages": ["hello"],
"last_assistant_message": "hi",
},
});
assert_eq!(actual, expected);
}
}
@@ -0,0 +1,132 @@
use std::sync::Arc;
use serde::Serialize;
use std::path::Path;
use std::process::Stdio;
use super::registry::command_from_argv;
use super::types::Hook;
use super::types::HookEvent;
use super::types::HookOutcome;
use super::types::HookPayload;
/// Legacy notify payload appended as the final argv argument for backward compatibility.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum UserNotification {
#[serde(rename_all = "kebab-case")]
AgentTurnComplete {
thread_id: String,
turn_id: String,
cwd: String,
/// Messages that the user sent to the agent to initiate the turn.
input_messages: Vec<String>,
/// The last message sent by the assistant in the turn.
last_assistant_message: Option<String>,
},
}
pub(super) fn legacy_notify_json(
hook_event: &HookEvent,
cwd: &Path,
) -> Result<String, serde_json::Error> {
serde_json::to_string(&match hook_event {
HookEvent::AfterAgent { event } => UserNotification::AgentTurnComplete {
thread_id: event.thread_id.to_string(),
turn_id: event.turn_id.clone(),
cwd: cwd.display().to_string(),
input_messages: event.input_messages.clone(),
last_assistant_message: event.last_assistant_message.clone(),
},
})
}
pub(super) fn notify_hook(argv: Vec<String>) -> Hook {
let argv = Arc::new(argv);
Hook {
func: Arc::new(move |payload: &HookPayload| {
let argv = Arc::clone(&argv);
Box::pin(async move {
let mut command = match command_from_argv(&argv) {
Some(command) => command,
None => return HookOutcome::Continue,
};
if let Ok(notify_payload) = legacy_notify_json(&payload.hook_event, &payload.cwd) {
command.arg(notify_payload);
}
// Backwards-compat: match legacy notify behavior (argv + JSON arg, fire-and-forget).
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = command.spawn();
HookOutcome::Continue
})
}),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use anyhow::Result;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
fn expected_notification_json() -> Value {
json!({
"type": "agent-turn-complete",
"thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
"turn-id": "12345",
"cwd": "/Users/example/project",
"input-messages": ["Rename `foo` to `bar` and update the callsites."],
"last-assistant-message": "Rename complete and verified `cargo build` succeeds.",
})
}
#[test]
fn test_user_notification() -> Result<()> {
let notification = UserNotification::AgentTurnComplete {
thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(),
turn_id: "12345".to_string(),
cwd: "/Users/example/project".to_string(),
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
};
let serialized = serde_json::to_string(&notification)?;
let actual: Value = serde_json::from_str(&serialized)?;
assert_eq!(actual, expected_notification_json());
Ok(())
}
#[test]
fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> {
let hook_event = HookEvent::AfterAgent {
event: super::super::types::HookEventAfterAgent {
thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666")
.expect("valid thread id"),
turn_id: "12345".to_string(),
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
},
};
let serialized = legacy_notify_json(&hook_event, Path::new("/Users/example/project"))?;
let actual: Value = serde_json::from_str(&serialized)?;
assert_eq!(actual, expected_notification_json());
Ok(())
}
}