mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Based on work from Vincent K - https://github.com/openai/codex/pull/19060 <img width="1836" height="642" alt="CleanShot 2026-04-29 at 20 47 40@2x" src="https://github.com/user-attachments/assets/b647bb89-65fe-40c8-80b0-7a6b7c984634" /> ## Why Compaction rewrites the conversation context that future model turns receive, but hooks currently have no deterministic lifecycle point around that rewrite. This adds compact lifecycle hooks so users can audit manual and automatic compaction, surface hook messages in the UI, and run post-compaction follow-up without overloading tool or prompt hooks. ## What Changed - Added `PreCompact` and `PostCompact` hook events across hook config, discovery, dispatch, generated schemas, app-server notifications, analytics, and TUI hook rendering. - Added trigger matching for compact hooks with the documented `manual` and `auto` matcher values. - Wired `PreCompact` before both local and remote compaction, and `PostCompact` after successful local or remote compaction. - Kept compact hook command input to lifecycle metadata: session id, Codex turn id, transcript path, cwd, hook event name, model, and trigger. - Made compact stdout handling consistent with other hooks: plain stdout is ignored as debug output, while malformed JSON-looking stdout is reported as failed hook output. - Added integration coverage for compact hook dispatch, trigger matching, post-compact execution, and the audited behavior that `decision:"block"` does not block compaction. ## Out of Scope - Hook-specific compaction blocking is not implemented; `decision:"block"` and exit-code-2 blocking semantics are intentionally unsupported for `PreCompact`. - Custom compaction instructions are not exposed to compact hooks in this PR. - Compact summaries, summary character counts, and summary previews are not exposed to compact hooks in this PR. ## Verification - `cargo test -p codex-hooks` - `cargo test -p codex-core manual_pre_compact_block_decision_does_not_block_compaction` - `cargo test -p codex-app-server hooks_list` - `cargo test -p codex-core config_schema_matches_fixture` - `cargo test -p codex-tui hooks_browser` ## Docs The developer documentation for Codex hooks should be updated alongside this feature to document `PreCompact` and `PostCompact`, the `manual`/`auto` matcher values, and the compact hook payload fields. --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
436 lines
13 KiB
Rust
436 lines
13 KiB
Rust
use std::path::Path;
|
|
|
|
use futures::future::join_all;
|
|
|
|
use codex_protocol::protocol::HookCompletedEvent;
|
|
use codex_protocol::protocol::HookEventName;
|
|
use codex_protocol::protocol::HookExecutionMode;
|
|
use codex_protocol::protocol::HookHandlerType;
|
|
use codex_protocol::protocol::HookRunStatus;
|
|
use codex_protocol::protocol::HookRunSummary;
|
|
use codex_protocol::protocol::HookScope;
|
|
|
|
use super::CommandShell;
|
|
use super::ConfiguredHandler;
|
|
use super::command_runner::CommandRunResult;
|
|
use super::command_runner::run_command;
|
|
use crate::events::common::matches_matcher;
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct ParsedHandler<T> {
|
|
pub completed: HookCompletedEvent,
|
|
pub data: T,
|
|
}
|
|
|
|
pub(crate) fn select_handlers(
|
|
handlers: &[ConfiguredHandler],
|
|
event_name: HookEventName,
|
|
matcher_input: Option<&str>,
|
|
) -> Vec<ConfiguredHandler> {
|
|
let matcher_inputs = matcher_input.into_iter().collect::<Vec<_>>();
|
|
select_handlers_for_matcher_inputs(handlers, event_name, &matcher_inputs)
|
|
}
|
|
|
|
pub(crate) fn select_handlers_for_matcher_inputs(
|
|
handlers: &[ConfiguredHandler],
|
|
event_name: HookEventName,
|
|
matcher_inputs: &[&str],
|
|
) -> Vec<ConfiguredHandler> {
|
|
// Check each configured handler once, even when several compatibility names
|
|
// match the same regex. A hook like `apply_patch|Write|Edit` should run a
|
|
// single time for one tool call, not once per matching alias.
|
|
handlers
|
|
.iter()
|
|
.filter(|handler| handler.event_name == event_name)
|
|
.filter(|handler| match event_name {
|
|
HookEventName::PreToolUse
|
|
| HookEventName::PermissionRequest
|
|
| HookEventName::PostToolUse
|
|
| HookEventName::SessionStart
|
|
| HookEventName::PreCompact
|
|
| HookEventName::PostCompact => {
|
|
if matcher_inputs.is_empty() {
|
|
matches_matcher(handler.matcher.as_deref(), /*input*/ None)
|
|
} else {
|
|
matcher_inputs
|
|
.iter()
|
|
.any(|input| matches_matcher(handler.matcher.as_deref(), Some(input)))
|
|
}
|
|
}
|
|
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
|
|
})
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
|
|
HookRunSummary {
|
|
id: handler.run_id(),
|
|
event_name: handler.event_name,
|
|
handler_type: HookHandlerType::Command,
|
|
execution_mode: HookExecutionMode::Sync,
|
|
scope: scope_for_event(handler.event_name),
|
|
source_path: handler.source_path.clone(),
|
|
source: handler.source,
|
|
display_order: handler.display_order,
|
|
status: HookRunStatus::Running,
|
|
status_message: handler.status_message.clone(),
|
|
started_at: chrono::Utc::now().timestamp(),
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
entries: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn execute_handlers<T>(
|
|
shell: &CommandShell,
|
|
handlers: Vec<ConfiguredHandler>,
|
|
input_json: String,
|
|
cwd: &Path,
|
|
turn_id: Option<String>,
|
|
parse: fn(&ConfiguredHandler, CommandRunResult, Option<String>) -> ParsedHandler<T>,
|
|
) -> Vec<ParsedHandler<T>> {
|
|
let results = join_all(
|
|
handlers
|
|
.iter()
|
|
.map(|handler| run_command(shell, handler, &input_json, cwd)),
|
|
)
|
|
.await;
|
|
|
|
handlers
|
|
.into_iter()
|
|
.zip(results)
|
|
.map(|(handler, result)| parse(&handler, result, turn_id.clone()))
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn completed_summary(
|
|
handler: &ConfiguredHandler,
|
|
run_result: &CommandRunResult,
|
|
status: HookRunStatus,
|
|
entries: Vec<codex_protocol::protocol::HookOutputEntry>,
|
|
) -> HookRunSummary {
|
|
HookRunSummary {
|
|
id: handler.run_id(),
|
|
event_name: handler.event_name,
|
|
handler_type: HookHandlerType::Command,
|
|
execution_mode: HookExecutionMode::Sync,
|
|
scope: scope_for_event(handler.event_name),
|
|
source_path: handler.source_path.clone(),
|
|
source: handler.source,
|
|
display_order: handler.display_order,
|
|
status,
|
|
status_message: handler.status_message.clone(),
|
|
started_at: run_result.started_at,
|
|
completed_at: Some(run_result.completed_at),
|
|
duration_ms: Some(run_result.duration_ms),
|
|
entries,
|
|
}
|
|
}
|
|
|
|
fn scope_for_event(event_name: HookEventName) -> HookScope {
|
|
match event_name {
|
|
HookEventName::SessionStart => HookScope::Thread,
|
|
HookEventName::PreToolUse
|
|
| HookEventName::PermissionRequest
|
|
| HookEventName::PostToolUse
|
|
| HookEventName::PreCompact
|
|
| HookEventName::PostCompact
|
|
| HookEventName::UserPromptSubmit
|
|
| HookEventName::Stop => HookScope::Turn,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use codex_protocol::protocol::HookEventName;
|
|
use codex_protocol::protocol::HookSource;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
|
|
use super::ConfiguredHandler;
|
|
use super::select_handlers;
|
|
use super::select_handlers_for_matcher_inputs;
|
|
|
|
fn make_handler(
|
|
event_name: HookEventName,
|
|
matcher: Option<&str>,
|
|
command: &str,
|
|
display_order: i64,
|
|
) -> ConfiguredHandler {
|
|
ConfiguredHandler {
|
|
event_name,
|
|
matcher: matcher.map(str::to_owned),
|
|
command: command.to_string(),
|
|
timeout_sec: 5,
|
|
status_message: None,
|
|
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
|
source: HookSource::User,
|
|
display_order,
|
|
env: std::collections::HashMap::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn select_handlers_keeps_duplicate_stop_handlers() {
|
|
let handlers = vec![
|
|
make_handler(
|
|
HookEventName::Stop,
|
|
/*matcher*/ None,
|
|
"echo same",
|
|
/*display_order*/ 0,
|
|
),
|
|
make_handler(
|
|
HookEventName::Stop,
|
|
/*matcher*/ None,
|
|
"echo same",
|
|
/*display_order*/ 1,
|
|
),
|
|
];
|
|
|
|
let selected = select_handlers(&handlers, HookEventName::Stop, /*matcher_input*/ None);
|
|
|
|
assert_eq!(selected.len(), 2);
|
|
assert_eq!(selected[0].display_order, 0);
|
|
assert_eq!(selected[1].display_order, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn select_handlers_keeps_overlapping_session_start_matchers() {
|
|
let handlers = vec![
|
|
make_handler(
|
|
HookEventName::SessionStart,
|
|
Some("start.*"),
|
|
"echo same",
|
|
/*display_order*/ 0,
|
|
),
|
|
make_handler(
|
|
HookEventName::SessionStart,
|
|
Some("^startup$"),
|
|
"echo same",
|
|
/*display_order*/ 1,
|
|
),
|
|
];
|
|
|
|
let selected = select_handlers(&handlers, HookEventName::SessionStart, Some("startup"));
|
|
|
|
assert_eq!(selected.len(), 2);
|
|
assert_eq!(selected[0].display_order, 0);
|
|
assert_eq!(selected[1].display_order, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_hooks_match_trigger() {
|
|
let handlers = vec![
|
|
make_handler(
|
|
HookEventName::PreCompact,
|
|
Some("manual"),
|
|
"echo manual",
|
|
/*display_order*/ 0,
|
|
),
|
|
make_handler(
|
|
HookEventName::PreCompact,
|
|
Some("auto"),
|
|
"echo auto",
|
|
/*display_order*/ 1,
|
|
),
|
|
];
|
|
|
|
let selected = select_handlers(&handlers, HookEventName::PreCompact, Some("manual"));
|
|
|
|
assert_eq!(selected.len(), 1);
|
|
assert_eq!(selected[0].display_order, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tool_use_matches_tool_name() {
|
|
let handlers = vec![
|
|
make_handler(
|
|
HookEventName::PreToolUse,
|
|
Some("^Bash$"),
|
|
"echo same",
|
|
/*display_order*/ 0,
|
|
),
|
|
make_handler(
|
|
HookEventName::PreToolUse,
|
|
Some("^Edit$"),
|
|
"echo same",
|
|
/*display_order*/ 1,
|
|
),
|
|
];
|
|
|
|
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
|
|
|
assert_eq!(selected.len(), 1);
|
|
assert_eq!(selected[0].display_order, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn post_tool_use_matches_tool_name() {
|
|
let handlers = vec![
|
|
make_handler(
|
|
HookEventName::PostToolUse,
|
|
Some("^Bash$"),
|
|
"echo same",
|
|
/*display_order*/ 0,
|
|
),
|
|
make_handler(
|
|
HookEventName::PostToolUse,
|
|
Some("^Edit$"),
|
|
"echo same",
|
|
/*display_order*/ 1,
|
|
),
|
|
];
|
|
|
|
let selected = select_handlers(&handlers, HookEventName::PostToolUse, Some("Bash"));
|
|
|
|
assert_eq!(selected.len(), 1);
|
|
assert_eq!(selected[0].display_order, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tool_use_star_matcher_matches_all_tools() {
|
|
let handlers = vec![
|
|
make_handler(
|
|
HookEventName::PreToolUse,
|
|
Some("*"),
|
|
"echo same",
|
|
/*display_order*/ 0,
|
|
),
|
|
make_handler(
|
|
HookEventName::PreToolUse,
|
|
Some("^Edit$"),
|
|
"echo same",
|
|
/*display_order*/ 1,
|
|
),
|
|
];
|
|
|
|
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
|
|
|
assert_eq!(selected.len(), 1);
|
|
assert_eq!(selected[0].display_order, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tool_use_regex_alternation_matches_each_tool_name() {
|
|
let handlers = vec![make_handler(
|
|
HookEventName::PreToolUse,
|
|
Some("Edit|Write"),
|
|
"echo same",
|
|
/*display_order*/ 0,
|
|
)];
|
|
|
|
let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit"));
|
|
let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write"));
|
|
let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
|
|
|
|
assert_eq!(selected_edit.len(), 1);
|
|
assert_eq!(selected_write.len(), 1);
|
|
assert_eq!(selected_bash.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn pre_tool_use_aliases_match_once_per_handler() {
|
|
let handlers = vec![
|
|
make_handler(
|
|
HookEventName::PreToolUse,
|
|
Some("^apply_patch$"),
|
|
"echo apply_patch",
|
|
/*display_order*/ 0,
|
|
),
|
|
make_handler(
|
|
HookEventName::PreToolUse,
|
|
Some("^Write$"),
|
|
"echo write",
|
|
/*display_order*/ 1,
|
|
),
|
|
make_handler(
|
|
HookEventName::PreToolUse,
|
|
Some("^Edit$"),
|
|
"echo edit",
|
|
/*display_order*/ 2,
|
|
),
|
|
make_handler(
|
|
HookEventName::PreToolUse,
|
|
Some("apply_patch|Write|Edit"),
|
|
"echo combined",
|
|
/*display_order*/ 3,
|
|
),
|
|
];
|
|
|
|
let selected = select_handlers_for_matcher_inputs(
|
|
&handlers,
|
|
HookEventName::PreToolUse,
|
|
&["apply_patch", "Write", "Edit"],
|
|
);
|
|
|
|
assert_eq!(selected.len(), 4);
|
|
assert_eq!(
|
|
selected
|
|
.iter()
|
|
.map(|handler| handler.display_order)
|
|
.collect::<Vec<_>>(),
|
|
vec![0, 1, 2, 3],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn user_prompt_submit_ignores_matcher() {
|
|
let handlers = vec![
|
|
make_handler(
|
|
HookEventName::UserPromptSubmit,
|
|
Some("^hello"),
|
|
"echo first",
|
|
/*display_order*/ 0,
|
|
),
|
|
make_handler(
|
|
HookEventName::UserPromptSubmit,
|
|
Some("["),
|
|
"echo second",
|
|
/*display_order*/ 1,
|
|
),
|
|
];
|
|
|
|
let selected = select_handlers(
|
|
&handlers,
|
|
HookEventName::UserPromptSubmit,
|
|
/*matcher_input*/ None,
|
|
);
|
|
|
|
assert_eq!(selected.len(), 2);
|
|
assert_eq!(selected[0].display_order, 0);
|
|
assert_eq!(selected[1].display_order, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn select_handlers_preserves_declaration_order() {
|
|
let handlers = vec![
|
|
make_handler(
|
|
HookEventName::Stop,
|
|
/*matcher*/ None,
|
|
"first",
|
|
/*display_order*/ 0,
|
|
),
|
|
make_handler(
|
|
HookEventName::Stop,
|
|
/*matcher*/ None,
|
|
"second",
|
|
/*display_order*/ 1,
|
|
),
|
|
make_handler(
|
|
HookEventName::Stop,
|
|
/*matcher*/ None,
|
|
"third",
|
|
/*display_order*/ 2,
|
|
),
|
|
];
|
|
|
|
let selected = select_handlers(&handlers, HookEventName::Stop, /*matcher_input*/ None);
|
|
|
|
assert_eq!(selected.len(), 3);
|
|
assert_eq!(selected[0].command, "first");
|
|
assert_eq!(selected[1].command, "second");
|
|
assert_eq!(selected[2].command, "third");
|
|
}
|
|
}
|