Simplify async hook dispatch

This commit is contained in:
Abhinav Vedmala
2026-06-08 23:01:00 -07:00
parent 27a561b18f
commit 218e45801a
12 changed files with 505 additions and 641 deletions

View File

@@ -6,6 +6,7 @@ use std::sync::MutexGuard;
use codex_protocol::protocol::HookEventName;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::approx_bytes_for_tokens;
use codex_utils_output_truncation::approx_token_count;
use codex_utils_output_truncation::formatted_truncate_text;
use codex_utils_output_truncation::truncate_text;
@@ -46,15 +47,13 @@ struct AsyncCommandState {
tasks: TaskTracker,
}
/// A rendered snapshot of the oldest deliverable completions in the queue.
/// Single-use marker for the completions ready before a real user turn began.
///
/// Preparing a batch does not consume it. The caller commits it only after the
/// synchronous `UserPromptSubmit` lane accepts the turn, so a blocked prompt
/// cannot lose completed async output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AsyncOutputBatch {
/// Delivery is single-consumer, and producers only append between capturing and
/// flushing a boundary. Therefore the marked prefix cannot move or shrink.
#[derive(Debug)]
pub(super) struct AsyncOutputBoundary {
completion_count: usize,
text: String,
}
impl AsyncCommandRuntime {
@@ -122,36 +121,66 @@ impl AsyncCommandRuntime {
.push_back(AsyncCommandCompletion { event_name, text });
}
/// Prepares the oldest contiguous queue prefix that fits the per-turn flush budget.
///
/// The selected completions remain queued until [`Self::commit`] is called.
/// Later completions stay in FIFO order for a subsequent real user turn.
pub(crate) fn prepare_batch(&self) -> Option<AsyncOutputBatch> {
let pending = self.lock_pending();
let mut selected = Vec::new();
for completion in pending.iter() {
selected.push(completion.clone());
// Measure the fully rendered developer injection so wrapper overhead
// counts toward the flush budget. The first item that does not fit,
// and every item after it, remains queued.
let text = render_batch(&selected);
if approx_token_count(&text) > ASYNC_HOOK_FLUSH_TOKEN_LIMIT {
selected.pop();
break;
}
/// Captures the queue boundary before the current user turn can spawn more hooks.
pub(super) fn ready_boundary(&self) -> AsyncOutputBoundary {
AsyncOutputBoundary {
completion_count: self.lock_pending().len(),
}
(!selected.is_empty()).then(|| AsyncOutputBatch {
completion_count: selected.len(),
text: render_batch(&selected),
})
}
/// Consumes a prepared prefix and returns its merged developer-context payload.
pub(crate) fn commit(&self, batch: AsyncOutputBatch) -> String {
// Producers only append, so completions arriving after preparation cannot
// disturb the prefix identified by `completion_count`.
self.lock_pending().drain(..batch.completion_count);
batch.text
/// Flushes the bounded FIFO prefix that was ready at `boundary`.
///
/// Callers invoke this only after the real user turn is accepted. Completions
/// appended after the boundary, including hooks fired by the current turn,
/// remain queued for a later turn.
pub(super) fn flush_through(&self, boundary: AsyncOutputBoundary) -> Option<String> {
let mut pending = self.lock_pending();
debug_assert!(
boundary.completion_count <= pending.len(),
"async output can only be appended between boundary capture and flush"
);
// Keep release builds resilient if the single-consumer contract is ever
// violated rather than allowing an out-of-bounds prefix.
let eligible_count = boundary.completion_count.min(pending.len());
let max_bytes = approx_bytes_for_tokens(ASYNC_HOOK_FLUSH_TOKEN_LIMIT);
let closing_tag = "\n</async_hook_outputs>";
let mut completion_count = 0;
let mut text = String::from("<async_hook_outputs>\n");
for completion in pending.iter().take(eligible_count) {
let rendered = format!(
"<async_hook_output event=\"{:?}\">\n{}\n</async_hook_output>",
completion.event_name, completion.text
);
let separator_len = usize::from(completion_count > 0);
// Include the outer closing tag in the budget. The first completion
// that does not fit, and every completion after it, stays queued.
if text
.len()
.saturating_add(separator_len)
.saturating_add(rendered.len())
.saturating_add(closing_tag.len())
> max_bytes
{
break;
}
if completion_count > 0 {
text.push('\n');
}
text.push_str(&rendered);
completion_count += 1;
}
if completion_count == 0 {
return None;
}
text.push_str(closing_tag);
// Producers only append, so arrivals after the boundary cannot disturb
// the prefix selected above.
pending.drain(..completion_count);
Some(text)
}
/// Cancels in-flight handlers, closes the tracker for waiting, and joins its tasks.
@@ -170,21 +199,6 @@ impl AsyncCommandRuntime {
}
}
/// Renders several completed firings as one ordered developer-context injection.
fn render_batch(completions: &[AsyncCommandCompletion]) -> String {
let outputs = completions
.iter()
.map(|completion| {
format!(
"<async_hook_output event=\"{:?}\">\n{}\n</async_hook_output>",
completion.event_name, completion.text
)
})
.collect::<Vec<_>>()
.join("\n");
format!("<async_hook_outputs>\n{outputs}\n</async_hook_outputs>")
}
/// Converts a command result into informational text suitable for later delivery.
///
/// Successful output contributes only event-supported informational content.

View File

@@ -78,22 +78,31 @@ fn async_output_surfaces_parse_and_runtime_failures() {
}
#[test]
fn queue_preserves_duplicate_completions_until_commit() {
fn queue_preserves_duplicate_completions_and_holds_arrivals_after_boundary() {
let runtime = AsyncCommandRuntime::default();
runtime.push(HookEventName::PreToolUse, "same".to_string());
runtime.push(HookEventName::PostToolUse, "same".to_string());
runtime.push(HookEventName::Stop, "last".to_string());
let batch = runtime.prepare_batch().expect("queued output batch");
assert_eq!(runtime.prepare_batch(), Some(batch.clone()));
let text = runtime.commit(batch);
let boundary = runtime.ready_boundary();
runtime.push(HookEventName::SessionStart, "next-turn".to_string());
let text = runtime
.flush_through(boundary)
.expect("queued async output");
assert_eq!(text.matches("same").count(), 2);
assert!(
["PreToolUse", "PostToolUse", "last"]
.map(|needle| text.find(needle).expect("queued output"))
.is_sorted()
);
assert!(runtime.prepare_batch().is_none());
assert!(!text.contains("next-turn"));
let text = runtime
.flush_through(runtime.ready_boundary())
.expect("completion after boundary");
assert!(text.contains("next-turn"));
assert!(runtime.flush_through(runtime.ready_boundary()).is_none());
}
#[test]
@@ -114,9 +123,10 @@ fn queue_bounds_items_and_flushes_a_contiguous_prefix() {
let mut completed = 0;
loop {
let batch = runtime.prepare_batch().expect("bounded output batch");
completed += batch.completion_count;
let text = runtime.commit(batch);
let text = runtime
.flush_through(runtime.ready_boundary())
.expect("bounded async output");
completed += text.matches("<async_hook_output event=").count();
assert!(approx_token_count(&text) <= ASYNC_HOOK_FLUSH_TOKEN_LIMIT);
if let Some(tail) = text.find("small-tail") {
if let Some(large) = text.find("tokens truncated") {
@@ -126,7 +136,7 @@ fn queue_bounds_items_and_flushes_a_contiguous_prefix() {
break;
}
}
assert!(runtime.prepare_batch().is_none());
assert!(runtime.flush_through(runtime.ready_boundary()).is_none());
}
#[tokio::test]

View File

@@ -2,6 +2,7 @@ use std::path::Path;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use serde::Serialize;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
@@ -11,9 +12,8 @@ use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookScope;
use super::CommandShell;
use super::ClaudeHooksEngine;
use super::ConfiguredHandler;
use super::async_output::AsyncCommandRuntime;
use super::command_runner::CommandRunResult;
use super::command_runner::run_command;
use crate::events::common::matches_matcher;
@@ -25,27 +25,7 @@ pub(crate) struct ParsedHandler<T> {
pub completion_order: usize,
}
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_sync_handlers(
handlers: &[ConfiguredHandler],
event_name: HookEventName,
matcher_input: Option<&str>,
) -> Vec<ConfiguredHandler> {
select_handlers(handlers, event_name, matcher_input)
.into_iter()
.filter(|handler| !handler.r#async)
.collect()
}
pub(crate) fn select_handlers_for_matcher_inputs(
fn select_handlers_for_matcher_inputs(
handlers: &[ConfiguredHandler],
event_name: HookEventName,
matcher_inputs: &[&str],
@@ -79,18 +59,7 @@ pub(crate) fn select_handlers_for_matcher_inputs(
.collect()
}
pub(crate) fn select_sync_handlers_for_matcher_inputs(
handlers: &[ConfiguredHandler],
event_name: HookEventName,
matcher_inputs: &[&str],
) -> Vec<ConfiguredHandler> {
select_handlers_for_matcher_inputs(handlers, event_name, matcher_inputs)
.into_iter()
.filter(|handler| !handler.r#async)
.collect()
}
pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
HookRunSummary {
id: handler.run_id(),
event_name: handler.event_name,
@@ -109,49 +78,86 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
}
}
pub(crate) async fn execute_handlers<T>(
shell: &CommandShell,
async_runtime: &AsyncCommandRuntime,
handlers: Vec<ConfiguredHandler>,
input_json: Result<String, String>,
cwd: &Path,
turn_id: Option<String>,
parse: fn(&ConfiguredHandler, CommandRunResult, Option<String>) -> ParsedHandler<T>,
) -> Vec<ParsedHandler<T>> {
let (handlers, asynchronous) = handlers
.into_iter()
.partition::<Vec<_>, _>(|handler| !handler.r#async);
for handler in asynchronous {
async_runtime.spawn_handler(
shell.clone(),
handler,
input_json.clone(),
cwd.to_path_buf(),
);
impl ClaudeHooksEngine {
pub(crate) fn preview_commands(
&self,
event_name: HookEventName,
matcher_inputs: &[&str],
) -> Vec<HookRunSummary> {
select_handlers_for_matcher_inputs(&self.handlers, event_name, matcher_inputs)
.into_iter()
.filter(|handler| !handler.r#async)
.map(|handler| running_summary(&handler))
.collect()
}
let mut pending = FuturesUnordered::new();
for (configured_order, handler) in handlers.into_iter().enumerate() {
let input_json = input_json.clone();
let turn_id = turn_id.clone();
pending.push(async move {
let result = match input_json {
Ok(input_json) => run_command(shell, &handler, &input_json, cwd).await,
Err(error) => CommandRunResult::failed(error),
};
(configured_order, parse(&handler, result, turn_id))
});
}
pub(crate) async fn execute_commands<T, I>(
&self,
event_name: HookEventName,
matcher_inputs: &[&str],
input: &I,
cwd: &Path,
turn_id: Option<String>,
parse: fn(&ConfiguredHandler, CommandRunResult, Option<String>) -> ParsedHandler<T>,
) -> Vec<ParsedHandler<T>>
where
I: Serialize + ?Sized,
{
let handlers =
select_handlers_for_matcher_inputs(&self.handlers, event_name, matcher_inputs);
if handlers.is_empty() {
return Vec::new();
}
let mut completed = Vec::new();
let mut completion_order = 0;
while let Some((configured_order, mut parsed)) = pending.next().await {
parsed.completion_order = completion_order;
completion_order += 1;
completed.push((configured_order, parsed));
let input_label = match event_name {
HookEventName::PreToolUse => "pre tool use",
HookEventName::PermissionRequest => "permission request",
HookEventName::PostToolUse => "post tool use",
HookEventName::PreCompact => "pre compact",
HookEventName::PostCompact => "post compact",
HookEventName::SessionStart => "session start",
HookEventName::UserPromptSubmit => "user prompt submit",
HookEventName::SubagentStart => "subagent start",
HookEventName::SubagentStop => "subagent stop",
HookEventName::Stop => "stop",
};
let input_json = serde_json::to_string(input)
.map_err(|error| format!("failed to serialize {input_label} hook input: {error}"));
let (handlers, asynchronous) = handlers
.into_iter()
.partition::<Vec<_>, _>(|handler| !handler.r#async);
for handler in asynchronous {
self.async_runtime.spawn_handler(
self.shell.clone(),
handler,
input_json.clone(),
cwd.to_path_buf(),
);
}
let mut pending = FuturesUnordered::new();
for (configured_order, handler) in handlers.into_iter().enumerate() {
let input_json = input_json.clone();
let turn_id = turn_id.clone();
pending.push(async move {
let result = match input_json {
Ok(input_json) => run_command(&self.shell, &handler, &input_json, cwd).await,
Err(error) => CommandRunResult::failed(error),
};
(configured_order, parse(&handler, result, turn_id))
});
}
let mut completed = Vec::new();
let mut completion_order = 0;
while let Some((configured_order, mut parsed)) = pending.next().await {
parsed.completion_order = completion_order;
completion_order += 1;
completed.push((configured_order, parsed));
}
completed.sort_by_key(|(configured_order, _)| *configured_order);
completed.into_iter().map(|(_, parsed)| parsed).collect()
}
completed.sort_by_key(|(configured_order, _)| *configured_order);
completed.into_iter().map(|(_, parsed)| parsed).collect()
}
pub(crate) fn completed_summary(
@@ -202,16 +208,19 @@ mod tests {
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use serde::Serialize;
use serde::Serializer;
use serde::ser::Error as _;
use super::ClaudeHooksEngine;
use super::CommandRunResult;
use super::CommandShell;
use super::ConfiguredHandler;
use super::ParsedHandler;
use super::completed_summary;
use super::execute_handlers;
use super::select_handlers;
use super::select_handlers_for_matcher_inputs;
use crate::engine::CommandShell;
use crate::engine::async_output::AsyncCommandRuntime;
use crate::output_spill::HookOutputSpiller;
fn make_handler(
event_name: HookEventName,
@@ -233,6 +242,22 @@ mod tests {
}
}
fn engine_with_handlers(
handlers: Vec<ConfiguredHandler>,
async_runtime: AsyncCommandRuntime,
) -> ClaudeHooksEngine {
ClaudeHooksEngine {
handlers,
warnings: Vec::new(),
shell: CommandShell {
program: String::new(),
args: Vec::new(),
},
async_runtime,
output_spiller: HookOutputSpiller::new(),
}
}
#[test]
fn select_handlers_keeps_duplicate_stop_handlers() {
let handlers = vec![
@@ -250,7 +275,7 @@ mod tests {
),
];
let selected = select_handlers(&handlers, HookEventName::Stop, /*matcher_input*/ None);
let selected = select_handlers_for_matcher_inputs(&handlers, HookEventName::Stop, &[]);
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].display_order, 0);
@@ -274,7 +299,11 @@ mod tests {
),
];
let selected = select_handlers(&handlers, HookEventName::SessionStart, Some("startup"));
let selected = select_handlers_for_matcher_inputs(
&handlers,
HookEventName::SessionStart,
&["startup"],
);
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].display_order, 0);
@@ -298,7 +327,8 @@ mod tests {
),
];
let selected = select_handlers(&handlers, HookEventName::PreCompact, Some("manual"));
let selected =
select_handlers_for_matcher_inputs(&handlers, HookEventName::PreCompact, &["manual"]);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].display_order, 0);
@@ -321,7 +351,8 @@ mod tests {
),
];
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
let selected =
select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Bash"]);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].display_order, 0);
@@ -344,7 +375,8 @@ mod tests {
),
];
let selected = select_handlers(&handlers, HookEventName::PostToolUse, Some("Bash"));
let selected =
select_handlers_for_matcher_inputs(&handlers, HookEventName::PostToolUse, &["Bash"]);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].display_order, 0);
@@ -367,7 +399,8 @@ mod tests {
),
];
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
let selected =
select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Bash"]);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].display_order, 0);
@@ -382,9 +415,12 @@ mod tests {
/*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"));
let selected_edit =
select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Edit"]);
let selected_write =
select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Write"]);
let selected_bash =
select_handlers_for_matcher_inputs(&handlers, HookEventName::PreToolUse, &["Bash"]);
assert_eq!(selected_edit.len(), 1);
assert_eq!(selected_write.len(), 1);
@@ -453,11 +489,8 @@ mod tests {
),
];
let selected = select_handlers(
&handlers,
HookEventName::UserPromptSubmit,
/*matcher_input*/ None,
);
let selected =
select_handlers_for_matcher_inputs(&handlers, HookEventName::UserPromptSubmit, &[]);
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].display_order, 0);
@@ -488,12 +521,9 @@ mod tests {
];
handlers[1].r#async = true;
let selected = select_handlers(&handlers, HookEventName::Stop, /*matcher_input*/ None);
let synchronous = super::select_sync_handlers(
&handlers,
HookEventName::Stop,
/*matcher_input*/ None,
);
let selected = select_handlers_for_matcher_inputs(&handlers, HookEventName::Stop, &[]);
let engine = engine_with_handlers(handlers, AsyncCommandRuntime::default());
let synchronous = engine.preview_commands(HookEventName::Stop, &[]);
assert_eq!(selected.len(), 3);
assert_eq!(selected[0].command, "first");
@@ -502,9 +532,9 @@ mod tests {
assert_eq!(
synchronous
.iter()
.map(|handler| handler.command.as_str())
.map(|run| run.display_order)
.collect::<Vec<_>>(),
vec!["first", "third"],
vec![0, 2],
);
}
@@ -521,41 +551,55 @@ mod tests {
asynchronous.display_order = 1;
asynchronous.r#async = true;
let runtime = AsyncCommandRuntime::default();
let engine = engine_with_handlers(vec![synchronous, asynchronous], runtime.clone());
let cwd = test_path_buf("/tmp").abs();
let results = execute_handlers(
&CommandShell {
program: String::new(),
args: Vec::new(),
},
&runtime,
vec![synchronous, asynchronous],
Err("serialize failed".to_string()),
cwd.as_path(),
Some("turn-1".to_string()),
parse_failure,
)
.await;
let results = engine
.execute_commands(
HookEventName::PreToolUse,
&["Bash"],
&SerializationFailure,
cwd.as_path(),
Some("turn-1".to_string()),
parse_failure,
)
.await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].completed.run.status, HookRunStatus::Failed);
assert_eq!(results[0].data, "serialize failed");
let batch = tokio::time::timeout(Duration::from_secs(1), async {
assert_eq!(
results[0].data,
"failed to serialize pre tool use hook input: serialize failed"
);
let output = tokio::time::timeout(Duration::from_secs(1), async {
loop {
if let Some(batch) = runtime.prepare_batch() {
break batch;
let boundary = runtime.ready_boundary();
if let Some(output) = runtime.flush_through(boundary) {
break output;
}
tokio::task::yield_now().await;
}
})
.await
.expect("async serialization failure completion");
let output = runtime.commit(batch);
assert!(output.contains("Async hook failed to run: serialize failed"));
assert!(output.contains(
"Async hook failed to run: failed to serialize pre tool use hook input: serialize failed"
));
assert!(output.contains("event=\"PreToolUse\""));
runtime.shutdown().await;
}
struct SerializationFailure;
impl Serialize for SerializationFailure {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
Err(S::Error::custom("serialize failed"))
}
}
fn parse_failure(
handler: &ConfiguredHandler,
run_result: CommandRunResult,

View File

@@ -196,25 +196,25 @@ impl ClaudeHooksEngine {
&self,
request: &SessionStartRequest,
) -> Vec<HookRunSummary> {
crate::events::session_start::preview(&self.handlers, request)
crate::events::session_start::preview(self, request)
}
pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec<HookRunSummary> {
crate::events::pre_tool_use::preview(&self.handlers, request)
crate::events::pre_tool_use::preview(self, request)
}
pub(crate) fn preview_permission_request(
&self,
request: &PermissionRequestRequest,
) -> Vec<HookRunSummary> {
crate::events::permission_request::preview(&self.handlers, request)
crate::events::permission_request::preview(self, request)
}
pub(crate) fn preview_post_tool_use(
&self,
request: &PostToolUseRequest,
) -> Vec<HookRunSummary> {
crate::events::post_tool_use::preview(&self.handlers, request)
crate::events::post_tool_use::preview(self, request)
}
pub(crate) async fn run_session_start(
@@ -223,14 +223,7 @@ impl ClaudeHooksEngine {
turn_id: Option<String>,
) -> SessionStartOutcome {
let session_id = request.session_id;
let mut outcome = crate::events::session_start::run(
&self.handlers,
&self.shell,
&self.async_runtime,
request,
turn_id,
)
.await;
let mut outcome = crate::events::session_start::run(self, request, turn_id).await;
outcome.additional_contexts = self
.maybe_spill_texts(session_id, outcome.additional_contexts)
.await;
@@ -239,13 +232,7 @@ impl ClaudeHooksEngine {
pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
let session_id = request.session_id;
let mut outcome = crate::events::pre_tool_use::run(
&self.handlers,
&self.shell,
&self.async_runtime,
request,
)
.await;
let mut outcome = crate::events::pre_tool_use::run(self, request).await;
outcome.additional_contexts = self
.maybe_spill_texts(session_id, outcome.additional_contexts)
.await;
@@ -256,13 +243,7 @@ impl ClaudeHooksEngine {
&self,
request: PermissionRequestRequest,
) -> PermissionRequestOutcome {
crate::events::permission_request::run(
&self.handlers,
&self.shell,
&self.async_runtime,
request,
)
.await
crate::events::permission_request::run(self, request).await
}
pub(crate) async fn run_post_tool_use(
@@ -270,13 +251,7 @@ impl ClaudeHooksEngine {
request: PostToolUseRequest,
) -> PostToolUseOutcome {
let session_id = request.session_id;
let mut outcome = crate::events::post_tool_use::run(
&self.handlers,
&self.shell,
&self.async_runtime,
request,
)
.await;
let mut outcome = crate::events::post_tool_use::run(self, request).await;
outcome.additional_contexts = self
.maybe_spill_texts(session_id, outcome.additional_contexts)
.await;
@@ -287,31 +262,29 @@ impl ClaudeHooksEngine {
}
pub(crate) fn preview_pre_compact(&self, request: &PreCompactRequest) -> Vec<HookRunSummary> {
crate::events::compact::preview_pre(&self.handlers, request)
crate::events::compact::preview_pre(self, request)
}
pub(crate) async fn run_pre_compact(&self, request: PreCompactRequest) -> PreCompactOutcome {
crate::events::compact::run_pre(&self.handlers, &self.shell, &self.async_runtime, request)
.await
crate::events::compact::run_pre(self, request).await
}
pub(crate) fn preview_post_compact(&self, request: &PostCompactRequest) -> Vec<HookRunSummary> {
crate::events::compact::preview_post(&self.handlers, request)
crate::events::compact::preview_post(self, request)
}
pub(crate) async fn run_post_compact(
&self,
request: PostCompactRequest,
) -> StatelessHookOutcome {
crate::events::compact::run_post(&self.handlers, &self.shell, &self.async_runtime, request)
.await
crate::events::compact::run_post(self, request).await
}
pub(crate) fn preview_user_prompt_submit(
&self,
request: &UserPromptSubmitRequest,
) -> Vec<HookRunSummary> {
crate::events::user_prompt_submit::preview(&self.handlers, request)
crate::events::user_prompt_submit::preview(self, request)
}
pub(crate) async fn run_user_prompt_submit(
@@ -319,35 +292,26 @@ impl ClaudeHooksEngine {
request: UserPromptSubmitRequest,
) -> UserPromptSubmitOutcome {
let session_id = request.session_id;
let async_output_batch = self.async_runtime.prepare_batch();
let mut outcome = crate::events::user_prompt_submit::run(
&self.handlers,
&self.shell,
&self.async_runtime,
request,
)
.await;
let async_output_boundary = self.async_runtime.ready_boundary();
let mut outcome = crate::events::user_prompt_submit::run(self, request).await;
outcome.additional_contexts = self
.maybe_spill_texts(session_id, outcome.additional_contexts)
.await;
if !outcome.should_stop
&& let Some(batch) = async_output_batch
&& let Some(text) = self.async_runtime.flush_through(async_output_boundary)
{
let text = self.async_runtime.commit(batch);
outcome.additional_contexts.insert(0, text);
}
outcome
}
pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec<HookRunSummary> {
crate::events::stop::preview(&self.handlers, request)
crate::events::stop::preview(self, request)
}
pub(crate) async fn run_stop(&self, request: StopRequest) -> StopOutcome {
let session_id = request.session_id;
let mut outcome =
crate::events::stop::run(&self.handlers, &self.shell, &self.async_runtime, request)
.await;
let mut outcome = crate::events::stop::run(self, request).await;
outcome.continuation_fragments = self
.maybe_spill_prompt_fragments(session_id, outcome.continuation_fragments)
.await;

View File

@@ -32,6 +32,7 @@ use tempfile::tempdir;
use super::ClaudeHooksEngine;
use super::CommandShell;
use super::ConfiguredHandler;
use crate::events::post_tool_use::PostToolUseRequest;
use crate::events::pre_tool_use::PreToolUseRequest;
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
@@ -66,6 +67,45 @@ fn user_prompt_request() -> UserPromptSubmitRequest {
}
}
#[test]
fn post_tool_use_preview_id_includes_tool_use_id() {
let source_path = cwd();
let mut engine = disabled_engine();
engine.handlers.push(ConfiguredHandler {
event_name: HookEventName::PostToolUse,
matcher: Some("^Bash$".to_string()),
command: "echo hook".to_string(),
timeout_sec: 5,
r#async: false,
status_message: None,
source_path: source_path.clone(),
source: HookSource::User,
display_order: 0,
env: HashMap::new(),
});
let preview = engine.preview_post_tool_use(&PostToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
subagent: None,
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-call-456".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
tool_response: serde_json::json!({ "output": "hello" }),
});
assert_eq!(preview.len(), 1);
assert_eq!(
preview[0].id,
format!("post-tool-use:0:{}:tool-call-456", source_path.display())
);
}
fn managed_hooks_for_current_platform(
managed_dir: impl AsRef<Path>,
hooks: HookEventsToml,
@@ -1272,6 +1312,7 @@ print(json.dumps({
.await;
assert_eq!(outcome.hook_events.len(), 1);
assert_eq!(outcome.hook_events[0].run.id, preview[0].id);
assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin);
assert_eq!(
outcome.hook_events[0].run.status,
@@ -1415,7 +1456,8 @@ async fn reconfiguration_preserves_pending_async_output() {
assert_eq!(outcome.hook_events, Vec::new());
assert_eq!(outcome.additional_contexts.len(), 1);
assert!(outcome.additional_contexts[0].contains("queued context"));
assert!(reconfigured.async_runtime.prepare_batch().is_none());
let boundary = reconfigured.async_runtime.ready_boundary();
assert!(reconfigured.async_runtime.flush_through(boundary).is_none());
}
#[tokio::test]
@@ -1444,11 +1486,11 @@ async fn blocked_prompt_preserves_pending_async_output() {
let blocked = engine.run_user_prompt_submit(request.clone()).await;
assert!(blocked.should_stop);
assert!(engine.async_runtime.prepare_batch().is_some());
engine.handlers.clear();
let accepted = engine.run_user_prompt_submit(request).await;
assert!(!accepted.should_stop);
assert!(accepted.additional_contexts[0].contains("queued context"));
assert!(engine.async_runtime.prepare_batch().is_none());
let boundary = engine.async_runtime.ready_boundary();
assert!(engine.async_runtime.flush_through(boundary).is_none());
}

View File

@@ -10,9 +10,8 @@ use codex_protocol::protocol::HookRunSummary;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ClaudeHooksEngine;
use crate::engine::ConfiguredHandler;
use crate::engine::async_output::AsyncCommandRuntime;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
@@ -57,51 +56,27 @@ pub struct PreCompactOutcome {
}
pub(crate) fn preview_pre(
handlers: &[ConfiguredHandler],
engine: &ClaudeHooksEngine,
request: &PreCompactRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_sync_handlers(
handlers,
HookEventName::PreCompact,
Some(request.trigger.as_str()),
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
engine.preview_commands(HookEventName::PreCompact, &[request.trigger.as_str()])
}
pub(crate) async fn run_pre(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
async_runtime: &AsyncCommandRuntime,
engine: &ClaudeHooksEngine,
request: PreCompactRequest,
) -> PreCompactOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::PreCompact,
Some(request.trigger.as_str()),
);
if matched.is_empty() {
return PreCompactOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
};
}
let input_json = pre_command_input_json(&request)
.map_err(|error| format!("failed to serialize pre compact hook input: {error}"));
let results = dispatcher::execute_handlers(
shell,
async_runtime,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_pre_completed,
)
.await;
let input = pre_command_input(&request);
let results = engine
.execute_commands(
HookEventName::PreCompact,
&[request.trigger.as_str()],
&input,
request.cwd.as_path(),
Some(request.turn_id),
parse_pre_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
@@ -113,9 +88,9 @@ pub(crate) async fn run_pre(
}
}
fn pre_command_input_json(request: &PreCompactRequest) -> Result<String, serde_json::Error> {
fn pre_command_input(request: &PreCompactRequest) -> PreCompactCommandInput {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
serde_json::to_string(&PreCompactCommandInput {
PreCompactCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
@@ -125,55 +100,31 @@ fn pre_command_input_json(request: &PreCompactRequest) -> Result<String, serde_j
hook_event_name: "PreCompact".to_string(),
model: request.model.clone(),
trigger: request.trigger.clone(),
})
}
}
pub(crate) fn preview_post(
handlers: &[ConfiguredHandler],
engine: &ClaudeHooksEngine,
request: &PostCompactRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_sync_handlers(
handlers,
HookEventName::PostCompact,
Some(request.trigger.as_str()),
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
engine.preview_commands(HookEventName::PostCompact, &[request.trigger.as_str()])
}
pub(crate) async fn run_post(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
async_runtime: &AsyncCommandRuntime,
engine: &ClaudeHooksEngine,
request: PostCompactRequest,
) -> StatelessHookOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::PostCompact,
Some(request.trigger.as_str()),
);
if matched.is_empty() {
return StatelessHookOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
};
}
let input_json = post_command_input_json(&request)
.map_err(|error| format!("failed to serialize post compact hook input: {error}"));
let results = dispatcher::execute_handlers(
shell,
async_runtime,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_post_completed,
)
.await;
let input = post_command_input(&request);
let results = engine
.execute_commands(
HookEventName::PostCompact,
&[request.trigger.as_str()],
&input,
request.cwd.as_path(),
Some(request.turn_id),
parse_post_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
@@ -185,9 +136,9 @@ pub(crate) async fn run_post(
}
}
fn post_command_input_json(request: &PostCompactRequest) -> Result<String, serde_json::Error> {
fn post_command_input(request: &PostCompactRequest) -> PostCompactCommandInput {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
serde_json::to_string(&PostCompactCommandInput {
PostCompactCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
@@ -197,7 +148,7 @@ fn post_command_input_json(request: &PostCompactRequest) -> Result<String, serde
hook_event_name: "PostCompact".to_string(),
model: request.model.clone(),
trigger: request.trigger.clone(),
})
}
}
#[derive(Default)]
@@ -410,16 +361,15 @@ mod tests {
use super::parse_post_completed;
use super::parse_pre_completed;
use super::post_command_input_json;
use super::pre_command_input_json;
use super::post_command_input;
use super::pre_command_input;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn pre_compact_input_includes_lifecycle_metadata() {
let input_json = pre_command_input_json(&pre_request()).expect("serialize command input");
let input: serde_json::Value =
serde_json::from_str(&input_json).expect("parse command input");
let input = serde_json::to_value(pre_command_input(&pre_request()))
.expect("serialize command input");
assert_eq!(
input,
@@ -437,9 +387,8 @@ mod tests {
#[test]
fn post_compact_input_includes_lifecycle_metadata() {
let input_json = post_command_input_json(&post_request()).expect("serialize command input");
let input: serde_json::Value =
serde_json::from_str(&input_json).expect("parse command input");
let input = serde_json::to_value(post_command_input(&post_request()))
.expect("serialize command input");
assert_eq!(
input,

View File

@@ -16,9 +16,8 @@
use std::path::PathBuf;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ClaudeHooksEngine;
use crate::engine::ConfiguredHandler;
use crate::engine::async_output::AsyncCommandRuntime;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
@@ -66,57 +65,33 @@ struct PermissionRequestHandlerData {
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
engine: &ClaudeHooksEngine,
request: &PermissionRequestRequest,
) -> Vec<HookRunSummary> {
let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
dispatcher::select_sync_handlers_for_matcher_inputs(
handlers,
HookEventName::PermissionRequest,
&matcher_inputs,
)
.into_iter()
.map(|handler| {
common::hook_run_for_tool_use(
dispatcher::running_summary(&handler),
&request.run_id_suffix,
)
})
.collect()
engine
.preview_commands(HookEventName::PermissionRequest, &matcher_inputs)
.into_iter()
.map(|run| common::hook_run_for_tool_use(run, &request.run_id_suffix))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
async_runtime: &AsyncCommandRuntime,
engine: &ClaudeHooksEngine,
request: PermissionRequestRequest,
) -> PermissionRequestOutcome {
let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
let matched = dispatcher::select_handlers_for_matcher_inputs(
handlers,
HookEventName::PermissionRequest,
&matcher_inputs,
);
if matched.is_empty() {
return PermissionRequestOutcome {
hook_events: Vec::new(),
decision: None,
};
}
let input_json = serde_json::to_string(&build_command_input(&request))
.map_err(|error| format!("failed to serialize permission request hook input: {error}"));
let results = dispatcher::execute_handlers(
shell,
async_runtime,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
let input = build_command_input(&request);
let results = engine
.execute_commands(
HookEventName::PermissionRequest,
&matcher_inputs,
&input,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
// Preserve the most specific matching allow, but treat any deny as final so
// broader policy layers cannot accidentally overrule a more specific block.

View File

@@ -11,9 +11,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ClaudeHooksEngine;
use crate::engine::ConfiguredHandler;
use crate::engine::async_output::AsyncCommandRuntime;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
@@ -54,57 +53,33 @@ struct PostToolUseHandlerData {
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
engine: &ClaudeHooksEngine,
request: &PostToolUseRequest,
) -> Vec<HookRunSummary> {
let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
dispatcher::select_sync_handlers_for_matcher_inputs(
handlers,
HookEventName::PostToolUse,
&matcher_inputs,
)
.into_iter()
.map(|handler| {
common::hook_run_for_tool_use(dispatcher::running_summary(&handler), &request.tool_use_id)
})
.collect()
engine
.preview_commands(HookEventName::PostToolUse, &matcher_inputs)
.into_iter()
.map(|run| common::hook_run_for_tool_use(run, &request.tool_use_id))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
async_runtime: &AsyncCommandRuntime,
engine: &ClaudeHooksEngine,
request: PostToolUseRequest,
) -> PostToolUseOutcome {
let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
let matched = dispatcher::select_handlers_for_matcher_inputs(
handlers,
HookEventName::PostToolUse,
&matcher_inputs,
);
if matched.is_empty() {
return PostToolUseOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
additional_contexts: Vec::new(),
feedback_message: None,
};
}
let input_json = command_input_json(&request)
.map_err(|error| format!("failed to serialize post tool use hook input: {error}"));
let results = dispatcher::execute_handlers(
shell,
async_runtime,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
let input = command_input(&request);
let results = engine
.execute_commands(
HookEventName::PostToolUse,
&matcher_inputs,
&input,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
let additional_contexts = common::flatten_additional_contexts(
results
@@ -136,15 +111,15 @@ pub(crate) async fn run(
}
}
/// Serializes command stdin for a selected `PostToolUse` hook.
/// Builds command stdin for a selected `PostToolUse` hook.
///
/// Handler selection may include internal matcher aliases, but hook stdin keeps
/// the canonical `tool_name` for logs and for consumers that pair pre/post
/// events across processes. Shell-like tools pass `{ "command": ... }` as
/// `tool_input`; MCP tools pass their resolved JSON arguments.
fn command_input_json(request: &PostToolUseRequest) -> Result<String, serde_json::Error> {
fn command_input(request: &PostToolUseRequest) -> PostToolUseCommandInput {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
serde_json::to_string(&PostToolUseCommandInput {
PostToolUseCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
@@ -158,7 +133,7 @@ fn command_input_json(request: &PostToolUseRequest) -> Result<String, serde_json
tool_input: request.tool_input.clone(),
tool_response: request.tool_response.clone(),
tool_use_id: request.tool_use_id.clone(),
})
}
}
fn parse_completed(
@@ -313,9 +288,8 @@ mod tests {
use serde_json::json;
use super::PostToolUseHandlerData;
use super::command_input_json;
use super::command_input;
use super::parse_completed;
use super::preview;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::events::common;
@@ -325,9 +299,7 @@ mod tests {
let mut request = request_for_tool_use("call-apply-patch");
request.tool_name = "apply_patch".to_string();
let input_json = command_input_json(&request).expect("serialize command input");
let input: serde_json::Value =
serde_json::from_str(&input_json).expect("parse command input");
let input = serde_json::to_value(command_input(&request)).expect("serialize command input");
assert_eq!(input["tool_name"], "apply_patch");
}
@@ -490,19 +462,8 @@ mod tests {
}
#[test]
fn preview_and_completed_run_ids_include_tool_use_id() {
fn completed_run_id_includes_tool_use_id() {
let request = request_for_tool_use("tool-call-456");
let runs = preview(&[handler()], &request);
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].id,
format!(
"post-tool-use:0:{}:tool-call-456",
test_path_buf("/tmp/hooks.json").display()
)
);
let parsed = parse_completed(
&handler(),
run_result(Some(0), "", ""),
@@ -510,7 +471,13 @@ mod tests {
);
let completed = common::hook_completed_for_tool_use(parsed.completed, &request.tool_use_id);
assert_eq!(completed.run.id, runs[0].id);
assert_eq!(
completed.run.id,
format!(
"post-tool-use:0:{}:tool-call-456",
test_path_buf("/tmp/hooks.json").display()
)
);
}
fn handler() -> ConfiguredHandler {

View File

@@ -11,9 +11,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ClaudeHooksEngine;
use crate::engine::ConfiguredHandler;
use crate::engine::async_output::AsyncCommandRuntime;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
@@ -53,57 +52,33 @@ struct PreToolUseHandlerData {
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
engine: &ClaudeHooksEngine,
request: &PreToolUseRequest,
) -> Vec<HookRunSummary> {
let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
dispatcher::select_sync_handlers_for_matcher_inputs(
handlers,
HookEventName::PreToolUse,
&matcher_inputs,
)
.into_iter()
.map(|handler| {
common::hook_run_for_tool_use(dispatcher::running_summary(&handler), &request.tool_use_id)
})
.collect()
engine
.preview_commands(HookEventName::PreToolUse, &matcher_inputs)
.into_iter()
.map(|run| common::hook_run_for_tool_use(run, &request.tool_use_id))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
async_runtime: &AsyncCommandRuntime,
engine: &ClaudeHooksEngine,
request: PreToolUseRequest,
) -> PreToolUseOutcome {
let matcher_inputs = common::matcher_inputs(&request.tool_name, &request.matcher_aliases);
let matched = dispatcher::select_handlers_for_matcher_inputs(
handlers,
HookEventName::PreToolUse,
&matcher_inputs,
);
if matched.is_empty() {
return PreToolUseOutcome {
hook_events: Vec::new(),
should_block: false,
block_reason: None,
additional_contexts: Vec::new(),
updated_input: None,
};
}
let input_json = command_input_json(&request)
.map_err(|error| format!("failed to serialize pre tool use hook input: {error}"));
let results = dispatcher::execute_handlers(
shell,
async_runtime,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
let input = command_input(&request);
let results = engine
.execute_commands(
HookEventName::PreToolUse,
&matcher_inputs,
&input,
request.cwd.as_path(),
Some(request.turn_id.clone()),
parse_completed,
)
.await;
let should_block = results.iter().any(|result| result.data.should_block);
let block_reason = results
@@ -154,15 +129,15 @@ fn latest_updated_input(
.map(|(_, updated_input)| updated_input)
}
/// Serializes command stdin for a selected `PreToolUse` hook.
/// Builds command stdin for a selected `PreToolUse` hook.
///
/// Handler selection may include internal matcher aliases, but hook stdin keeps
/// the canonical `tool_name` so audit logs and downstream policy decisions stay
/// stable. Shell-like tools pass `{ "command": ... }` as `tool_input`; MCP
/// tools pass their resolved JSON arguments.
fn command_input_json(request: &PreToolUseRequest) -> Result<String, serde_json::Error> {
fn command_input(request: &PreToolUseRequest) -> PreToolUseCommandInput {
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
serde_json::to_string(&PreToolUseCommandInput {
PreToolUseCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
@@ -175,7 +150,7 @@ fn command_input_json(request: &PreToolUseRequest) -> Result<String, serde_json:
tool_name: request.tool_name.clone(),
tool_input: request.tool_input.clone(),
tool_use_id: request.tool_use_id.clone(),
})
}
}
fn parse_completed(
@@ -307,10 +282,9 @@ mod tests {
use pretty_assertions::assert_eq;
use super::PreToolUseHandlerData;
use super::command_input_json;
use super::command_input;
use super::latest_updated_input;
use super::parse_completed;
use super::preview;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::events::common;
@@ -320,9 +294,7 @@ mod tests {
let mut request = request_for_tool_use("call-apply-patch");
request.tool_name = "apply_patch".to_string();
let input_json = command_input_json(&request).expect("serialize command input");
let input: serde_json::Value =
serde_json::from_str(&input_json).expect("parse command input");
let input = serde_json::to_value(command_input(&request)).expect("serialize command input");
assert_eq!(input["tool_name"], "apply_patch");
}
@@ -682,19 +654,8 @@ mod tests {
}
#[test]
fn preview_and_completed_run_ids_include_tool_use_id() {
fn completed_run_id_includes_tool_use_id() {
let request = request_for_tool_use("tool-call-123");
let runs = preview(&[handler()], &request);
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].id,
format!(
"pre-tool-use:0:{}:tool-call-123",
test_path_buf("/tmp/hooks.json").display()
)
);
let parsed = parse_completed(
&handler(),
run_result(Some(0), "", ""),
@@ -702,7 +663,13 @@ mod tests {
);
let completed = common::hook_completed_for_tool_use(parsed.completed, &request.tool_use_id);
assert_eq!(completed.run.id, runs[0].id);
assert_eq!(
completed.run.id,
format!(
"pre-tool-use:0:{}:tool-call-123",
test_path_buf("/tmp/hooks.json").display()
)
);
}
fn handler() -> ConfiguredHandler {

View File

@@ -10,9 +10,8 @@ use codex_protocol::protocol::HookRunSummary;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ClaudeHooksEngine;
use crate::engine::ConfiguredHandler;
use crate::engine::async_output::AsyncCommandRuntime;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
@@ -93,53 +92,41 @@ struct SessionStartHandlerData {
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
engine: &ClaudeHooksEngine,
request: &SessionStartRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_sync_handlers(
handlers,
engine.preview_commands(
request.target.event_name(),
Some(request.target.matcher_input()),
&[request.target.matcher_input()],
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
async_runtime: &AsyncCommandRuntime,
engine: &ClaudeHooksEngine,
request: SessionStartRequest,
turn_id: Option<String>,
) -> SessionStartOutcome {
let matched = dispatcher::select_handlers(
handlers,
request.target.event_name(),
Some(request.target.matcher_input()),
);
if matched.is_empty() {
return SessionStartOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
additional_contexts: Vec::new(),
};
}
let (input_json, turn_id) = match request.target {
StartHookTarget::SessionStart { source } => (
serde_json::to_string(&SessionStartCommandInput::new(
let results = match request.target {
StartHookTarget::SessionStart { source } => {
let input = SessionStartCommandInput::new(
request.session_id.to_string(),
request.transcript_path.clone(),
request.cwd.display().to_string(),
request.model.clone(),
request.permission_mode.clone(),
source.as_str().to_string(),
))
.map_err(|error| format!("failed to serialize session start hook input: {error}")),
turn_id,
),
);
engine
.execute_commands(
HookEventName::SessionStart,
&[source.as_str()],
&input,
request.cwd.as_path(),
turn_id,
parse_completed,
)
.await
}
StartHookTarget::SubagentStart {
turn_id: subagent_turn_id,
agent_id,
@@ -156,23 +143,19 @@ pub(crate) async fn run(
agent_id,
agent_type,
};
let input_json = serde_json::to_string(&input)
.map_err(|error| format!("failed to serialize subagent start hook input: {error}"));
(input_json, Some(subagent_turn_id))
engine
.execute_commands(
HookEventName::SubagentStart,
&[input.agent_type.as_str()],
&input,
request.cwd.as_path(),
Some(subagent_turn_id),
parse_completed,
)
.await
}
};
let results = dispatcher::execute_handlers(
shell,
async_runtime,
matched,
input_json,
request.cwd.as_path(),
turn_id,
parse_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()

View File

@@ -11,9 +11,8 @@ use codex_protocol::protocol::HookRunSummary;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ClaudeHooksEngine;
use crate::engine::ConfiguredHandler;
use crate::engine::async_output::AsyncCommandRuntime;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
@@ -79,43 +78,13 @@ struct StopHandlerData {
continuation_fragments: Vec<HookPromptFragment>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
request: &StopRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_sync_handlers(
handlers,
request.target.event_name(),
request.target.matcher_input(),
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
pub(crate) fn preview(engine: &ClaudeHooksEngine, request: &StopRequest) -> Vec<HookRunSummary> {
let matcher_input = request.target.matcher_input();
engine.preview_commands(request.target.event_name(), matcher_input.as_slice())
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
async_runtime: &AsyncCommandRuntime,
request: StopRequest,
) -> StopOutcome {
let matched = dispatcher::select_handlers(
handlers,
request.target.event_name(),
request.target.matcher_input(),
);
if matched.is_empty() {
return StopOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
should_block: false,
block_reason: None,
continuation_fragments: Vec::new(),
};
}
let input_json = match request.target {
pub(crate) async fn run(engine: &ClaudeHooksEngine, request: StopRequest) -> StopOutcome {
let results = match request.target {
StopHookTarget::Stop => {
let input = StopCommandInput {
session_id: request.session_id.to_string(),
@@ -130,8 +99,16 @@ pub(crate) async fn run(
request.last_assistant_message.clone(),
),
};
serde_json::to_string(&input)
.map_err(|error| format!("failed to serialize stop hook input: {error}"))
engine
.execute_commands(
HookEventName::Stop,
&[],
&input,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await
}
StopHookTarget::SubagentStop {
agent_id,
@@ -154,22 +131,19 @@ pub(crate) async fn run(
request.last_assistant_message.clone(),
),
};
serde_json::to_string(&input)
.map_err(|error| format!("failed to serialize subagent stop hook input: {error}"))
engine
.execute_commands(
HookEventName::SubagentStop,
&[input.agent_type.as_str()],
&input,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await
}
};
let results = dispatcher::execute_handlers(
shell,
async_runtime,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await;
let aggregate = aggregate_results(results.iter().map(|result| &result.data));
StopOutcome {

View File

@@ -10,9 +10,8 @@ use codex_protocol::protocol::HookRunSummary;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ClaudeHooksEngine;
use crate::engine::ConfiguredHandler;
use crate::engine::async_output::AsyncCommandRuntime;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
@@ -48,41 +47,18 @@ struct UserPromptSubmitHandlerData {
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
engine: &ClaudeHooksEngine,
_request: &UserPromptSubmitRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_sync_handlers(
handlers,
HookEventName::UserPromptSubmit,
/*matcher_input*/ None,
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
engine.preview_commands(HookEventName::UserPromptSubmit, &[])
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
async_runtime: &AsyncCommandRuntime,
engine: &ClaudeHooksEngine,
request: UserPromptSubmitRequest,
) -> UserPromptSubmitOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::UserPromptSubmit,
/*matcher_input*/ None,
);
if matched.is_empty() {
return UserPromptSubmitOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
additional_contexts: Vec::new(),
};
}
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
let input_json = serde_json::to_string(&UserPromptSubmitCommandInput {
let input = UserPromptSubmitCommandInput {
session_id: request.session_id.to_string(),
turn_id: request.turn_id.clone(),
agent_id: subagent.agent_id,
@@ -93,19 +69,18 @@ pub(crate) async fn run(
model: request.model.clone(),
permission_mode: request.permission_mode.clone(),
prompt: request.prompt.clone(),
})
.map_err(|error| format!("failed to serialize user prompt submit hook input: {error}"));
};
let results = dispatcher::execute_handlers(
shell,
async_runtime,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await;
let results = engine
.execute_commands(
HookEventName::UserPromptSubmit,
&[],
&input,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results