mirror of
https://github.com/openai/codex.git
synced 2026-05-04 05:11:37 +03:00
(Experimental) This PR adds a first MVP for hooks, with SessionStart and Stop The core design is: - hooks live in a dedicated engine under codex-rs/hooks - each hook type has its own event-specific file - hook execution is synchronous and blocks normal turn progression while running - matching hooks run in parallel, then their results are aggregated into a normalized HookRunSummary On the AppServer side, hooks are exposed as operational metadata rather than transcript-native items: - new live notifications: hook/started, hook/completed - persisted/replayed hook results live on Turn.hookRuns - we intentionally did not add hook-specific ThreadItem variants Hooks messages are not persisted, they remain ephemeral. The context changes they add are (they get appended to the user's prompt)
146 lines
5.1 KiB
Rust
146 lines
5.1 KiB
Rust
use std::process::Stdio;
|
|
use std::sync::Arc;
|
|
|
|
use serde::Serialize;
|
|
|
|
use crate::Hook;
|
|
use crate::HookEvent;
|
|
use crate::HookPayload;
|
|
use crate::HookResult;
|
|
use crate::command_from_argv;
|
|
|
|
/// 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,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
client: Option<String>,
|
|
input_messages: Vec<String>,
|
|
last_assistant_message: Option<String>,
|
|
},
|
|
}
|
|
|
|
pub fn legacy_notify_json(payload: &HookPayload) -> Result<String, serde_json::Error> {
|
|
match &payload.hook_event {
|
|
HookEvent::AfterAgent { event } => {
|
|
serde_json::to_string(&UserNotification::AgentTurnComplete {
|
|
thread_id: event.thread_id.to_string(),
|
|
turn_id: event.turn_id.clone(),
|
|
cwd: payload.cwd.display().to_string(),
|
|
client: payload.client.clone(),
|
|
input_messages: event.input_messages.clone(),
|
|
last_assistant_message: event.last_assistant_message.clone(),
|
|
})
|
|
}
|
|
HookEvent::AfterToolUse { .. } => Err(serde_json::Error::io(std::io::Error::other(
|
|
"legacy notify payload is only supported for after_agent",
|
|
))),
|
|
}
|
|
}
|
|
|
|
pub fn notify_hook(argv: Vec<String>) -> Hook {
|
|
let argv = Arc::new(argv);
|
|
Hook {
|
|
name: "legacy_notify".to_string(),
|
|
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 HookResult::Success,
|
|
};
|
|
if let Ok(notify_payload) = legacy_notify_json(payload) {
|
|
command.arg(notify_payload);
|
|
}
|
|
|
|
command
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
|
|
match command.spawn() {
|
|
Ok(_) => HookResult::Success,
|
|
Err(err) => HookResult::FailedContinue(err.into()),
|
|
}
|
|
})
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use anyhow::Result;
|
|
use codex_protocol::ThreadId;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::path::Path;
|
|
|
|
use super::*;
|
|
use crate::HookEventAfterAgent;
|
|
|
|
fn expected_notification_json() -> Value {
|
|
json!({
|
|
"type": "agent-turn-complete",
|
|
"thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
|
|
"turn-id": "12345",
|
|
"cwd": "/Users/example/project",
|
|
"client": "codex-tui",
|
|
"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(),
|
|
client: Some("codex-tui".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(¬ification)?;
|
|
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 payload = HookPayload {
|
|
session_id: ThreadId::new(),
|
|
cwd: Path::new("/Users/example/project").to_path_buf(),
|
|
client: Some("codex-tui".to_string()),
|
|
triggered_at: chrono::Utc::now(),
|
|
hook_event: HookEvent::AfterAgent {
|
|
event: 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(&payload)?;
|
|
let actual: Value = serde_json::from_str(&serialized)?;
|
|
assert_eq!(actual, expected_notification_json());
|
|
|
|
Ok(())
|
|
}
|
|
}
|