mirror of
https://github.com/openai/codex.git
synced 2026-10-28 00:03:58 +00:00
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:
committed by
copyberry
parent
9b2ef38f54
commit
cbfd999db7
@@ -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]
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
Reference in New Issue
Block a user