mirror of
https://github.com/openai/codex.git
synced 2026-03-20 04:46:31 +03:00
Compare commits
3 Commits
starr/exec
...
steipete-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea65336542 | ||
|
|
86d6d7742a | ||
|
|
60f646db30 |
@@ -634,6 +634,67 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"HooksToml": {
|
||||
"additionalProperties": false,
|
||||
"description": "Lifecycle hook command config deserialized from `[hooks]` in config.toml.",
|
||||
"properties": {
|
||||
"post_tool_use": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"pre_compact": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"pre_tool_use": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"session_end": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"session_start": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"stop": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"subagent_start": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"subagent_stop": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"user_prompt_submit": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MemoriesToml": {
|
||||
"additionalProperties": false,
|
||||
"description": "Memories settings loaded from config.toml.",
|
||||
@@ -1910,6 +1971,25 @@
|
||||
"default": null,
|
||||
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
|
||||
},
|
||||
"hooks": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/HooksToml"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"post_tool_use": null,
|
||||
"pre_compact": null,
|
||||
"pre_tool_use": null,
|
||||
"session_end": null,
|
||||
"session_start": null,
|
||||
"stop": null,
|
||||
"subagent_start": null,
|
||||
"subagent_stop": null,
|
||||
"user_prompt_submit": null
|
||||
},
|
||||
"description": "Optional external hook commands keyed by lifecycle event."
|
||||
},
|
||||
"instructions": {
|
||||
"description": "System instructions.",
|
||||
"type": "string"
|
||||
|
||||
@@ -57,6 +57,7 @@ use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use codex_hooks::HookEvent;
|
||||
use codex_hooks::HookEventAfterAgent;
|
||||
use codex_hooks::HookEventLifecycle;
|
||||
use codex_hooks::HookPayload;
|
||||
use codex_hooks::HookResult;
|
||||
use codex_hooks::Hooks;
|
||||
@@ -1506,9 +1507,7 @@ impl Session {
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
),
|
||||
hooks: Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
}),
|
||||
hooks: Hooks::new(hooks_config_from_config(config.as_ref())),
|
||||
rollout: Mutex::new(rollout_recorder),
|
||||
user_shell: Arc::new(default_shell),
|
||||
shell_snapshot_tx,
|
||||
@@ -1588,6 +1587,29 @@ impl Session {
|
||||
for event in events {
|
||||
sess.send_event_raw(event).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: session_configuration.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::SessionStart {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
prompt: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// Start the watcher after SessionConfigured so it cannot emit earlier events.
|
||||
sess.start_file_watcher_listener();
|
||||
@@ -4613,9 +4635,18 @@ mod handlers {
|
||||
i64::try_from(turn_count).unwrap_or(0),
|
||||
&[],
|
||||
);
|
||||
let cwd = {
|
||||
let state = sess.state.lock().await;
|
||||
state.session_configuration.cwd.clone()
|
||||
};
|
||||
|
||||
// Gracefully flush and shutdown rollout recorder on session end so tests
|
||||
// that inspect the rollout file do not race with the background writer.
|
||||
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()
|
||||
@@ -4633,6 +4664,28 @@ mod handlers {
|
||||
};
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
super::dispatch_nonfatal_lifecycle_hook(
|
||||
sess.as_ref(),
|
||||
codex_hooks::HookPayload {
|
||||
session_id: sess.conversation_id,
|
||||
transcript_path,
|
||||
cwd,
|
||||
client,
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: codex_hooks::HookEvent::SessionEnd {
|
||||
event: codex_hooks::HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
prompt: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
@@ -4866,6 +4919,55 @@ fn errors_to_info(errors: &[SkillError]) -> Vec<SkillErrorInfo> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hooks_config_from_config(config: &Config) -> HooksConfig {
|
||||
HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
session_start_argv: config.hooks.session_start.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_stop_argv: config.hooks.subagent_stop.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
pub(crate) async fn dispatch_nonfatal_lifecycle_hook(session: &Session, hook_payload: HookPayload) {
|
||||
let hook_event = hook_payload.hook_event.name();
|
||||
let hook_outcomes = session.hooks().dispatch(hook_payload).await;
|
||||
for hook_outcome in hook_outcomes {
|
||||
let hook_name = hook_outcome.hook_name;
|
||||
match hook_outcome.result {
|
||||
HookResult::Success => {}
|
||||
HookResult::FailedContinue(error) => {
|
||||
warn!(
|
||||
hook_event = %hook_event,
|
||||
hook_name = %hook_name,
|
||||
error = %error,
|
||||
"lifecycle hook failed; continuing"
|
||||
);
|
||||
}
|
||||
HookResult::FailedAbort(error) => {
|
||||
warn!(
|
||||
hook_event = %hook_event,
|
||||
hook_name = %hook_name,
|
||||
error = %error,
|
||||
"lifecycle hook failed with abort; continuing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a user message as input and runs a loop where, at each sampling request, the model
|
||||
/// replies with either:
|
||||
///
|
||||
@@ -4900,6 +5002,37 @@ pub(crate) async fn run_turn(
|
||||
collaboration_mode_kind: turn_context.collaboration_mode.mode,
|
||||
});
|
||||
sess.send_event(&turn_context, event).await;
|
||||
let prompt = input
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
UserInput::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
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::UserPromptSubmit {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
prompt: (!prompt.is_empty()).then_some(prompt),
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
// TODO(ccunningham): Pre-turn compaction runs before context updates and the
|
||||
// new user message are recorded. Estimate pending incoming items (context
|
||||
// diffs/full reinjection + user input) and trigger compaction preemptively
|
||||
@@ -5151,11 +5284,38 @@ 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 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::Stop {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
prompt: turn_end_prompt,
|
||||
last_assistant_message,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.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(),
|
||||
@@ -5335,6 +5495,29 @@ async fn run_auto_compact(
|
||||
)
|
||||
.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::PreCompact {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
prompt: Some(turn_context.compact_prompt().to_string()),
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8426,9 +8609,7 @@ mod tests {
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
),
|
||||
hooks: Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
}),
|
||||
hooks: Hooks::new(hooks_config_from_config(config.as_ref())),
|
||||
rollout: Mutex::new(None),
|
||||
user_shell: Arc::new(default_user_shell()),
|
||||
shell_snapshot_tx: watch::channel(None).0,
|
||||
@@ -8675,6 +8856,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compute_hook_transcript_path_is_none_without_rollout() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
|
||||
let transcript_path = compute_hook_transcript_path(&session).await;
|
||||
|
||||
assert_eq!(transcript_path, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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;
|
||||
Arc::clone(&state.session_configuration.original_config_do_not_use)
|
||||
};
|
||||
let recorder = RolloutRecorder::new(
|
||||
config.as_ref(),
|
||||
RolloutRecorderParams::new(
|
||||
session.conversation_id,
|
||||
None,
|
||||
SessionSource::Exec,
|
||||
BaseInstructions::default(),
|
||||
Vec::new(),
|
||||
EventPersistenceMode::Limited,
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create rollout recorder");
|
||||
let rollout_path = recorder.rollout_path().display().to_string();
|
||||
{
|
||||
let mut rollout = session.services.rollout.lock().await;
|
||||
*rollout = Some(recorder);
|
||||
}
|
||||
|
||||
let transcript_path = compute_hook_transcript_path(&session).await;
|
||||
|
||||
assert_eq!(transcript_path, Some(rollout_path));
|
||||
}
|
||||
|
||||
pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
dynamic_tools: Vec<DynamicToolSpec>,
|
||||
) -> (
|
||||
@@ -8775,9 +8998,7 @@ mod tests {
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
),
|
||||
hooks: Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
}),
|
||||
hooks: Hooks::new(hooks_config_from_config(config.as_ref())),
|
||||
rollout: Mutex::new(None),
|
||||
user_shell: Arc::new(default_user_shell()),
|
||||
shell_snapshot_tx: watch::channel(None).0,
|
||||
|
||||
@@ -273,6 +273,9 @@ pub struct Config {
|
||||
/// If unset the feature is disabled.
|
||||
pub notify: Option<Vec<String>>,
|
||||
|
||||
/// Optional external hook commands keyed by lifecycle event.
|
||||
pub hooks: HooksToml,
|
||||
|
||||
/// TUI notifications preference. When set, the TUI will send terminal notifications on
|
||||
/// approvals and turn completions when not focused.
|
||||
pub tui_notifications: Notifications,
|
||||
@@ -1008,6 +1011,22 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R
|
||||
.map_err(|err| std::io::Error::other(format!("failed to persist config.toml: {err}")))
|
||||
}
|
||||
|
||||
/// 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 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_stop: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Base config deserialized from ~/.codex/config.toml.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
@@ -1056,6 +1075,10 @@ pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub notify: Option<Vec<String>>,
|
||||
|
||||
/// Optional external hook commands keyed by lifecycle event.
|
||||
#[serde(default)]
|
||||
pub hooks: HooksToml,
|
||||
|
||||
/// System instructions.
|
||||
pub instructions: Option<String>,
|
||||
|
||||
@@ -2157,6 +2180,7 @@ impl Config {
|
||||
enforce_residency: enforce_residency.value,
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode,
|
||||
notify: cfg.notify,
|
||||
hooks: cfg.hooks,
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
personality,
|
||||
@@ -5198,6 +5222,7 @@ model_verbosity = "high"
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
hooks: HooksToml::default(),
|
||||
cwd: fixture.cwd(),
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: Constrained::allow_any(HashMap::new()),
|
||||
@@ -5328,6 +5353,7 @@ model_verbosity = "high"
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
hooks: HooksToml::default(),
|
||||
cwd: fixture.cwd(),
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: Constrained::allow_any(HashMap::new()),
|
||||
@@ -5456,6 +5482,7 @@ model_verbosity = "high"
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
hooks: HooksToml::default(),
|
||||
cwd: fixture.cwd(),
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: Constrained::allow_any(HashMap::new()),
|
||||
@@ -5570,6 +5597,7 @@ model_verbosity = "high"
|
||||
did_user_set_custom_approval_policy_or_sandbox_mode: true,
|
||||
user_instructions: None,
|
||||
notify: None,
|
||||
hooks: HooksToml::default(),
|
||||
cwd: fixture.cwd(),
|
||||
cli_auth_credentials_store_mode: Default::default(),
|
||||
mcp_servers: Constrained::allow_any(HashMap::new()),
|
||||
@@ -6660,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,6 +9,8 @@ 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_transcript_path;
|
||||
use crate::codex::dispatch_nonfatal_lifecycle_hook;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
use crate::features::Feature;
|
||||
@@ -20,6 +22,10 @@ use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use async_trait::async_trait;
|
||||
use codex_hooks::HookEvent;
|
||||
use codex_hooks::HookEventLifecycle;
|
||||
use codex_hooks::HookPayload;
|
||||
use codex_hooks::HookToolInput;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
@@ -136,6 +142,7 @@ mod spawn {
|
||||
.filter(|role| !role.is_empty());
|
||||
let input_items = parse_collab_input(args.message, args.items)?;
|
||||
let prompt = input_preview(&input_items);
|
||||
let prompt_for_hook = (!prompt.is_empty()).then_some(prompt.clone());
|
||||
let session_source = turn.session_source.clone();
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
let max_depth = turn.config.agent_max_depth;
|
||||
@@ -155,6 +162,31 @@ mod spawn {
|
||||
.into(),
|
||||
)
|
||||
.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 {
|
||||
previous_session_id: None,
|
||||
prompt: prompt_for_hook.clone(),
|
||||
last_assistant_message: None,
|
||||
tool_use_id: Some(call_id.clone()),
|
||||
tool_input: Some(HookToolInput::Function {
|
||||
arguments: arguments.clone(),
|
||||
}),
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let mut config =
|
||||
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
|
||||
apply_role_to_config(&mut config, role_name)
|
||||
@@ -201,7 +233,7 @@ mod spawn {
|
||||
.send_event(
|
||||
&turn,
|
||||
CollabAgentSpawnEndEvent {
|
||||
call_id,
|
||||
call_id: call_id.clone(),
|
||||
sender_thread_id: session.conversation_id,
|
||||
new_thread_id,
|
||||
new_agent_nickname,
|
||||
@@ -212,6 +244,30 @@ mod spawn {
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
dispatch_nonfatal_lifecycle_hook(
|
||||
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::SubagentStop {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
prompt: prompt_for_hook,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: Some(call_id.clone()),
|
||||
tool_input: Some(HookToolInput::Function {
|
||||
arguments: arguments.clone(),
|
||||
}),
|
||||
subagent_id: new_thread_id,
|
||||
metadata: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let new_thread_id = result?;
|
||||
let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
|
||||
turn.otel_manager
|
||||
|
||||
@@ -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,7 +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;
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::types::Hook;
|
||||
use crate::types::HookEvent;
|
||||
use crate::types::HookPayload;
|
||||
use crate::types::HookResponse;
|
||||
use crate::types::HookResult;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct HooksConfig {
|
||||
pub legacy_notify_argv: Option<Vec<String>>,
|
||||
pub session_start_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_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>,
|
||||
user_prompt_submit: Vec<Hook>,
|
||||
stop: Vec<Hook>,
|
||||
pre_compact: Vec<Hook>,
|
||||
session_end: Vec<Hook>,
|
||||
subagent_start: Vec<Hook>,
|
||||
subagent_stop: Vec<Hook>,
|
||||
}
|
||||
|
||||
impl Default for Hooks {
|
||||
@@ -32,16 +54,87 @@ impl Hooks {
|
||||
.map(crate::notify_hook)
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let session_start = config
|
||||
.session_start_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("session_start", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let user_prompt_submit = config
|
||||
.user_prompt_submit_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("user_prompt_submit", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let pre_tool_use = config
|
||||
.pre_tool_use_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("pre_tool_use", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let post_tool_use = config
|
||||
.post_tool_use_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.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
|
||||
.session_end_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("session_end", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let subagent_start = config
|
||||
.subagent_start_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.map(|argv| command_hook("subagent_start", argv))
|
||||
.into_iter()
|
||||
.collect();
|
||||
let subagent_stop = config
|
||||
.subagent_stop_argv
|
||||
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
|
||||
.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,
|
||||
user_prompt_submit,
|
||||
stop,
|
||||
pre_compact,
|
||||
session_end,
|
||||
subagent_start,
|
||||
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::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::SubagentStop { .. } => &self.subagent_stop,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +164,63 @@ pub fn command_from_argv(argv: &[String]) -> Option<Command> {
|
||||
Some(command)
|
||||
}
|
||||
|
||||
fn command_hook(name: &str, argv: Vec<String>) -> Hook {
|
||||
let hook_name = name.to_string();
|
||||
let argv = Arc::new(argv);
|
||||
Hook {
|
||||
name: hook_name,
|
||||
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,
|
||||
};
|
||||
let payload_json = match serde_json::to_string(payload) {
|
||||
Ok(payload_json) => payload_json,
|
||||
Err(err) => return HookResult::FailedContinue(err.into()),
|
||||
};
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.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()),
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
@@ -92,7 +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;
|
||||
@@ -103,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
|
||||
@@ -172,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(),
|
||||
@@ -199,6 +353,34 @@ 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
|
||||
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp"),
|
||||
hook_event: event,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_lifecycle_event() -> HookEventLifecycle {
|
||||
HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
prompt: Some("hello".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(),
|
||||
}),
|
||||
subagent_id: Some(ThreadId::new()),
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_from_argv_returns_none_for_empty_args() {
|
||||
assert!(command_from_argv(&[]).is_none());
|
||||
@@ -231,6 +413,7 @@ mod tests {
|
||||
assert!(
|
||||
Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: Some(vec![]),
|
||||
..HooksConfig::default()
|
||||
})
|
||||
.after_agent
|
||||
.is_empty()
|
||||
@@ -238,6 +421,7 @@ mod tests {
|
||||
assert!(
|
||||
Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: Some(vec!["".to_string()]),
|
||||
..HooksConfig::default()
|
||||
})
|
||||
.after_agent
|
||||
.is_empty()
|
||||
@@ -245,11 +429,55 @@ mod tests {
|
||||
assert_eq!(
|
||||
Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: Some(vec!["notify-send".to_string()]),
|
||||
..HooksConfig::default()
|
||||
})
|
||||
.after_agent
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
Hooks::new(HooksConfig {
|
||||
session_start_argv: Some(vec!["".to_string()]),
|
||||
..HooksConfig::default()
|
||||
})
|
||||
.session_start
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
Hooks::new(HooksConfig {
|
||||
session_start_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
..HooksConfig::default()
|
||||
})
|
||||
.session_start
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_new_wires_all_lifecycle_commands() {
|
||||
let hooks = Hooks::new(HooksConfig {
|
||||
session_start_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_stop_argv: Some(vec!["hooks-cli".to_string()]),
|
||||
..HooksConfig::default()
|
||||
});
|
||||
|
||||
assert_eq!(hooks.session_start.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_stop.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -317,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()
|
||||
};
|
||||
|
||||
@@ -328,6 +556,147 @@ mod tests {
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_executes_lifecycle_hooks() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let hooks = Hooks {
|
||||
session_start: vec![counting_success_hook(&calls, "counting")],
|
||||
..Hooks::default()
|
||||
};
|
||||
|
||||
let outcomes = hooks
|
||||
.dispatch(lifecycle_payload(HookEvent::SessionStart {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
prompt: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: None,
|
||||
subagent_id: None,
|
||||
metadata: None,
|
||||
},
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert_eq!(outcomes[0].hook_name, "counting");
|
||||
assert!(matches!(outcomes[0].result, HookResult::Success));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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 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_stop_calls = Arc::new(AtomicUsize::new(0));
|
||||
let hooks = Hooks {
|
||||
session_start: vec![counting_success_hook(&session_start_calls, "session_start")],
|
||||
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_stop: vec![counting_success_hook(&subagent_stop_calls, "subagent_stop")],
|
||||
..Hooks::default()
|
||||
};
|
||||
|
||||
let cases = vec![
|
||||
(
|
||||
"session_start",
|
||||
lifecycle_payload(HookEvent::SessionStart {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&session_start_calls),
|
||||
),
|
||||
(
|
||||
"user_prompt_submit",
|
||||
lifecycle_payload(HookEvent::UserPromptSubmit {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&turn_start_calls),
|
||||
),
|
||||
(
|
||||
"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(&pre_tool_use_calls),
|
||||
),
|
||||
(
|
||||
"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(&stop_calls),
|
||||
),
|
||||
(
|
||||
"pre_compact",
|
||||
lifecycle_payload(HookEvent::PreCompact {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&pre_compact_calls),
|
||||
),
|
||||
(
|
||||
"session_end",
|
||||
lifecycle_payload(HookEvent::SessionEnd {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&session_end_calls),
|
||||
),
|
||||
(
|
||||
"subagent_start",
|
||||
lifecycle_payload(HookEvent::SubagentStart {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&subagent_start_calls),
|
||||
),
|
||||
(
|
||||
"subagent_stop",
|
||||
lifecycle_payload(HookEvent::SubagentStop {
|
||||
event: sample_lifecycle_event(),
|
||||
}),
|
||||
Arc::clone(&subagent_stop_calls),
|
||||
),
|
||||
];
|
||||
|
||||
for (hook_name, payload, counter) in cases {
|
||||
let outcomes = hooks.dispatch(payload).await;
|
||||
assert_eq!(outcomes.len(), 1);
|
||||
assert_eq!(outcomes[0].hook_name, hook_name);
|
||||
assert!(matches!(outcomes[0].result, HookResult::Success));
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_continues_after_continueable_failure() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
@@ -352,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()
|
||||
};
|
||||
@@ -369,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));
|
||||
@@ -423,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));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -9,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>;
|
||||
|
||||
@@ -60,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,
|
||||
}
|
||||
@@ -122,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,
|
||||
@@ -137,28 +155,123 @@ pub struct HookEventAfterToolUse {
|
||||
pub output_preview: 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, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct HookEventLifecycle {
|
||||
#[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 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")]
|
||||
pub tool_input: Option<HookToolInput>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subagent_id: Option<ThreadId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
},
|
||||
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 { .. } => "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)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::TimeZone;
|
||||
@@ -170,18 +283,41 @@ 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;
|
||||
use super::HookToolKind;
|
||||
|
||||
fn sample_lifecycle_event(
|
||||
previous_session_id: ThreadId,
|
||||
subagent_id: ThreadId,
|
||||
) -> HookEventLifecycle {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("phase".to_string(), "done".to_string());
|
||||
|
||||
HookEventLifecycle {
|
||||
previous_session_id: Some(previous_session_id),
|
||||
prompt: Some("hello world".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(),
|
||||
}),
|
||||
subagent_id: Some(subagent_id),
|
||||
metadata: Some(metadata),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_payload_serializes_stable_wire_shape() {
|
||||
let session_id = ThreadId::new();
|
||||
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
|
||||
@@ -201,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(),
|
||||
@@ -256,35 +392,305 @@ 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 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::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()),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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_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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_lifecycle_payloads_serialize_stable_wire_shape() {
|
||||
let session_id = ThreadId::new();
|
||||
let previous_session_id = ThreadId::new();
|
||||
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
|
||||
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
|
||||
.single()
|
||||
.expect("valid timestamp"),
|
||||
hook_event,
|
||||
};
|
||||
|
||||
let cases = vec![
|
||||
(
|
||||
"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_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",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
"UserPromptSubmit",
|
||||
base_payload(HookEvent::UserPromptSubmit {
|
||||
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": "UserPromptSubmit",
|
||||
"prompt": "hello world",
|
||||
"last_assistant_message": "done",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
"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,
|
||||
subagent_id: 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": "PreCompact",
|
||||
"previous_session_id": previous_session_id.to_string(),
|
||||
"last_assistant_message": "done",
|
||||
"tool_use_id": "toolu_123",
|
||||
"metadata": {
|
||||
"phase": "done",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
"SessionEnd",
|
||||
base_payload(HookEvent::SessionEnd {
|
||||
event: HookEventLifecycle {
|
||||
prompt: None,
|
||||
last_assistant_message: None,
|
||||
tool_use_id: None,
|
||||
tool_input: 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": "SessionEnd",
|
||||
"previous_session_id": previous_session_id.to_string(),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"SubagentStart",
|
||||
base_payload(HookEvent::SubagentStart {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: None,
|
||||
last_assistant_message: 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": "SubagentStart",
|
||||
"prompt": "hello world",
|
||||
"tool_use_id": "toolu_123",
|
||||
"tool_input": {
|
||||
"input_type": "function",
|
||||
"arguments": "{\"code\":\"cargo test\"}",
|
||||
},
|
||||
}),
|
||||
),
|
||||
(
|
||||
"SubagentStop",
|
||||
base_payload(HookEvent::SubagentStop {
|
||||
event: HookEventLifecycle {
|
||||
previous_session_id: 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_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(),
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (event_name, payload, expected) in cases {
|
||||
assert_eq!(payload.hook_event.name(), event_name);
|
||||
let actual = serde_json::to_value(payload).expect("serialize lifecycle payload");
|
||||
assert_eq!(actual, expected, "lifecycle event {event_name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -26,6 +26,34 @@ Codex can run a notification hook when the agent finishes a turn. See the config
|
||||
|
||||
When Codex knows which client started the turn, the legacy notify JSON payload also includes a top-level `client` field. The TUI reports `codex-tui`, and the app server reports the `clientInfo.name` value from `initialize`.
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
Codex can run command hooks for lifecycle events via `[hooks]` in `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[hooks]
|
||||
session_start = ["my-hook", "session_start"]
|
||||
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_stop = ["my-hook", "subagent_stop"]
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`.
|
||||
|
||||
Reference in New Issue
Block a user