mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
feat(hooks): align lifecycle hooks with Claude-style naming and payloads
This commit is contained in:
@@ -638,7 +638,19 @@
|
||||
"additionalProperties": false,
|
||||
"description": "Lifecycle hook command config deserialized from `[hooks]` in config.toml.",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"post_tool_use": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"pre_compact": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"pre_tool_use": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -656,7 +668,7 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"subagent_end": {
|
||||
"stop": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -668,13 +680,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"turn_end": {
|
||||
"subagent_stop": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"turn_start": {
|
||||
"user_prompt_submit": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1966,13 +1978,15 @@
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"compaction": null,
|
||||
"post_tool_use": null,
|
||||
"pre_compact": null,
|
||||
"pre_tool_use": null,
|
||||
"session_end": null,
|
||||
"session_start": null,
|
||||
"subagent_end": null,
|
||||
"stop": null,
|
||||
"subagent_start": null,
|
||||
"turn_end": null,
|
||||
"turn_start": null
|
||||
"subagent_stop": null,
|
||||
"user_prompt_submit": null
|
||||
},
|
||||
"description": "Optional external hook commands keyed by lifecycle event."
|
||||
},
|
||||
|
||||
+39
-37
@@ -1587,20 +1587,20 @@ impl Session {
|
||||
for event in events {
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
let hook_session_ref = compute_hook_session_ref(sess.as_ref()).await;
|
||||
let transcript_path = compute_hook_transcript_path(sess.as_ref()).await;
|
||||
dispatch_nonfatal_lifecycle_hook(
|
||||
sess.as_ref(),
|
||||
HookPayload {
|
||||
session_id: conversation_id,
|
||||
transcript_path,
|
||||
cwd: session_configuration.cwd.clone(),
|
||||
client: None,
|
||||
client: session_configuration.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::SessionStart {
|
||||
event: HookEventLifecycle {
|
||||
session_ref: hook_session_ref,
|
||||
previous_session_id: None,
|
||||
prompt: None,
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
@@ -4642,13 +4642,11 @@ mod handlers {
|
||||
|
||||
// Gracefully flush and shutdown rollout recorder on session end so tests
|
||||
// that inspect the rollout file do not race with the background writer.
|
||||
let hook_session_ref = {
|
||||
let rollout = sess.services.rollout.lock().await;
|
||||
rollout
|
||||
.as_ref()
|
||||
.map(|recorder| recorder.rollout_path().display().to_string())
|
||||
.unwrap_or_else(|| sess.conversation_id.to_string())
|
||||
let client = {
|
||||
let state = sess.state.lock().await;
|
||||
state.session_configuration.app_server_client_name.clone()
|
||||
};
|
||||
let transcript_path = super::compute_hook_transcript_path(sess.as_ref()).await;
|
||||
let recorder_opt = {
|
||||
let mut guard = sess.services.rollout.lock().await;
|
||||
guard.take()
|
||||
@@ -4670,15 +4668,15 @@ mod handlers {
|
||||
sess.as_ref(),
|
||||
codex_hooks::HookPayload {
|
||||
session_id: sess.conversation_id,
|
||||
transcript_path,
|
||||
cwd,
|
||||
client: None,
|
||||
client,
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: codex_hooks::HookEvent::SessionEnd {
|
||||
event: codex_hooks::HookEventLifecycle {
|
||||
session_ref: hook_session_ref,
|
||||
previous_session_id: None,
|
||||
prompt: None,
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
@@ -4925,21 +4923,22 @@ fn hooks_config_from_config(config: &Config) -> HooksConfig {
|
||||
HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
session_start_argv: config.hooks.session_start.clone(),
|
||||
turn_start_argv: config.hooks.turn_start.clone(),
|
||||
turn_end_argv: config.hooks.turn_end.clone(),
|
||||
compaction_argv: config.hooks.compaction.clone(),
|
||||
user_prompt_submit_argv: config.hooks.user_prompt_submit.clone(),
|
||||
pre_tool_use_argv: config.hooks.pre_tool_use.clone(),
|
||||
post_tool_use_argv: config.hooks.post_tool_use.clone(),
|
||||
stop_argv: config.hooks.stop.clone(),
|
||||
pre_compact_argv: config.hooks.pre_compact.clone(),
|
||||
session_end_argv: config.hooks.session_end.clone(),
|
||||
subagent_start_argv: config.hooks.subagent_start.clone(),
|
||||
subagent_end_argv: config.hooks.subagent_end.clone(),
|
||||
subagent_stop_argv: config.hooks.subagent_stop.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn compute_hook_session_ref(session: &Session) -> String {
|
||||
pub(crate) async fn compute_hook_transcript_path(session: &Session) -> Option<String> {
|
||||
let rollout = session.services.rollout.lock().await;
|
||||
rollout
|
||||
.as_ref()
|
||||
.map(|recorder| recorder.rollout_path().display().to_string())
|
||||
.unwrap_or_else(|| session.conversation_id.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn dispatch_nonfatal_lifecycle_hook(session: &Session, hook_payload: HookPayload) {
|
||||
@@ -5011,20 +5010,20 @@ pub(crate) async fn run_turn(
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let hook_session_ref = compute_hook_session_ref(sess.as_ref()).await;
|
||||
let transcript_path = compute_hook_transcript_path(sess.as_ref()).await;
|
||||
dispatch_nonfatal_lifecycle_hook(
|
||||
sess.as_ref(),
|
||||
HookPayload {
|
||||
session_id: sess.conversation_id,
|
||||
transcript_path,
|
||||
cwd: turn_context.cwd.clone(),
|
||||
client: turn_context.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::TurnStart {
|
||||
hook_event: HookEvent::UserPromptSubmit {
|
||||
event: HookEventLifecycle {
|
||||
session_ref: hook_session_ref,
|
||||
previous_session_id: None,
|
||||
prompt: (!prompt.is_empty()).then_some(prompt),
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
@@ -5285,22 +5284,23 @@ pub(crate) async fn run_turn(
|
||||
}
|
||||
|
||||
if !needs_follow_up {
|
||||
let last_assistant_message = sampling_request_last_agent_message.clone();
|
||||
last_agent_message = sampling_request_last_agent_message;
|
||||
let turn_end_prompt = sampling_request_input_messages.last().cloned();
|
||||
let hook_session_ref = compute_hook_session_ref(sess.as_ref()).await;
|
||||
let transcript_path = compute_hook_transcript_path(sess.as_ref()).await;
|
||||
dispatch_nonfatal_lifecycle_hook(
|
||||
sess.as_ref(),
|
||||
HookPayload {
|
||||
session_id: sess.conversation_id,
|
||||
transcript_path,
|
||||
cwd: turn_context.cwd.clone(),
|
||||
client: turn_context.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::TurnEnd {
|
||||
hook_event: HookEvent::Stop {
|
||||
event: HookEventLifecycle {
|
||||
session_ref: hook_session_ref,
|
||||
previous_session_id: None,
|
||||
prompt: turn_end_prompt,
|
||||
response_message: None,
|
||||
last_assistant_message,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
@@ -5310,10 +5310,12 @@ pub(crate) async fn run_turn(
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let transcript_path = compute_hook_transcript_path(sess.as_ref()).await;
|
||||
let hook_outcomes = sess
|
||||
.hooks()
|
||||
.dispatch(HookPayload {
|
||||
session_id: sess.conversation_id,
|
||||
transcript_path,
|
||||
cwd: turn_context.cwd.clone(),
|
||||
client: turn_context.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
@@ -5493,20 +5495,20 @@ async fn run_auto_compact(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let hook_session_ref = compute_hook_session_ref(sess.as_ref()).await;
|
||||
let transcript_path = compute_hook_transcript_path(sess.as_ref()).await;
|
||||
dispatch_nonfatal_lifecycle_hook(
|
||||
sess.as_ref(),
|
||||
HookPayload {
|
||||
session_id: sess.conversation_id,
|
||||
transcript_path,
|
||||
cwd: turn_context.cwd.clone(),
|
||||
client: turn_context.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::Compaction {
|
||||
hook_event: HookEvent::PreCompact {
|
||||
event: HookEventLifecycle {
|
||||
session_ref: hook_session_ref,
|
||||
previous_session_id: None,
|
||||
prompt: Some(turn_context.compact_prompt().to_string()),
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
@@ -8855,16 +8857,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compute_hook_session_ref_falls_back_to_conversation_id_without_rollout() {
|
||||
async fn compute_hook_transcript_path_is_none_without_rollout() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
|
||||
let session_ref = compute_hook_session_ref(&session).await;
|
||||
let transcript_path = compute_hook_transcript_path(&session).await;
|
||||
|
||||
assert_eq!(session_ref, session.conversation_id.to_string());
|
||||
assert_eq!(transcript_path, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compute_hook_session_ref_prefers_rollout_path_when_available() {
|
||||
async fn compute_hook_transcript_path_prefers_rollout_path_when_available() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let config = {
|
||||
let state = session.state.lock().await;
|
||||
@@ -8891,9 +8893,9 @@ mod tests {
|
||||
*rollout = Some(recorder);
|
||||
}
|
||||
|
||||
let session_ref = compute_hook_session_ref(&session).await;
|
||||
let transcript_path = compute_hook_transcript_path(&session).await;
|
||||
|
||||
assert_eq!(session_ref, rollout_path);
|
||||
assert_eq!(transcript_path, Some(rollout_path));
|
||||
}
|
||||
|
||||
pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
|
||||
@@ -1014,14 +1014,17 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R
|
||||
/// Lifecycle hook command config deserialized from `[hooks]` in config.toml.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HooksToml {
|
||||
pub session_start: Option<Vec<String>>,
|
||||
pub turn_start: Option<Vec<String>>,
|
||||
pub turn_end: Option<Vec<String>>,
|
||||
pub compaction: Option<Vec<String>>,
|
||||
pub user_prompt_submit: Option<Vec<String>>,
|
||||
pub pre_tool_use: Option<Vec<String>>,
|
||||
pub post_tool_use: Option<Vec<String>>,
|
||||
pub stop: Option<Vec<String>>,
|
||||
pub pre_compact: Option<Vec<String>>,
|
||||
pub session_end: Option<Vec<String>>,
|
||||
pub subagent_start: Option<Vec<String>>,
|
||||
pub subagent_end: Option<Vec<String>>,
|
||||
pub subagent_stop: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Base config deserialized from ~/.codex/config.toml.
|
||||
@@ -6685,6 +6688,59 @@ speaker = "Desk Speakers"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hooks_toml_tests {
|
||||
use super::ConfigToml;
|
||||
use super::HooksToml;
|
||||
|
||||
#[test]
|
||||
fn hooks_toml_uses_claude_style_hook_names() {
|
||||
let toml = r#"
|
||||
[hooks]
|
||||
session_start = ["hooks", "SessionStart"]
|
||||
user_prompt_submit = ["hooks", "UserPromptSubmit"]
|
||||
pre_tool_use = ["hooks", "PreToolUse"]
|
||||
post_tool_use = ["hooks", "PostToolUse"]
|
||||
stop = ["hooks", "Stop"]
|
||||
pre_compact = ["hooks", "PreCompact"]
|
||||
session_end = ["hooks", "SessionEnd"]
|
||||
subagent_start = ["hooks", "SubagentStart"]
|
||||
subagent_stop = ["hooks", "SubagentStop"]
|
||||
"#;
|
||||
|
||||
let parsed: ConfigToml =
|
||||
toml::from_str(toml).expect("TOML deserialization should succeed for hooks");
|
||||
|
||||
assert_eq!(
|
||||
parsed.hooks,
|
||||
HooksToml {
|
||||
session_start: Some(vec!["hooks".to_string(), "SessionStart".to_string()]),
|
||||
user_prompt_submit: Some(vec!["hooks".to_string(), "UserPromptSubmit".to_string()]),
|
||||
pre_tool_use: Some(vec!["hooks".to_string(), "PreToolUse".to_string()]),
|
||||
post_tool_use: Some(vec!["hooks".to_string(), "PostToolUse".to_string()]),
|
||||
stop: Some(vec!["hooks".to_string(), "Stop".to_string()]),
|
||||
pre_compact: Some(vec!["hooks".to_string(), "PreCompact".to_string()]),
|
||||
session_end: Some(vec!["hooks".to_string(), "SessionEnd".to_string()]),
|
||||
subagent_start: Some(vec!["hooks".to_string(), "SubagentStart".to_string()]),
|
||||
subagent_stop: Some(vec!["hooks".to_string(), "SubagentStop".to_string()]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_toml_rejects_unlanded_legacy_hook_names() {
|
||||
let toml = r#"
|
||||
[hooks]
|
||||
turn_start = ["hooks", "turn_start"]
|
||||
"#;
|
||||
|
||||
let err =
|
||||
toml::from_str::<ConfigToml>(toml).expect_err("legacy hook names should be rejected");
|
||||
|
||||
assert!(err.to_string().contains("turn_start"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod notifications_tests {
|
||||
use crate::config::types::NotificationMethod;
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::agent::AgentStatus;
|
||||
use crate::agent::exceeds_thread_spawn_depth_limit;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex::compute_hook_session_ref;
|
||||
use crate::codex::compute_hook_transcript_path;
|
||||
use crate::codex::dispatch_nonfatal_lifecycle_hook;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
@@ -162,20 +162,20 @@ mod spawn {
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
let hook_session_ref = compute_hook_session_ref(session.as_ref()).await;
|
||||
let transcript_path = compute_hook_transcript_path(session.as_ref()).await;
|
||||
dispatch_nonfatal_lifecycle_hook(
|
||||
session.as_ref(),
|
||||
HookPayload {
|
||||
session_id: session.conversation_id,
|
||||
transcript_path: transcript_path.clone(),
|
||||
cwd: turn.cwd.clone(),
|
||||
client: turn.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::SubagentStart {
|
||||
event: HookEventLifecycle {
|
||||
session_ref: hook_session_ref.clone(),
|
||||
previous_session_id: None,
|
||||
prompt: prompt_for_hook.clone(),
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: Some(call_id.clone()),
|
||||
tool_input: Some(HookToolInput::Function {
|
||||
arguments: arguments.clone(),
|
||||
@@ -248,15 +248,15 @@ mod spawn {
|
||||
session.as_ref(),
|
||||
HookPayload {
|
||||
session_id: session.conversation_id,
|
||||
transcript_path,
|
||||
cwd: turn.cwd.clone(),
|
||||
client: turn.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::SubagentEnd {
|
||||
hook_event: HookEvent::SubagentStop {
|
||||
event: HookEventLifecycle {
|
||||
session_ref: hook_session_ref,
|
||||
previous_session_id: None,
|
||||
prompt: prompt_for_hook,
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: Some(call_id.clone()),
|
||||
tool_input: Some(HookToolInput::Function {
|
||||
arguments: arguments.clone(),
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::codex::compute_hook_transcript_path;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::memories::usage::emit_metric_for_tool_read;
|
||||
@@ -14,7 +15,8 @@ use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use async_trait::async_trait;
|
||||
use codex_hooks::HookEvent;
|
||||
use codex_hooks::HookEventAfterToolUse;
|
||||
use codex_hooks::HookEventPostToolUse;
|
||||
use codex_hooks::HookEventPreToolUse;
|
||||
use codex_hooks::HookPayload;
|
||||
use codex_hooks::HookResult;
|
||||
use codex_hooks::HookToolInput;
|
||||
@@ -163,6 +165,9 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
let is_mutating = handler.is_mutating(&invocation).await;
|
||||
if let Some(err) = dispatch_pre_tool_use_hook(&invocation, is_mutating).await {
|
||||
return Err(err);
|
||||
}
|
||||
let output_cell = tokio::sync::Mutex::new(None);
|
||||
let invocation_for_tool = invocation.clone();
|
||||
|
||||
@@ -204,7 +209,7 @@ impl ToolRegistry {
|
||||
Err(err) => (err.to_string(), false),
|
||||
};
|
||||
emit_metric_for_tool_read(&invocation, success).await;
|
||||
let hook_abort_error = dispatch_after_tool_use_hook(AfterToolUseHookDispatch {
|
||||
let hook_abort_error = dispatch_post_tool_use_hook(PostToolUseHookDispatch {
|
||||
invocation: &invocation,
|
||||
output_preview,
|
||||
success,
|
||||
@@ -366,7 +371,7 @@ fn hook_tool_kind(tool_input: &HookToolInput) -> HookToolKind {
|
||||
}
|
||||
}
|
||||
|
||||
struct AfterToolUseHookDispatch<'a> {
|
||||
struct PostToolUseHookDispatch<'a> {
|
||||
invocation: &'a ToolInvocation,
|
||||
output_preview: String,
|
||||
success: bool,
|
||||
@@ -375,22 +380,93 @@ struct AfterToolUseHookDispatch<'a> {
|
||||
mutating: bool,
|
||||
}
|
||||
|
||||
async fn dispatch_after_tool_use_hook(
|
||||
dispatch: AfterToolUseHookDispatch<'_>,
|
||||
async fn dispatch_pre_tool_use_hook(
|
||||
invocation: &ToolInvocation,
|
||||
mutating: bool,
|
||||
) -> Option<FunctionCallError> {
|
||||
let AfterToolUseHookDispatch { invocation, .. } = dispatch;
|
||||
let session = invocation.session.as_ref();
|
||||
let turn = invocation.turn.as_ref();
|
||||
let tool_input = HookToolInput::from(&invocation.payload);
|
||||
let transcript_path = compute_hook_transcript_path(session).await;
|
||||
let hook_outcomes = session
|
||||
.hooks()
|
||||
.dispatch(HookPayload {
|
||||
session_id: session.conversation_id,
|
||||
transcript_path,
|
||||
cwd: turn.cwd.clone(),
|
||||
client: turn.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::AfterToolUse {
|
||||
event: HookEventAfterToolUse {
|
||||
hook_event: HookEvent::PreToolUse {
|
||||
event: HookEventPreToolUse {
|
||||
turn_id: turn.sub_id.clone(),
|
||||
call_id: invocation.call_id.clone(),
|
||||
tool_name: invocation.tool_name.clone(),
|
||||
tool_kind: hook_tool_kind(&tool_input),
|
||||
tool_input,
|
||||
mutating: Some(mutating),
|
||||
sandbox: Some(
|
||||
sandbox_tag(
|
||||
&turn.sandbox_policy,
|
||||
turn.windows_sandbox_level,
|
||||
turn.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
)
|
||||
.to_string(),
|
||||
),
|
||||
sandbox_policy: Some(sandbox_policy_tag(&turn.sandbox_policy).to_string()),
|
||||
},
|
||||
},
|
||||
})
|
||||
.await;
|
||||
|
||||
for hook_outcome in hook_outcomes {
|
||||
let hook_name = hook_outcome.hook_name;
|
||||
match hook_outcome.result {
|
||||
HookResult::Success => {}
|
||||
HookResult::FailedContinue(error) => {
|
||||
warn!(
|
||||
call_id = %invocation.call_id,
|
||||
tool_name = %invocation.tool_name,
|
||||
hook_name = %hook_name,
|
||||
error = %error,
|
||||
"pre_tool_use hook failed; continuing"
|
||||
);
|
||||
}
|
||||
HookResult::FailedAbort(error) => {
|
||||
warn!(
|
||||
call_id = %invocation.call_id,
|
||||
tool_name = %invocation.tool_name,
|
||||
hook_name = %hook_name,
|
||||
error = %error,
|
||||
"pre_tool_use hook failed; aborting operation"
|
||||
);
|
||||
return Some(FunctionCallError::Fatal(format!(
|
||||
"pre_tool_use hook '{hook_name}' failed and aborted operation: {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn dispatch_post_tool_use_hook(
|
||||
dispatch: PostToolUseHookDispatch<'_>,
|
||||
) -> Option<FunctionCallError> {
|
||||
let PostToolUseHookDispatch { invocation, .. } = dispatch;
|
||||
let session = invocation.session.as_ref();
|
||||
let turn = invocation.turn.as_ref();
|
||||
let tool_input = HookToolInput::from(&invocation.payload);
|
||||
let transcript_path = compute_hook_transcript_path(session).await;
|
||||
let hook_outcomes = session
|
||||
.hooks()
|
||||
.dispatch(HookPayload {
|
||||
session_id: session.conversation_id,
|
||||
transcript_path,
|
||||
cwd: turn.cwd.clone(),
|
||||
client: turn.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::PostToolUse {
|
||||
event: HookEventPostToolUse {
|
||||
turn_id: turn.sub_id.clone(),
|
||||
call_id: invocation.call_id.clone(),
|
||||
tool_name: invocation.tool_name.clone(),
|
||||
@@ -423,7 +499,7 @@ async fn dispatch_after_tool_use_hook(
|
||||
tool_name = %invocation.tool_name,
|
||||
hook_name = %hook_name,
|
||||
error = %error,
|
||||
"after_tool_use hook failed; continuing"
|
||||
"post_tool_use hook failed; continuing"
|
||||
);
|
||||
}
|
||||
HookResult::FailedAbort(error) => {
|
||||
@@ -432,10 +508,10 @@ async fn dispatch_after_tool_use_hook(
|
||||
tool_name = %invocation.tool_name,
|
||||
hook_name = %hook_name,
|
||||
error = %error,
|
||||
"after_tool_use hook failed; aborting operation"
|
||||
"post_tool_use hook failed; aborting operation"
|
||||
);
|
||||
return Some(FunctionCallError::Fatal(format!(
|
||||
"after_tool_use hook '{hook_name}' failed and aborted operation: {error}"
|
||||
"post_tool_use hook '{hook_name}' failed and aborted operation: {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ codex-protocol = { workspace = true }
|
||||
futures = { workspace = true, features = ["alloc"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process"] }
|
||||
tokio = { workspace = true, features = ["io-util", "process"] }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
|
||||
@@ -8,8 +8,9 @@ pub use registry::command_from_argv;
|
||||
pub use types::Hook;
|
||||
pub use types::HookEvent;
|
||||
pub use types::HookEventAfterAgent;
|
||||
pub use types::HookEventAfterToolUse;
|
||||
pub use types::HookEventLifecycle;
|
||||
pub use types::HookEventPostToolUse;
|
||||
pub use types::HookEventPreToolUse;
|
||||
pub use types::HookPayload;
|
||||
pub use types::HookResponse;
|
||||
pub use types::HookResult;
|
||||
|
||||
+242
-132
@@ -1,6 +1,7 @@
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::types::Hook;
|
||||
@@ -13,25 +14,28 @@ use crate::types::HookResult;
|
||||
pub struct HooksConfig {
|
||||
pub legacy_notify_argv: Option<Vec<String>>,
|
||||
pub session_start_argv: Option<Vec<String>>,
|
||||
pub turn_start_argv: Option<Vec<String>>,
|
||||
pub turn_end_argv: Option<Vec<String>>,
|
||||
pub compaction_argv: Option<Vec<String>>,
|
||||
pub user_prompt_submit_argv: Option<Vec<String>>,
|
||||
pub pre_tool_use_argv: Option<Vec<String>>,
|
||||
pub post_tool_use_argv: Option<Vec<String>>,
|
||||
pub stop_argv: Option<Vec<String>>,
|
||||
pub pre_compact_argv: Option<Vec<String>>,
|
||||
pub session_end_argv: Option<Vec<String>>,
|
||||
pub subagent_start_argv: Option<Vec<String>>,
|
||||
pub subagent_end_argv: Option<Vec<String>>,
|
||||
pub subagent_stop_argv: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Hooks {
|
||||
after_agent: Vec<Hook>,
|
||||
after_tool_use: Vec<Hook>,
|
||||
pre_tool_use: Vec<Hook>,
|
||||
post_tool_use: Vec<Hook>,
|
||||
session_start: Vec<Hook>,
|
||||
turn_start: Vec<Hook>,
|
||||
turn_end: Vec<Hook>,
|
||||
compaction: Vec<Hook>,
|
||||
user_prompt_submit: Vec<Hook>,
|
||||
stop: Vec<Hook>,
|
||||
pre_compact: Vec<Hook>,
|
||||
session_end: Vec<Hook>,
|
||||
subagent_start: Vec<Hook>,
|
||||
subagent_end: Vec<Hook>,
|
||||
subagent_stop: Vec<Hook>,
|
||||
}
|
||||
|
||||
impl Default for Hooks {
|
||||
@@ -57,22 +61,34 @@ impl Hooks {
|
||||
.map(|argv| command_hook("session_start", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let turn_start = config
|
||||
.turn_start_argv
|
||||
let user_prompt_submit = config
|
||||
.user_prompt_submit_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("turn_start", argv))
|
||||
.map(|argv| command_hook("user_prompt_submit", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let turn_end = config
|
||||
.turn_end_argv
|
||||
let pre_tool_use = config
|
||||
.pre_tool_use_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("turn_end", argv))
|
||||
.map(|argv| command_hook("pre_tool_use", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let compaction = config
|
||||
.compaction_argv
|
||||
let post_tool_use = config
|
||||
.post_tool_use_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("compaction", argv))
|
||||
.map(|argv| command_hook("post_tool_use", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let stop = config
|
||||
.stop_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("stop", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let pre_compact = config
|
||||
.pre_compact_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("pre_compact", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let session_end = config
|
||||
@@ -87,36 +103,38 @@ impl Hooks {
|
||||
.map(|argv| command_hook("subagent_start", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let subagent_end = config
|
||||
.subagent_end_argv
|
||||
let subagent_stop = config
|
||||
.subagent_stop_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("subagent_end", argv))
|
||||
.map(|argv| command_hook("subagent_stop", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
Self {
|
||||
after_agent,
|
||||
after_tool_use: Vec::new(),
|
||||
pre_tool_use,
|
||||
post_tool_use,
|
||||
session_start,
|
||||
turn_start,
|
||||
turn_end,
|
||||
compaction,
|
||||
user_prompt_submit,
|
||||
stop,
|
||||
pre_compact,
|
||||
session_end,
|
||||
subagent_start,
|
||||
subagent_end,
|
||||
subagent_stop,
|
||||
}
|
||||
}
|
||||
|
||||
fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] {
|
||||
match hook_event {
|
||||
HookEvent::AfterAgent { .. } => &self.after_agent,
|
||||
HookEvent::AfterToolUse { .. } => &self.after_tool_use,
|
||||
HookEvent::PreToolUse { .. } => &self.pre_tool_use,
|
||||
HookEvent::PostToolUse { .. } => &self.post_tool_use,
|
||||
HookEvent::SessionStart { .. } => &self.session_start,
|
||||
HookEvent::TurnStart { .. } => &self.turn_start,
|
||||
HookEvent::TurnEnd { .. } => &self.turn_end,
|
||||
HookEvent::Compaction { .. } => &self.compaction,
|
||||
HookEvent::UserPromptSubmit { .. } => &self.user_prompt_submit,
|
||||
HookEvent::Stop { .. } => &self.stop,
|
||||
HookEvent::PreCompact { .. } => &self.pre_compact,
|
||||
HookEvent::SessionEnd { .. } => &self.session_end,
|
||||
HookEvent::SubagentStart { .. } => &self.subagent_start,
|
||||
HookEvent::SubagentEnd { .. } => &self.subagent_end,
|
||||
HookEvent::SubagentStop { .. } => &self.subagent_stop,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,13 +180,40 @@ fn command_hook(name: &str, argv: Vec<String>) -> Hook {
|
||||
Ok(payload_json) => payload_json,
|
||||
Err(err) => return HookResult::FailedContinue(err.into()),
|
||||
};
|
||||
command.arg(payload_json);
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
match command.spawn() {
|
||||
Ok(_) => HookResult::Success,
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => return HookResult::FailedContinue(err.into()),
|
||||
};
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take()
|
||||
&& let Err(err) = stdin.write_all(payload_json.as_bytes()).await
|
||||
{
|
||||
return HookResult::FailedContinue(Box::new(err));
|
||||
}
|
||||
|
||||
match child.wait_with_output().await {
|
||||
Ok(output) if output.status.success() => HookResult::Success,
|
||||
Ok(output) => {
|
||||
let code = output.status.code();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let message = if stderr.is_empty() {
|
||||
match code {
|
||||
Some(code) => format!("hook exited with status code {code}"),
|
||||
None => "hook terminated without a status code".to_string(),
|
||||
}
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
if code == Some(2) && payload.hook_event.aborts_on_exit_code_two() {
|
||||
HookResult::FailedAbort(std::io::Error::other(message).into())
|
||||
} else {
|
||||
HookResult::FailedContinue(std::io::Error::other(message).into())
|
||||
}
|
||||
}
|
||||
Err(err) => HookResult::FailedContinue(err.into()),
|
||||
}
|
||||
})
|
||||
@@ -197,8 +242,9 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::types::HookEventAfterAgent;
|
||||
use crate::types::HookEventAfterToolUse;
|
||||
use crate::types::HookEventLifecycle;
|
||||
use crate::types::HookEventPostToolUse;
|
||||
use crate::types::HookEventPreToolUse;
|
||||
use crate::types::HookResult;
|
||||
use crate::types::HookToolInput;
|
||||
use crate::types::HookToolKind;
|
||||
@@ -209,6 +255,7 @@ mod tests {
|
||||
fn hook_payload(label: &str) -> HookPayload {
|
||||
HookPayload {
|
||||
session_id: ThreadId::new(),
|
||||
transcript_path: None,
|
||||
cwd: PathBuf::from(CWD),
|
||||
client: None,
|
||||
triggered_at: Utc
|
||||
@@ -278,14 +325,15 @@ mod tests {
|
||||
fn after_tool_use_payload(label: &str) -> HookPayload {
|
||||
HookPayload {
|
||||
session_id: ThreadId::new(),
|
||||
transcript_path: None,
|
||||
cwd: PathBuf::from(CWD),
|
||||
client: None,
|
||||
triggered_at: Utc
|
||||
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp"),
|
||||
hook_event: HookEvent::AfterToolUse {
|
||||
event: HookEventAfterToolUse {
|
||||
hook_event: HookEvent::PostToolUse {
|
||||
event: HookEventPostToolUse {
|
||||
turn_id: format!("turn-{label}"),
|
||||
call_id: format!("call-{label}"),
|
||||
tool_name: "apply_patch".to_string(),
|
||||
@@ -308,6 +356,7 @@ mod tests {
|
||||
fn lifecycle_payload(event: HookEvent) -> HookPayload {
|
||||
HookPayload {
|
||||
session_id: ThreadId::new(),
|
||||
transcript_path: Some("/tmp/rollout.jsonl".to_string()),
|
||||
cwd: PathBuf::from(CWD),
|
||||
client: Some("codex-tui".to_string()),
|
||||
triggered_at: Utc
|
||||
@@ -320,10 +369,9 @@ mod tests {
|
||||
|
||||
fn sample_lifecycle_event() -> HookEventLifecycle {
|
||||
HookEventLifecycle {
|
||||
session_ref: "session-ref".to_string(),
|
||||
previous_session_id: None,
|
||||
prompt: Some("hello".to_string()),
|
||||
response_message: Some("done".to_string()),
|
||||
last_assistant_message: Some("done".to_string()),
|
||||
tool_use_id: Some("toolu_123".to_string()),
|
||||
tool_input: Some(HookToolInput::Custom {
|
||||
input: "payload".to_string(),
|
||||
@@ -410,22 +458,26 @@ mod tests {
|
||||
fn hooks_new_wires_all_lifecycle_commands() {
|
||||
let hooks = Hooks::new(HooksConfig {
|
||||
session_start_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
turn_start_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
turn_end_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
compaction_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
user_prompt_submit_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
pre_tool_use_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
post_tool_use_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
stop_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
pre_compact_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
session_end_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
subagent_start_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
subagent_end_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
subagent_stop_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
..HooksConfig::default()
|
||||
});
|
||||
|
||||
assert_eq!(hooks.session_start.len(), 1);
|
||||
assert_eq!(hooks.turn_start.len(), 1);
|
||||
assert_eq!(hooks.turn_end.len(), 1);
|
||||
assert_eq!(hooks.compaction.len(), 1);
|
||||
assert_eq!(hooks.user_prompt_submit.len(), 1);
|
||||
assert_eq!(hooks.pre_tool_use.len(), 1);
|
||||
assert_eq!(hooks.post_tool_use.len(), 1);
|
||||
assert_eq!(hooks.stop.len(), 1);
|
||||
assert_eq!(hooks.pre_compact.len(), 1);
|
||||
assert_eq!(hooks.session_end.len(), 1);
|
||||
assert_eq!(hooks.subagent_start.len(), 1);
|
||||
assert_eq!(hooks.subagent_end.len(), 1);
|
||||
assert_eq!(hooks.subagent_stop.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -493,7 +545,7 @@ mod tests {
|
||||
async fn dispatch_executes_after_tool_use_hooks() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let hooks = Hooks {
|
||||
after_tool_use: vec![counting_success_hook(&calls, "counting")],
|
||||
post_tool_use: vec![counting_success_hook(&calls, "counting")],
|
||||
..Hooks::default()
|
||||
};
|
||||
|
||||
@@ -515,10 +567,9 @@ mod tests {
|
||||
let outcomes = hooks
|
||||
.dispatch(lifecycle_payload(HookEvent::SessionStart {
|
||||
event: HookEventLifecycle {
|
||||
session_ref: "session-ref".to_string(),
|
||||
previous_session_id: None,
|
||||
prompt: None,
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
@@ -536,22 +587,29 @@ mod tests {
|
||||
async fn dispatch_routes_each_lifecycle_event_to_matching_hooks() {
|
||||
let session_start_calls = Arc::new(AtomicUsize::new(0));
|
||||
let turn_start_calls = Arc::new(AtomicUsize::new(0));
|
||||
let turn_end_calls = Arc::new(AtomicUsize::new(0));
|
||||
let compaction_calls = Arc::new(AtomicUsize::new(0));
|
||||
let pre_tool_use_calls = Arc::new(AtomicUsize::new(0));
|
||||
let post_tool_use_calls = Arc::new(AtomicUsize::new(0));
|
||||
let stop_calls = Arc::new(AtomicUsize::new(0));
|
||||
let pre_compact_calls = Arc::new(AtomicUsize::new(0));
|
||||
let session_end_calls = Arc::new(AtomicUsize::new(0));
|
||||
let subagent_start_calls = Arc::new(AtomicUsize::new(0));
|
||||
let subagent_end_calls = Arc::new(AtomicUsize::new(0));
|
||||
let subagent_stop_calls = Arc::new(AtomicUsize::new(0));
|
||||
let hooks = Hooks {
|
||||
session_start: vec![counting_success_hook(&session_start_calls, "session_start")],
|
||||
turn_start: vec![counting_success_hook(&turn_start_calls, "turn_start")],
|
||||
turn_end: vec![counting_success_hook(&turn_end_calls, "turn_end")],
|
||||
compaction: vec![counting_success_hook(&compaction_calls, "compaction")],
|
||||
user_prompt_submit: vec![counting_success_hook(
|
||||
&turn_start_calls,
|
||||
"user_prompt_submit",
|
||||
)],
|
||||
pre_tool_use: vec![counting_success_hook(&pre_tool_use_calls, "pre_tool_use")],
|
||||
post_tool_use: vec![counting_success_hook(&post_tool_use_calls, "post_tool_use")],
|
||||
stop: vec![counting_success_hook(&stop_calls, "stop")],
|
||||
pre_compact: vec![counting_success_hook(&pre_compact_calls, "pre_compact")],
|
||||
session_end: vec![counting_success_hook(&session_end_calls, "session_end")],
|
||||
subagent_start: vec![counting_success_hook(
|
||||
&subagent_start_calls,
|
||||
"subagent_start",
|
||||
)],
|
||||
subagent_end: vec![counting_success_hook(&subagent_end_calls, "subagent_end")],
|
||||
subagent_stop: vec![counting_success_hook(&subagent_stop_calls, "subagent_stop")],
|
||||
..Hooks::default()
|
||||
};
|
||||
|
||||
@@ -564,25 +622,48 @@ mod tests {
|
||||
Arc::clone(&session_start_calls),
|
||||
),
|
||||
(
|
||||
"turn_start",
|
||||
lifecycle_payload(HookEvent::TurnStart {
|
||||
"user_prompt_submit",
|
||||
lifecycle_payload(HookEvent::UserPromptSubmit {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&turn_start_calls),
|
||||
),
|
||||
(
|
||||
"turn_end",
|
||||
lifecycle_payload(HookEvent::TurnEnd {
|
||||
event: sample_lifecycle_event(),
|
||||
"pre_tool_use",
|
||||
lifecycle_payload(HookEvent::PreToolUse {
|
||||
event: HookEventPreToolUse {
|
||||
turn_id: "turn-pre".to_string(),
|
||||
call_id: "call-pre".to_string(),
|
||||
tool_name: "apply_patch".to_string(),
|
||||
tool_kind: HookToolKind::Custom,
|
||||
tool_input: HookToolInput::Custom {
|
||||
input: "*** Begin Patch".to_string(),
|
||||
},
|
||||
mutating: Some(true),
|
||||
sandbox: Some("none".to_string()),
|
||||
sandbox_policy: Some("danger-full-access".to_string()),
|
||||
},
|
||||
}),
|
||||
Arc::clone(&turn_end_calls),
|
||||
Arc::clone(&pre_tool_use_calls),
|
||||
),
|
||||
(
|
||||
"compaction",
|
||||
lifecycle_payload(HookEvent::Compaction {
|
||||
"post_tool_use",
|
||||
after_tool_use_payload("post"),
|
||||
Arc::clone(&post_tool_use_calls),
|
||||
),
|
||||
(
|
||||
"stop",
|
||||
lifecycle_payload(HookEvent::Stop {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&compaction_calls),
|
||||
Arc::clone(&stop_calls),
|
||||
),
|
||||
(
|
||||
"pre_compact",
|
||||
lifecycle_payload(HookEvent::PreCompact {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&pre_compact_calls),
|
||||
),
|
||||
(
|
||||
"session_end",
|
||||
@@ -599,11 +680,11 @@ mod tests {
|
||||
Arc::clone(&subagent_start_calls),
|
||||
),
|
||||
(
|
||||
"subagent_end",
|
||||
lifecycle_payload(HookEvent::SubagentEnd {
|
||||
"subagent_stop",
|
||||
lifecycle_payload(HookEvent::SubagentStop {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&subagent_end_calls),
|
||||
Arc::clone(&subagent_stop_calls),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -640,10 +721,10 @@ mod tests {
|
||||
async fn dispatch_returns_after_tool_use_failure_outcome() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let hooks = Hooks {
|
||||
after_tool_use: vec![failing_continue_hook(
|
||||
post_tool_use: vec![failing_continue_hook(
|
||||
&calls,
|
||||
"failing",
|
||||
"after_tool_use hook failed",
|
||||
"post_tool_use hook failed",
|
||||
)],
|
||||
..Hooks::default()
|
||||
};
|
||||
@@ -657,38 +738,78 @@ mod tests {
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn hook_executes_program_with_payload_argument_unix() -> Result<()> {
|
||||
async fn pre_tool_use_exit_code_two_aborts_operation() {
|
||||
let hooks = Hooks::new(HooksConfig {
|
||||
pre_tool_use_argv: Some(vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"exit 2".to_string(),
|
||||
]),
|
||||
..HooksConfig::default()
|
||||
});
|
||||
|
||||
let outcomes = hooks
|
||||
.dispatch(lifecycle_payload(HookEvent::PreToolUse {
|
||||
event: HookEventPreToolUse {
|
||||
turn_id: "turn-pre".to_string(),
|
||||
call_id: "call-pre".to_string(),
|
||||
tool_name: "apply_patch".to_string(),
|
||||
tool_kind: HookToolKind::Custom,
|
||||
tool_input: HookToolInput::Custom {
|
||||
input: "*** Begin Patch".to_string(),
|
||||
},
|
||||
mutating: Some(true),
|
||||
sandbox: Some("none".to_string()),
|
||||
sandbox_policy: Some("danger-full-access".to_string()),
|
||||
},
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert!(matches!(outcomes[0].result, HookResult::FailedAbort(_)));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn post_tool_use_exit_code_two_continues_operation() {
|
||||
let hooks = Hooks::new(HooksConfig {
|
||||
post_tool_use_argv: Some(vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"exit 2".to_string(),
|
||||
]),
|
||||
..HooksConfig::default()
|
||||
});
|
||||
|
||||
let outcomes = hooks
|
||||
.dispatch(after_tool_use_payload("post-exit-two"))
|
||||
.await;
|
||||
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert!(matches!(outcomes[0].result, HookResult::FailedContinue(_)));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[tokio::test]
|
||||
async fn hook_executes_program_with_payload_on_stdin_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 {
|
||||
name: "write_payload".to_string(),
|
||||
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");
|
||||
HookResult::Success
|
||||
})
|
||||
}),
|
||||
};
|
||||
|
||||
let payload = hook_payload("4");
|
||||
let payload = lifecycle_payload(HookEvent::SessionStart {
|
||||
event: sample_lifecycle_event(),
|
||||
});
|
||||
let expected = to_string(&payload)?;
|
||||
|
||||
let hooks = Hooks {
|
||||
after_agent: vec![hook],
|
||||
..Hooks::default()
|
||||
};
|
||||
let hooks = Hooks::new(HooksConfig {
|
||||
session_start_argv: Some(vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"cat > \"$1\"".to_string(),
|
||||
"sh".to_string(),
|
||||
payload_path_arg,
|
||||
]),
|
||||
..HooksConfig::default()
|
||||
});
|
||||
let outcomes = hooks.dispatch(payload).await;
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert!(matches!(outcomes[0].result, HookResult::Success));
|
||||
@@ -711,45 +832,34 @@ mod tests {
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn hook_executes_program_with_payload_argument_windows() -> Result<()> {
|
||||
async fn hook_executes_program_with_payload_on_stdin_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])")?;
|
||||
fs::write(
|
||||
&script_path,
|
||||
"$inputData = [Console]::In.ReadToEnd(); [IO.File]::WriteAllText($args[0], $inputData)",
|
||||
)?;
|
||||
let script_path_arg = script_path.to_string_lossy().into_owned();
|
||||
let hook = Hook {
|
||||
name: "write_payload".to_string(),
|
||||
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 mut command = command_from_argv(&[
|
||||
"powershell.exe".to_string(),
|
||||
"-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");
|
||||
HookResult::Success
|
||||
})
|
||||
}),
|
||||
};
|
||||
|
||||
let payload = hook_payload("4");
|
||||
let payload = lifecycle_payload(HookEvent::SessionStart {
|
||||
event: sample_lifecycle_event(),
|
||||
});
|
||||
let expected = to_string(&payload)?;
|
||||
|
||||
let hooks = Hooks {
|
||||
after_agent: vec![hook],
|
||||
..Hooks::default()
|
||||
};
|
||||
let hooks = Hooks::new(HooksConfig {
|
||||
session_start_argv: Some(vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoLogo".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-ExecutionPolicy".to_string(),
|
||||
"Bypass".to_string(),
|
||||
"-File".to_string(),
|
||||
script_path_arg,
|
||||
payload_path_arg,
|
||||
]),
|
||||
..HooksConfig::default()
|
||||
});
|
||||
let outcomes = hooks.dispatch(payload).await;
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert!(matches!(outcomes[0].result, HookResult::Success));
|
||||
|
||||
+269
-182
@@ -10,6 +10,9 @@ use codex_protocol::models::SandboxPermissions;
|
||||
use futures::future::BoxFuture;
|
||||
use serde::Serialize;
|
||||
use serde::Serializer;
|
||||
use serde::ser::SerializeMap;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
|
||||
pub type HookFn = Arc<dyn for<'a> Fn(&'a HookPayload) -> BoxFuture<'a, HookResult> + Send + Sync>;
|
||||
|
||||
@@ -61,14 +64,12 @@ impl Hook {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HookPayload {
|
||||
pub session_id: ThreadId,
|
||||
pub transcript_path: Option<String>,
|
||||
pub cwd: PathBuf,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client: Option<String>,
|
||||
#[serde(serialize_with = "serialize_triggered_at")]
|
||||
pub triggered_at: DateTime<Utc>,
|
||||
pub hook_event: HookEvent,
|
||||
}
|
||||
@@ -123,7 +124,23 @@ pub enum HookToolInput {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct HookEventAfterToolUse {
|
||||
pub struct HookEventPreToolUse {
|
||||
pub turn_id: String,
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
pub tool_kind: HookToolKind,
|
||||
pub tool_input: HookToolInput,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mutating: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox_policy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct HookEventPostToolUse {
|
||||
pub turn_id: String,
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
@@ -141,13 +158,12 @@ pub struct HookEventAfterToolUse {
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct HookEventLifecycle {
|
||||
pub session_ref: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub previous_session_id: Option<ThreadId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_message: Option<String>,
|
||||
pub last_assistant_message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_use_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -158,68 +174,99 @@ pub struct HookEventLifecycle {
|
||||
pub metadata: Option<HashMap<String, 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")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HookEvent {
|
||||
AfterAgent {
|
||||
#[serde(flatten)]
|
||||
event: HookEventAfterAgent,
|
||||
},
|
||||
AfterToolUse {
|
||||
#[serde(flatten)]
|
||||
event: HookEventAfterToolUse,
|
||||
},
|
||||
SessionStart {
|
||||
#[serde(flatten)]
|
||||
event: HookEventLifecycle,
|
||||
},
|
||||
TurnStart {
|
||||
#[serde(flatten)]
|
||||
event: HookEventLifecycle,
|
||||
},
|
||||
TurnEnd {
|
||||
#[serde(flatten)]
|
||||
event: HookEventLifecycle,
|
||||
},
|
||||
Compaction {
|
||||
#[serde(flatten)]
|
||||
event: HookEventLifecycle,
|
||||
},
|
||||
SessionEnd {
|
||||
#[serde(flatten)]
|
||||
event: HookEventLifecycle,
|
||||
},
|
||||
SubagentStart {
|
||||
#[serde(flatten)]
|
||||
event: HookEventLifecycle,
|
||||
},
|
||||
SubagentEnd {
|
||||
#[serde(flatten)]
|
||||
event: HookEventLifecycle,
|
||||
},
|
||||
AfterAgent { event: HookEventAfterAgent },
|
||||
PreToolUse { event: HookEventPreToolUse },
|
||||
PostToolUse { event: HookEventPostToolUse },
|
||||
SessionStart { event: HookEventLifecycle },
|
||||
UserPromptSubmit { event: HookEventLifecycle },
|
||||
Stop { event: HookEventLifecycle },
|
||||
PreCompact { event: HookEventLifecycle },
|
||||
SessionEnd { event: HookEventLifecycle },
|
||||
SubagentStart { event: HookEventLifecycle },
|
||||
SubagentStop { event: HookEventLifecycle },
|
||||
}
|
||||
|
||||
impl HookEvent {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AfterAgent { .. } => "after_agent",
|
||||
Self::AfterToolUse { .. } => "after_tool_use",
|
||||
Self::SessionStart { .. } => "session_start",
|
||||
Self::TurnStart { .. } => "turn_start",
|
||||
Self::TurnEnd { .. } => "turn_end",
|
||||
Self::Compaction { .. } => "compaction",
|
||||
Self::SessionEnd { .. } => "session_end",
|
||||
Self::SubagentStart { .. } => "subagent_start",
|
||||
Self::SubagentEnd { .. } => "subagent_end",
|
||||
Self::AfterAgent { .. } => "AfterAgent",
|
||||
Self::PreToolUse { .. } => "PreToolUse",
|
||||
Self::PostToolUse { .. } => "PostToolUse",
|
||||
Self::SessionStart { .. } => "SessionStart",
|
||||
Self::UserPromptSubmit { .. } => "UserPromptSubmit",
|
||||
Self::Stop { .. } => "Stop",
|
||||
Self::PreCompact { .. } => "PreCompact",
|
||||
Self::SessionEnd { .. } => "SessionEnd",
|
||||
Self::SubagentStart { .. } => "SubagentStart",
|
||||
Self::SubagentStop { .. } => "SubagentStop",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aborts_on_exit_code_two(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::PreToolUse { .. }
|
||||
| Self::UserPromptSubmit { .. }
|
||||
| Self::Stop { .. }
|
||||
| Self::SubagentStop { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn fields(&self) -> Result<Map<String, Value>, serde_json::Error> {
|
||||
match self {
|
||||
Self::AfterAgent { event } => serialize_object(event),
|
||||
Self::PreToolUse { event } => serialize_object(event),
|
||||
Self::PostToolUse { event } => serialize_object(event),
|
||||
Self::SessionStart { event } => serialize_object(event),
|
||||
Self::UserPromptSubmit { event } => serialize_object(event),
|
||||
Self::Stop { event } => serialize_object(event),
|
||||
Self::PreCompact { event } => serialize_object(event),
|
||||
Self::SessionEnd { event } => serialize_object(event),
|
||||
Self::SubagentStart { event } => serialize_object(event),
|
||||
Self::SubagentStop { event } => serialize_object(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for HookPayload {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("session_id", &self.session_id)?;
|
||||
if let Some(transcript_path) = &self.transcript_path {
|
||||
map.serialize_entry("transcript_path", transcript_path)?;
|
||||
}
|
||||
map.serialize_entry("cwd", &self.cwd)?;
|
||||
if let Some(client) = &self.client {
|
||||
map.serialize_entry("client", client)?;
|
||||
}
|
||||
map.serialize_entry(
|
||||
"triggered_at",
|
||||
&self.triggered_at.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
)?;
|
||||
map.serialize_entry("hook_event_name", self.hook_event.name())?;
|
||||
|
||||
let fields = self
|
||||
.hook_event
|
||||
.fields()
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
for (key, value) in fields {
|
||||
map.serialize_entry(&key, &value)?;
|
||||
}
|
||||
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_object<T: Serialize>(value: &T) -> Result<Map<String, Value>, serde_json::Error> {
|
||||
match serde_json::to_value(value)? {
|
||||
Value::Object(object) => Ok(object),
|
||||
_ => Ok(Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -236,8 +283,9 @@ mod tests {
|
||||
|
||||
use super::HookEvent;
|
||||
use super::HookEventAfterAgent;
|
||||
use super::HookEventAfterToolUse;
|
||||
use super::HookEventLifecycle;
|
||||
use super::HookEventPostToolUse;
|
||||
use super::HookEventPreToolUse;
|
||||
use super::HookPayload;
|
||||
use super::HookToolInput;
|
||||
use super::HookToolInputLocalShell;
|
||||
@@ -251,10 +299,9 @@ mod tests {
|
||||
metadata.insert("phase".to_string(), "done".to_string());
|
||||
|
||||
HookEventLifecycle {
|
||||
session_ref: "session-ref-1".to_string(),
|
||||
previous_session_id: Some(previous_session_id),
|
||||
prompt: Some("hello world".to_string()),
|
||||
response_message: Some("done".to_string()),
|
||||
last_assistant_message: Some("done".to_string()),
|
||||
tool_use_id: Some("toolu_123".to_string()),
|
||||
tool_input: Some(HookToolInput::Function {
|
||||
arguments: "{\"code\":\"cargo test\"}".to_string(),
|
||||
@@ -270,6 +317,7 @@ mod tests {
|
||||
let thread_id = ThreadId::new();
|
||||
let payload = HookPayload {
|
||||
session_id,
|
||||
transcript_path: Some("/tmp/rollout.jsonl".to_string()),
|
||||
cwd: PathBuf::from("tmp"),
|
||||
client: None,
|
||||
triggered_at: Utc
|
||||
@@ -289,33 +337,33 @@ mod tests {
|
||||
let actual = serde_json::to_value(payload).expect("serialize hook payload");
|
||||
let expected = json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"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",
|
||||
},
|
||||
"hook_event_name": "AfterAgent",
|
||||
"thread_id": thread_id.to_string(),
|
||||
"turn_id": "turn-1",
|
||||
"input_messages": ["hello"],
|
||||
"last_assistant_message": "hi",
|
||||
});
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_tool_use_payload_serializes_stable_wire_shape() {
|
||||
fn post_tool_use_payload_serializes_stable_wire_shape() {
|
||||
let session_id = ThreadId::new();
|
||||
let payload = HookPayload {
|
||||
session_id,
|
||||
transcript_path: Some("/tmp/rollout.jsonl".to_string()),
|
||||
cwd: PathBuf::from("tmp"),
|
||||
client: None,
|
||||
triggered_at: Utc
|
||||
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp"),
|
||||
hook_event: HookEvent::AfterToolUse {
|
||||
event: HookEventAfterToolUse {
|
||||
hook_event: HookEvent::PostToolUse {
|
||||
event: HookEventPostToolUse {
|
||||
turn_id: "turn-2".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: "local_shell".to_string(),
|
||||
@@ -344,59 +392,68 @@ mod tests {
|
||||
let actual = serde_json::to_value(payload).expect("serialize hook payload");
|
||||
let expected = json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"cwd": "tmp",
|
||||
"triggered_at": "2025-01-01T00:00:00Z",
|
||||
"hook_event": {
|
||||
"event_type": "after_tool_use",
|
||||
"turn_id": "turn-2",
|
||||
"call_id": "call-1",
|
||||
"tool_name": "local_shell",
|
||||
"tool_kind": "local_shell",
|
||||
"tool_input": {
|
||||
"input_type": "local_shell",
|
||||
"params": {
|
||||
"command": ["cargo", "fmt"],
|
||||
"workdir": "codex-rs",
|
||||
"timeout_ms": 60000,
|
||||
"sandbox_permissions": "use_default",
|
||||
"justification": null,
|
||||
"prefix_rule": null,
|
||||
},
|
||||
"hook_event_name": "PostToolUse",
|
||||
"turn_id": "turn-2",
|
||||
"call_id": "call-1",
|
||||
"tool_name": "local_shell",
|
||||
"tool_kind": "local_shell",
|
||||
"tool_input": {
|
||||
"input_type": "local_shell",
|
||||
"params": {
|
||||
"command": ["cargo", "fmt"],
|
||||
"workdir": "codex-rs",
|
||||
"timeout_ms": 60000,
|
||||
"sandbox_permissions": "use_default",
|
||||
"justification": null,
|
||||
"prefix_rule": null,
|
||||
},
|
||||
"executed": true,
|
||||
"success": true,
|
||||
"duration_ms": 42,
|
||||
"mutating": true,
|
||||
"sandbox": "none",
|
||||
"sandbox_policy": "danger-full-access",
|
||||
"output_preview": "ok",
|
||||
},
|
||||
"executed": true,
|
||||
"success": true,
|
||||
"duration_ms": 42,
|
||||
"mutating": true,
|
||||
"sandbox": "none",
|
||||
"sandbox_policy": "danger-full-access",
|
||||
"output_preview": "ok",
|
||||
});
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_payload_serializes_stable_wire_shape() {
|
||||
fn pre_tool_use_payload_serializes_stable_wire_shape() {
|
||||
let session_id = ThreadId::new();
|
||||
let payload = HookPayload {
|
||||
session_id,
|
||||
transcript_path: Some("/tmp/rollout.jsonl".to_string()),
|
||||
cwd: PathBuf::from("tmp"),
|
||||
client: Some("codex-tui".to_string()),
|
||||
triggered_at: Utc
|
||||
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp"),
|
||||
hook_event: HookEvent::TurnStart {
|
||||
event: HookEventLifecycle {
|
||||
session_ref: "session-ref-1".to_string(),
|
||||
previous_session_id: None,
|
||||
prompt: Some("hello world".to_string()),
|
||||
response_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
hook_event: HookEvent::PreToolUse {
|
||||
event: HookEventPreToolUse {
|
||||
turn_id: "turn-2".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: "local_shell".to_string(),
|
||||
tool_kind: HookToolKind::LocalShell,
|
||||
tool_input: HookToolInput::LocalShell {
|
||||
params: HookToolInputLocalShell {
|
||||
command: vec!["cargo".to_string(), "fmt".to_string()],
|
||||
workdir: Some("codex-rs".to_string()),
|
||||
timeout_ms: Some(60_000),
|
||||
sandbox_permissions: Some(SandboxPermissions::UseDefault),
|
||||
justification: None,
|
||||
prefix_rule: None,
|
||||
},
|
||||
},
|
||||
mutating: Some(true),
|
||||
sandbox: Some("none".to_string()),
|
||||
sandbox_policy: Some("danger-full-access".to_string()),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -404,14 +461,29 @@ mod tests {
|
||||
let actual = serde_json::to_value(payload).expect("serialize hook payload");
|
||||
let expected = json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"cwd": "tmp",
|
||||
"client": "codex-tui",
|
||||
"triggered_at": "2025-01-01T00:00:00Z",
|
||||
"hook_event": {
|
||||
"event_type": "turn_start",
|
||||
"session_ref": "session-ref-1",
|
||||
"prompt": "hello world",
|
||||
"hook_event_name": "PreToolUse",
|
||||
"turn_id": "turn-2",
|
||||
"call_id": "call-1",
|
||||
"tool_name": "local_shell",
|
||||
"tool_kind": "local_shell",
|
||||
"tool_input": {
|
||||
"input_type": "local_shell",
|
||||
"params": {
|
||||
"command": ["cargo", "fmt"],
|
||||
"workdir": "codex-rs",
|
||||
"timeout_ms": 60000,
|
||||
"sandbox_permissions": "use_default",
|
||||
"justification": null,
|
||||
"prefix_rule": null,
|
||||
},
|
||||
},
|
||||
"mutating": true,
|
||||
"sandbox": "none",
|
||||
"sandbox_policy": "danger-full-access",
|
||||
});
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
@@ -424,6 +496,7 @@ mod tests {
|
||||
let subagent_id = ThreadId::new();
|
||||
let base_payload = |hook_event: HookEvent| HookPayload {
|
||||
session_id,
|
||||
transcript_path: Some("/tmp/rollout.jsonl".to_string()),
|
||||
cwd: PathBuf::from("tmp"),
|
||||
client: Some("codex-tui".to_string()),
|
||||
triggered_at: Utc
|
||||
@@ -435,36 +508,34 @@ mod tests {
|
||||
|
||||
let cases = vec![
|
||||
(
|
||||
"session_start",
|
||||
"SessionStart",
|
||||
base_payload(HookEvent::SessionStart {
|
||||
event: sample_lifecycle_event(previous_session_id, subagent_id),
|
||||
}),
|
||||
json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"cwd": "tmp",
|
||||
"client": "codex-tui",
|
||||
"triggered_at": "2025-01-01T00:00:00Z",
|
||||
"hook_event": {
|
||||
"event_type": "session_start",
|
||||
"session_ref": "session-ref-1",
|
||||
"previous_session_id": previous_session_id.to_string(),
|
||||
"prompt": "hello world",
|
||||
"response_message": "done",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
"subagent_id": subagent_id.to_string(),
|
||||
"metadata": {
|
||||
"phase": "done",
|
||||
},
|
||||
"hook_event_name": "SessionStart",
|
||||
"previous_session_id": previous_session_id.to_string(),
|
||||
"prompt": "hello world",
|
||||
"last_assistant_message": "done",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
"subagent_id": subagent_id.to_string(),
|
||||
"metadata": {
|
||||
"phase": "done",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
"turn_end",
|
||||
base_payload(HookEvent::TurnEnd {
|
||||
"UserPromptSubmit",
|
||||
base_payload(HookEvent::UserPromptSubmit {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
subagent_id: None,
|
||||
@@ -474,25 +545,49 @@ mod tests {
|
||||
}),
|
||||
json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"cwd": "tmp",
|
||||
"client": "codex-tui",
|
||||
"triggered_at": "2025-01-01T00:00:00Z",
|
||||
"hook_event": {
|
||||
"event_type": "turn_end",
|
||||
"session_ref": "session-ref-1",
|
||||
"prompt": "hello world",
|
||||
"response_message": "done",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
"hook_event_name": "UserPromptSubmit",
|
||||
"prompt": "hello world",
|
||||
"last_assistant_message": "done",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
"compaction",
|
||||
base_payload(HookEvent::Compaction {
|
||||
"Stop",
|
||||
base_payload(HookEvent::Stop {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
..sample_lifecycle_event(previous_session_id, subagent_id)
|
||||
},
|
||||
}),
|
||||
json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"cwd": "tmp",
|
||||
"client": "codex-tui",
|
||||
"triggered_at": "2025-01-01T00:00:00Z",
|
||||
"hook_event_name": "Stop",
|
||||
"prompt": "hello world",
|
||||
"last_assistant_message": "done",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
"PreCompact",
|
||||
base_payload(HookEvent::PreCompact {
|
||||
event: HookEventLifecycle {
|
||||
prompt: None,
|
||||
tool_input: None,
|
||||
@@ -502,27 +597,25 @@ mod tests {
|
||||
}),
|
||||
json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"cwd": "tmp",
|
||||
"client": "codex-tui",
|
||||
"triggered_at": "2025-01-01T00:00:00Z",
|
||||
"hook_event": {
|
||||
"event_type": "compaction",
|
||||
"session_ref": "session-ref-1",
|
||||
"previous_session_id": previous_session_id.to_string(),
|
||||
"response_message": "done",
|
||||
"tool_use_id": "toolu_123",
|
||||
"metadata": {
|
||||
"phase": "done",
|
||||
},
|
||||
"hook_event_name": "PreCompact",
|
||||
"previous_session_id": previous_session_id.to_string(),
|
||||
"last_assistant_message": "done",
|
||||
"tool_use_id": "toolu_123",
|
||||
"metadata": {
|
||||
"phase": "done",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
"session_end",
|
||||
"SessionEnd",
|
||||
base_payload(HookEvent::SessionEnd {
|
||||
event: HookEventLifecycle {
|
||||
prompt: None,
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
@@ -532,22 +625,20 @@ mod tests {
|
||||
}),
|
||||
json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"cwd": "tmp",
|
||||
"client": "codex-tui",
|
||||
"triggered_at": "2025-01-01T00:00:00Z",
|
||||
"hook_event": {
|
||||
"event_type": "session_end",
|
||||
"session_ref": "session-ref-1",
|
||||
"previous_session_id": previous_session_id.to_string(),
|
||||
},
|
||||
"hook_event_name": "SessionEnd",
|
||||
"previous_session_id": previous_session_id.to_string(),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"subagent_start",
|
||||
"SubagentStart",
|
||||
base_payload(HookEvent::SubagentStart {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
..sample_lifecycle_event(previous_session_id, subagent_id)
|
||||
@@ -555,47 +646,43 @@ mod tests {
|
||||
}),
|
||||
json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"cwd": "tmp",
|
||||
"client": "codex-tui",
|
||||
"triggered_at": "2025-01-01T00:00:00Z",
|
||||
"hook_event": {
|
||||
"event_type": "subagent_start",
|
||||
"session_ref": "session-ref-1",
|
||||
"prompt": "hello world",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
"hook_event_name": "SubagentStart",
|
||||
"prompt": "hello world",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
"subagent_end",
|
||||
base_payload(HookEvent::SubagentEnd {
|
||||
"SubagentStop",
|
||||
base_payload(HookEvent::SubagentStop {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
response_message: None,
|
||||
last_assistant_message: None,
|
||||
metadata: None,
|
||||
..sample_lifecycle_event(previous_session_id, subagent_id)
|
||||
},
|
||||
}),
|
||||
json!({
|
||||
"session_id": session_id.to_string(),
|
||||
"transcript_path": "/tmp/rollout.jsonl",
|
||||
"cwd": "tmp",
|
||||
"client": "codex-tui",
|
||||
"triggered_at": "2025-01-01T00:00:00Z",
|
||||
"hook_event": {
|
||||
"event_type": "subagent_end",
|
||||
"session_ref": "session-ref-1",
|
||||
"prompt": "hello world",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
"subagent_id": subagent_id.to_string(),
|
||||
"hook_event_name": "SubagentStop",
|
||||
"prompt": "hello world",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
"subagent_id": subagent_id.to_string(),
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -121,6 +121,7 @@ mod tests {
|
||||
fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> {
|
||||
let payload = HookPayload {
|
||||
session_id: ThreadId::new(),
|
||||
transcript_path: None,
|
||||
cwd: std::path::Path::new("/Users/example/project").to_path_buf(),
|
||||
client: Some("codex-tui".to_string()),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
|
||||
+16
-5
@@ -33,15 +33,26 @@ Codex can run command hooks for lifecycle events via `[hooks]` in `~/.codex/conf
|
||||
```toml
|
||||
[hooks]
|
||||
session_start = ["my-hook", "session_start"]
|
||||
turn_start = ["my-hook", "turn_start"]
|
||||
turn_end = ["my-hook", "turn_end"]
|
||||
compaction = ["my-hook", "compaction"]
|
||||
user_prompt_submit = ["my-hook", "user_prompt_submit"]
|
||||
pre_tool_use = ["my-hook", "pre_tool_use"]
|
||||
post_tool_use = ["my-hook", "post_tool_use"]
|
||||
stop = ["my-hook", "stop"]
|
||||
pre_compact = ["my-hook", "pre_compact"]
|
||||
session_end = ["my-hook", "session_end"]
|
||||
subagent_start = ["my-hook", "subagent_start"]
|
||||
subagent_end = ["my-hook", "subagent_end"]
|
||||
subagent_stop = ["my-hook", "subagent_stop"]
|
||||
```
|
||||
|
||||
Each command receives one extra final argv argument with JSON describing the hook payload.
|
||||
Each command receives JSON on stdin describing the hook payload.
|
||||
|
||||
To align with the Claude-compatible event naming, the hook names are:
|
||||
|
||||
- `turn_start` becomes `user_prompt_submit`
|
||||
- `turn_end` becomes `stop`
|
||||
- `compaction` becomes `pre_compact`
|
||||
- `subagent_end` becomes `subagent_stop`
|
||||
|
||||
These older names never landed on `main`, so they are not accepted as aliases.
|
||||
|
||||
## JSON Schema
|
||||
|
||||
|
||||
Reference in New Issue
Block a user