Add hooks for interrupted turns (#40511)

## What changed

- Add an `Interrupt` hook event that runs for an active top-level turn before its
  interrupted abort event is emitted.
- Flush the turn transcript before invoking the hook and provide the session,
  turn, transcript, working directory, model, and permission mode in its input.
- Support command and MCP handlers, including asynchronous commands, with a
  one-second default timeout and a three-second maximum.
- Expose the event through hook configuration, managed requirements, app-server
  notifications, generated schemas, analytics, and the TUI hook views.

## Testing

- Cover handler discovery, timeout normalization, output parsing, protocol
  compatibility, TUI rendering, and interrupt execution ordering.

GitOrigin-RevId: 163fa7c098d94ac2775f6d137f8e916f8ea9b6eb
This commit is contained in:
Andrei Eternal
2026-08-25 01:27:18 +00:00
committed by copyberry
parent 9b2ef38f54
commit cbfd999db7
49 changed files with 1393 additions and 48 deletions
+5 -1
View File
@@ -123,7 +123,7 @@ pub(crate) fn matcher_pattern_for_event(
| HookEventName::SubagentStop
| HookEventName::PreCompact
| HookEventName::PostCompact => matcher,
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
HookEventName::UserPromptSubmit | HookEventName::Stop | HookEventName::Interrupt => None,
}
}
@@ -270,6 +270,10 @@ mod tests {
matcher_pattern_for_event(HookEventName::Stop, Some("^done$")),
None
);
assert_eq!(
matcher_pattern_for_event(HookEventName::Interrupt, Some("^interrupted$")),
None
);
}
#[test]
+175
View File
@@ -0,0 +1,175 @@
use std::path::PathBuf;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use codex_utils_absolute_path::AbsolutePathBuf;
use super::common;
use crate::engine::ClaudeHooksEngine;
use crate::engine::ConfiguredHandler;
use crate::engine::HandlerRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::InterruptCommandInput;
use crate::schema::NullableString;
#[derive(Debug, Clone)]
pub struct InterruptRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub cwd: AbsolutePathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
}
#[derive(Debug, Default)]
pub struct InterruptOutcome {
pub hook_events: Vec<HookCompletedEvent>,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct InterruptHandlerData;
pub(crate) fn preview(handlers: &[ConfiguredHandler]) -> Vec<HookRunSummary> {
dispatcher::select_handlers(
handlers,
HookEventName::Interrupt,
/*matcher_input*/ None,
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(engine: &ClaudeHooksEngine, request: InterruptRequest) -> InterruptOutcome {
let matched = dispatcher::select_handlers(
&engine.handlers,
HookEventName::Interrupt,
/*matcher_input*/ None,
);
if matched.is_empty() {
return InterruptOutcome::default();
}
let InterruptRequest {
session_id,
turn_id,
cwd,
transcript_path,
model,
permission_mode,
} = request;
let input_json = match serde_json::to_string(&InterruptCommandInput {
session_id: session_id.to_string(),
turn_id: turn_id.clone(),
transcript_path: NullableString::from_path(transcript_path),
cwd: cwd.display().to_string(),
hook_event_name: "Interrupt".to_string(),
model,
permission_mode,
}) {
Ok(input_json) => input_json,
Err(error) => {
return InterruptOutcome {
hook_events: common::serialization_failure_hook_events(
matched,
Some(turn_id),
format!("failed to serialize interrupt hook input: {error}"),
),
};
}
};
let results = dispatcher::execute_handlers(
engine,
matched,
input_json,
cwd.as_path(),
Some(turn_id),
parse_completed,
)
.await;
InterruptOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: HandlerRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<InterruptHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
match run_result.error.as_deref() {
Some(error) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.to_string(),
});
}
None => match run_result.exit_code {
Some(0) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) = output_parser::parse_interrupt(&run_result.stdout) {
if let Some(system_message) = parsed.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: system_message,
});
}
} else {
status = HookRunStatus::Failed;
let text = if output_parser::looks_like_json(&run_result.stdout) {
"hook returned invalid interrupt hook JSON output"
} else {
"Interrupt hook returned non-JSON stdout"
};
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: text.to_string(),
});
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited without a status code".to_string(),
});
}
},
}
let completed = HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
};
dispatcher::ParsedHandler {
completed,
data: InterruptHandlerData,
completion_order: 0,
}
}
#[cfg(test)]
#[path = "interrupt_tests.rs"]
mod tests;
@@ -0,0 +1,127 @@
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use super::InterruptHandlerData;
use super::parse_completed;
use crate::engine::ConfiguredHandler;
use crate::engine::ConfiguredHandlerKind;
use crate::engine::HandlerRunResult;
#[test]
fn empty_stdout_succeeds() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), "", ""),
/*turn_id*/ None,
);
assert_eq!(parsed.data, InterruptHandlerData);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert!(parsed.completed.run.entries.is_empty());
}
#[test]
fn system_message_becomes_warning() {
let parsed = parse(r#"{"systemMessage":"watch the tide"}"#, Some(0));
assert_eq!(parsed.data, InterruptHandlerData);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert_eq!(
parsed.completed.run.entries,
vec![warning("watch the tide")]
);
}
#[test]
fn invalid_json_outputs_fail() {
for stdout in [
r#"{"continue":true}"#,
r#"{"stopReason":null}"#,
r#"{"suppressOutput":false}"#,
r#"{"decision":"block"}"#,
r#"{"systemMessage":"watch the tide""#,
] {
assert_failed(
stdout,
Some(0),
"hook returned invalid interrupt hook JSON output",
);
}
}
#[test]
fn other_failures_use_standard_errors() {
for (exit_code, stdout, expected) in [
(Some(0), "aloha", "Interrupt hook returned non-JSON stdout"),
(Some(2), "", "hook exited with code 2"),
] {
assert_failed(stdout, exit_code, expected);
}
}
fn assert_failed(stdout: &str, exit_code: Option<i32>, expected: &str) {
let parsed = parse(stdout, exit_code);
assert_eq!(parsed.data, InterruptHandlerData);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(parsed.completed.run.entries, vec![error(expected)]);
}
fn parse(
stdout: &str,
exit_code: Option<i32>,
) -> crate::engine::dispatcher::ParsedHandler<InterruptHandlerData> {
parse_completed(
&handler(),
run_result(exit_code, stdout, "ignored"),
Some("turn-1".to_string()),
)
}
fn warning(text: &str) -> HookOutputEntry {
HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: text.to_string(),
}
}
fn error(text: &str) -> HookOutputEntry {
HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: text.to_string(),
}
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::Interrupt,
matcher: None,
timeout_sec: 600,
status_message: None,
additional_context_limit: Default::default(),
source_path: test_path_buf("/tmp/hooks.json").abs().into(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
kind: ConfiguredHandlerKind::Command {
command: "echo hook".to_string(),
r#async: false,
env: std::collections::HashMap::new(),
},
}
}
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> HandlerRunResult {
HandlerRunResult {
started_at: 1,
completed_at: 2,
duration_ms: 1,
exit_code,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
error: None,
}
}
+1
View File
@@ -1,5 +1,6 @@
pub(crate) mod common;
pub mod compact;
pub mod interrupt;
pub mod permission_request;
pub mod post_tool_use;
pub mod pre_tool_use;