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
@@ -0,0 +1,55 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"NullableString": {
"type": [
"string",
"null"
]
}
},
"properties": {
"cwd": {
"type": "string"
},
"hook_event_name": {
"const": "Interrupt",
"type": "string"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"session_id": {
"type": "string"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
},
"turn_id": {
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
"type": "string"
}
},
"required": [
"cwd",
"hook_event_name",
"model",
"permission_mode",
"session_id",
"transcript_path",
"turn_id"
],
"title": "interrupt.command.input",
"type": "object"
}
@@ -0,0 +1,12 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"systemMessage": {
"default": null,
"type": "string"
}
},
"title": "interrupt.command.output",
"type": "object"
}
+169 -23
View File
@@ -17,6 +17,7 @@ use codex_config::RequirementSource;
use codex_config::TomlValue;
use codex_config::version_for_toml;
use codex_plugin::PluginHookSource;
use codex_protocol::protocol::HookEventName;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
@@ -25,6 +26,7 @@ use super::ConfiguredHandler;
use super::ConfiguredHandlerKind;
use super::HookListEntry;
use super::HookListEntryHandler;
use super::dispatcher::hook_event_name_label;
use crate::config_rules::hook_states_from_stack;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
@@ -525,11 +527,11 @@ fn append_matcher_groups(
source.path.as_path(),
warnings,
);
let runs_async = r#async
&& event_name != codex_protocol::protocol::HookEventName::SessionEnd;
let runs_async = r#async && event_name != HookEventName::SessionEnd;
if r#async && !runs_async {
warnings.push(format!(
"running async SessionEnd hook synchronously in {}",
"running async {} hook synchronously in {}",
hook_event_name_label(event_name),
source.path.display()
));
}
@@ -583,11 +585,12 @@ fn append_matcher_groups(
timeout_sec,
status_message,
} => {
if event_name == codex_protocol::protocol::HookEventName::SessionEnd {
if event_name == HookEventName::SessionEnd {
source.record_load_failure(
format!(
"skipping MCP tool hook in {}: SessionEnd MCP hooks are not supported",
source.path.display()
"skipping MCP tool hook in {}: {} MCP hooks are not supported",
source.path.display(),
hook_event_name_label(event_name),
),
warnings,
);
@@ -603,7 +606,12 @@ fn append_matcher_groups(
);
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
let timeout_sec = normalize_command_hook(
event_name,
timeout_sec,
source.path.as_path(),
warnings,
);
let config = HookHandlerConfig::McpTool {
server: server.clone(),
tool: tool.clone(),
@@ -716,28 +724,30 @@ fn append_matcher_groups(
}
}
/// Normalizes command-hook timeouts. SessionEnd defaults to one second and is capped at three
/// seconds; all other command hooks keep the standard ten-minute default.
/// Normalizes hook timeouts. SessionEnd and Interrupt default to one second and are capped at three
/// seconds; all other hooks keep the standard ten-minute default.
fn normalize_command_hook(
event_name: codex_protocol::protocol::HookEventName,
event_name: HookEventName,
timeout_sec: Option<u64>,
source_path: &Path,
warnings: &mut Vec<String>,
) -> u64 {
if event_name != codex_protocol::protocol::HookEventName::SessionEnd {
return timeout_sec.unwrap_or(600).max(1);
match event_name {
HookEventName::SessionEnd | HookEventName::Interrupt => {
let max_timeout_sec = SESSION_END_MAX_TIMEOUT_SEC;
if timeout_sec.is_some_and(|timeout_sec| timeout_sec > max_timeout_sec) {
warnings.push(format!(
"clamping {} hook timeout to {max_timeout_sec}s in {}",
hook_event_name_label(event_name),
source_path.display()
));
}
timeout_sec
.unwrap_or(SESSION_END_DEFAULT_TIMEOUT_SEC)
.clamp(1, max_timeout_sec)
}
_ => timeout_sec.unwrap_or(600).max(1),
}
let max_timeout_sec = SESSION_END_MAX_TIMEOUT_SEC;
if timeout_sec.is_some_and(|timeout_sec| timeout_sec > max_timeout_sec) {
warnings.push(format!(
"clamping SessionEnd hook timeout to {max_timeout_sec}s in {}",
source_path.display()
));
}
timeout_sec
.unwrap_or(SESSION_END_DEFAULT_TIMEOUT_SEC)
.clamp(1, max_timeout_sec)
}
/// Hash a normalized, config-derived identity instead of source text so equivalent
@@ -841,6 +851,7 @@ mod tests {
use codex_config::HookEventsToml;
use codex_config::RequirementSource;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookExecutionMode;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
@@ -852,6 +863,7 @@ mod tests {
use super::HookListEntry;
use super::HookListEntryHandler;
use super::append_matcher_groups;
use super::normalize_command_hook;
use crate::output_spill::AdditionalContextLimit;
use crate::output_spill::DEFAULT_HOOK_OUTPUT_TOKEN_LIMIT;
use codex_config::HookHandlerConfig;
@@ -1053,6 +1065,71 @@ mod tests {
);
}
#[test]
fn interrupt_mcp_tool_hooks_are_supported_and_timeout_is_clamped() {
let source_path = source_path();
let hook_states = std::collections::HashMap::new();
let mut handlers = Vec::new();
let mut entries = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_matcher_groups(
&mut handlers,
&mut entries,
&mut warnings,
&mut display_order,
&mut hook_handler_source(&source_path, &hook_states),
HookEventName::Interrupt,
vec![MatcherGroup {
matcher: None,
hooks: vec![
HookHandlerConfig::McpTool {
server: "security".to_string(),
tool: "scan".to_string(),
input: serde_json::Map::new(),
timeout_sec: None,
status_message: None,
},
HookHandlerConfig::McpTool {
server: "security".to_string(),
tool: "report".to_string(),
input: serde_json::Map::new(),
timeout_sec: Some(600),
status_message: None,
},
],
}],
);
assert_eq!(
handlers
.iter()
.map(|handler| handler.timeout_sec)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert_eq!(
entries
.iter()
.map(|entry| entry.timeout_sec)
.collect::<Vec<_>>(),
vec![1, 3]
);
assert!(
entries
.iter()
.all(|entry| matches!(entry.handler, HookListEntryHandler::McpTool { .. }))
);
assert_eq!(
warnings,
vec![format!(
"clamping Interrupt hook timeout to 3s in {}",
source_path.display()
)]
);
}
fn discover_command(
event_name: HookEventName,
additional_context_limit: Option<usize>,
@@ -1308,6 +1385,75 @@ mod tests {
);
}
#[test]
fn interrupt_normalizes_timeout_and_supports_async_execution() {
let mut handlers = Vec::new();
let mut hook_entries = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
let source_path = source_path();
let hook_states = std::collections::HashMap::new();
append_matcher_groups(
&mut handlers,
&mut hook_entries,
&mut warnings,
&mut display_order,
&mut hook_handler_source(&source_path, &hook_states),
HookEventName::Interrupt,
vec![MatcherGroup {
matcher: Some("ignored".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "echo interrupt".to_string(),
command_windows: None,
timeout_sec: Some(600),
r#async: true,
status_message: None,
additional_context_limit: None,
}],
}],
);
assert_eq!(
normalize_command_hook(
HookEventName::Interrupt,
/*timeout_sec*/ None,
source_path.as_path(),
&mut Vec::new(),
),
1
);
assert_eq!(
handlers
.iter()
.map(|handler| (
handler.timeout_sec,
handler.matcher.as_deref(),
handler.execution_mode()
))
.collect::<Vec<_>>(),
vec![(3, None, HookExecutionMode::Async)]
);
assert_eq!(
hook_entries
.iter()
.map(|entry| (entry.timeout_sec, entry.matcher.as_deref()))
.collect::<Vec<_>>(),
vec![(3, None)]
);
assert!(hook_entries.iter().all(|entry| matches!(
entry.handler,
HookListEntryHandler::Command { r#async: true, .. }
)));
assert_eq!(
warnings,
vec![format!(
"clamping Interrupt hook timeout to 3s in {}",
source_path.display()
)]
);
}
#[test]
fn bypass_hook_trust_allows_enabled_untrusted_handlers() {
let mut handlers = Vec::new();
+34 -2
View File
@@ -67,7 +67,9 @@ pub(crate) fn select_handlers_for_matcher_inputs(
.any(|input| matches_matcher(handler.matcher.as_deref(), Some(input)))
}
}
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
HookEventName::UserPromptSubmit | HookEventName::Stop | HookEventName::Interrupt => {
true
}
})
.cloned()
.collect()
@@ -261,7 +263,8 @@ pub(crate) fn scope_for_event(event_name: HookEventName) -> HookScope {
| HookEventName::PostCompact
| HookEventName::UserPromptSubmit
| HookEventName::SubagentStop
| HookEventName::Stop => HookScope::Turn,
| HookEventName::Stop
| HookEventName::Interrupt => HookScope::Turn,
}
}
@@ -278,6 +281,7 @@ pub(crate) fn hook_event_name_label(event_name: HookEventName) -> &'static str {
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
HookEventName::Stop => "Stop",
HookEventName::Interrupt => "Interrupt",
}
}
@@ -384,6 +388,34 @@ mod tests {
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn select_handlers_ignores_interrupt_matchers() {
let handlers = vec![
make_handler(
HookEventName::Interrupt,
Some("^interrupted$"),
"echo first",
/*display_order*/ 0,
),
make_handler(
HookEventName::Interrupt,
/*matcher*/ None,
"echo second",
/*display_order*/ 1,
),
];
let selected = select_handlers(
&handlers,
HookEventName::Interrupt,
/*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![
+11
View File
@@ -9,6 +9,8 @@ use crate::events::compact::PostCompactRequest;
use crate::events::compact::PreCompactOutcome;
use crate::events::compact::PreCompactRequest;
use crate::events::compact::StatelessHookOutcome;
use crate::events::interrupt::InterruptOutcome;
use crate::events::interrupt::InterruptRequest;
use crate::events::permission_request::PermissionRequestOutcome;
use crate::events::permission_request::PermissionRequestRequest;
use crate::events::post_tool_use::PostToolUseOutcome;
@@ -169,6 +171,7 @@ impl ConfiguredHandler {
codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start",
codex_protocol::protocol::HookEventName::SubagentStop => "subagent-stop",
codex_protocol::protocol::HookEventName::Stop => "stop",
codex_protocol::protocol::HookEventName::Interrupt => "interrupt",
}
}
@@ -452,6 +455,14 @@ impl ClaudeHooksEngine {
.await;
outcome
}
pub(crate) fn preview_interrupt(&self) -> Vec<HookRunSummary> {
crate::events::interrupt::preview(&self.handlers)
}
pub(crate) async fn run_interrupt(&self, request: InterruptRequest) -> InterruptOutcome {
crate::events::interrupt::run(self, request).await
}
}
#[cfg(test)]
+104
View File
@@ -45,6 +45,7 @@ use super::ConfiguredHandler;
use super::ConfiguredHandlerKind;
use super::HandlerSourcePath;
use super::HookListEntryHandler;
use crate::events::interrupt::InterruptRequest;
use crate::events::pre_tool_use::PreToolUseRequest;
use crate::events::stop::StopHookTarget;
use crate::events::stop::StopRequest;
@@ -2359,3 +2360,106 @@ async fn mcp_tool_hooks_expand_event_input_and_apply_pre_tool_decisions() {
}]
);
}
#[tokio::test]
async fn mcp_interrupt_hooks_expand_event_input_and_bound_timeout() {
let temp = tempdir().expect("create temp dir");
let config_path =
AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path");
fs::write(
temp.path().join("hooks.json"),
serde_json::json!({
"hooks": {
"Interrupt": [{
"hooks": [{
"type": "mcp_tool",
"server": "security",
"tool": "notify",
"input": {
"event": "${hook_event_name}",
"turn_id": "${turn_id}",
"permission_mode": "${permission_mode}",
},
"timeout": 20,
}],
}],
},
})
.to_string(),
)
.expect("write MCP Interrupt hooks.json");
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_path,
profile: None,
},
TomlValue::Table(Default::default()),
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack");
let calls = Arc::new(Mutex::new(Vec::new()));
let executor = StaticMcpExecutor {
calls: Arc::clone(&calls),
output: serde_json::json!({
"systemMessage": "interrupt observed",
})
.to_string(),
outputs_by_tool: HashMap::new(),
};
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*bypass_hook_trust*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
command_runtime(CommandShell {
program: String::new(),
args: Vec::new(),
}),
Arc::new(executor),
);
let outcome = engine
.run_interrupt(InterruptRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
})
.await;
assert_eq!(outcome.hook_events.len(), 1);
assert_eq!(
outcome.hook_events[0].run.handler_type,
HookHandlerType::McpTool
);
assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Completed);
assert_eq!(
outcome.hook_events[0].run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: "interrupt observed".to_string(),
}]
);
assert_eq!(
*calls.lock().expect("lock MCP calls"),
vec![HookMcpCall {
server: "security".to_string(),
tool: "notify".to_string(),
environment_id: None,
metadata: None,
input: serde_json::from_value(serde_json::json!({
"event": "Interrupt",
"turn_id": "turn-1",
"permission_mode": "default",
}))
.expect("object input"),
timeout: Duration::from_secs(3),
}]
);
}
@@ -67,8 +67,14 @@ pub(crate) struct StatelessHookOutput {
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct InterruptOutput {
pub system_message: Option<String>,
}
use crate::schema::BlockDecisionWire;
use crate::schema::HookUniversalOutputWire;
use crate::schema::InterruptCommandOutputWire;
use crate::schema::PermissionRequestBehaviorWire;
use crate::schema::PermissionRequestCommandOutputWire;
use crate::schema::PermissionRequestDecisionWire;
@@ -250,6 +256,13 @@ pub(crate) fn parse_post_compact(stdout: &str) -> Option<StatelessHookOutput> {
})
}
pub(crate) fn parse_interrupt(stdout: &str) -> Option<InterruptOutput> {
let wire: InterruptCommandOutputWire = parse_json(stdout)?;
Some(InterruptOutput {
system_message: wire.system_message,
})
}
pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitOutput> {
let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
@@ -25,6 +25,8 @@ pub(crate) struct GeneratedHookSchemas {
pub user_prompt_submit_command_output: Value,
pub stop_command_input: Value,
pub stop_command_output: Value,
pub interrupt_command_input: Value,
pub interrupt_command_output: Value,
}
pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
@@ -114,6 +116,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
"stop.command.output",
include_str!("../../schema/generated/stop.command.output.schema.json"),
),
interrupt_command_input: parse_json_schema(
"interrupt.command.input",
include_str!("../../schema/generated/interrupt.command.input.schema.json"),
),
interrupt_command_output: parse_json_schema(
"interrupt.command.output",
include_str!("../../schema/generated/interrupt.command.output.schema.json"),
),
})
}
@@ -152,5 +162,7 @@ mod tests {
assert_eq!(schemas.user_prompt_submit_command_output["type"], "object");
assert_eq!(schemas.stop_command_input["type"], "object");
assert_eq!(schemas.stop_command_output["type"], "object");
assert_eq!(schemas.interrupt_command_input["type"], "object");
assert_eq!(schemas.interrupt_command_output["type"], "object");
}
}
+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;
+5 -1
View File
@@ -20,7 +20,7 @@ pub use engine::dispatcher::hook_execution_mode_label;
pub use engine::dispatcher::hook_handler_type_label;
pub use events::common::SubagentHookContext;
/// Hook event names as they appear in hooks JSON and config files.
pub const HOOK_EVENT_NAMES: [&str; 11] = [
pub const HOOK_EVENT_NAMES: [&str; 12] = [
"PreToolUse",
"PermissionRequest",
"PostToolUse",
@@ -32,6 +32,7 @@ pub const HOOK_EVENT_NAMES: [&str; 11] = [
"SubagentStart",
"SubagentStop",
"Stop",
"Interrupt",
];
/// Hook event names whose matcher fields are meaningful during dispatch.
@@ -55,6 +56,8 @@ pub use events::compact::PostCompactRequest;
pub use events::compact::PreCompactOutcome;
pub use events::compact::PreCompactRequest;
pub use events::compact::StatelessHookOutcome;
pub use events::interrupt::InterruptOutcome;
pub use events::interrupt::InterruptRequest;
pub use events::permission_request::PermissionRequestDecision;
pub use events::permission_request::PermissionRequestOutcome;
pub use events::permission_request::PermissionRequestRequest;
@@ -102,6 +105,7 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str {
HookEventName::SubagentStart => "subagent_start",
HookEventName::SubagentStop => "subagent_stop",
HookEventName::Stop => "stop",
HookEventName::Interrupt => "interrupt",
}
}
+10
View File
@@ -6,6 +6,8 @@ use crate::events::compact::PostCompactRequest;
use crate::events::compact::PreCompactOutcome;
use crate::events::compact::PreCompactRequest;
use crate::events::compact::StatelessHookOutcome;
use crate::events::interrupt::InterruptOutcome;
use crate::events::interrupt::InterruptRequest;
use crate::events::permission_request::PermissionRequestOutcome;
use crate::events::permission_request::PermissionRequestRequest;
use crate::events::post_tool_use::PostToolUseOutcome;
@@ -276,6 +278,14 @@ impl Hooks {
pub async fn run_session_end(&self, request: SessionEndRequest) -> SessionEndOutcome {
self.engine.run_session_end(request).await
}
pub fn preview_interrupt(&self) -> Vec<codex_protocol::protocol::HookRunSummary> {
self.engine.preview_interrupt()
}
pub async fn run_interrupt(&self, request: InterruptRequest) -> InterruptOutcome {
self.engine.run_interrupt(request).await
}
}
pub fn list_hooks(config: HooksConfig) -> HookListOutcome {
+57
View File
@@ -36,6 +36,8 @@ const SUBAGENT_STOP_INPUT_FIXTURE: &str = "subagent-stop.command.input.schema.js
const SUBAGENT_STOP_OUTPUT_FIXTURE: &str = "subagent-stop.command.output.schema.json";
const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json";
const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json";
const INTERRUPT_INPUT_FIXTURE: &str = "interrupt.command.input.schema.json";
const INTERRUPT_OUTPUT_FIXTURE: &str = "interrupt.command.output.schema.json";
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
@@ -118,6 +120,8 @@ pub(crate) enum HookEventNameWire {
SubagentStop,
#[serde(rename = "Stop")]
Stop,
#[serde(rename = "Interrupt")]
Interrupt,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -474,6 +478,15 @@ pub(crate) struct SubagentStopCommandOutputWire {
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "interrupt.command.output")]
pub(crate) struct InterruptCommandOutputWire {
#[serde(default)]
pub system_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum BlockDecisionWire {
#[serde(rename = "block")]
@@ -608,6 +621,22 @@ pub(crate) struct SubagentStopCommandInput {
pub last_assistant_message: NullableString,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "interrupt.command.input")]
pub(crate) struct InterruptCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "interrupt_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
}
pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
let generated_dir = schema_root.join(GENERATED_DIR);
ensure_empty_dir(&generated_dir)?;
@@ -696,6 +725,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
&generated_dir.join(STOP_OUTPUT_FIXTURE),
schema_json::<StopCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(INTERRUPT_INPUT_FIXTURE),
schema_json::<InterruptCommandInput>()?,
)?;
write_schema(
&generated_dir.join(INTERRUPT_OUTPUT_FIXTURE),
schema_json::<InterruptCommandOutputWire>()?,
)?;
Ok(())
}
@@ -799,6 +836,10 @@ fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Stop")
}
fn interrupt_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Interrupt")
}
fn permission_mode_schema(_gen: &mut SchemaGenerator) -> Schema {
string_enum_schema(&[
"default",
@@ -846,6 +887,9 @@ fn default_continue() -> bool {
#[cfg(test)]
mod tests {
use super::INTERRUPT_INPUT_FIXTURE;
use super::INTERRUPT_OUTPUT_FIXTURE;
use super::InterruptCommandInput;
use super::NullableString;
use super::PERMISSION_REQUEST_INPUT_FIXTURE;
use super::PERMISSION_REQUEST_OUTPUT_FIXTURE;
@@ -958,6 +1002,12 @@ mod tests {
STOP_OUTPUT_FIXTURE => {
include_str!("../schema/generated/stop.command.output.schema.json")
}
INTERRUPT_INPUT_FIXTURE => {
include_str!("../schema/generated/interrupt.command.input.schema.json")
}
INTERRUPT_OUTPUT_FIXTURE => {
include_str!("../schema/generated/interrupt.command.output.schema.json")
}
_ => panic!("unexpected fixture name: {name}"),
}
}
@@ -1008,6 +1058,8 @@ mod tests {
SUBAGENT_STOP_OUTPUT_FIXTURE,
STOP_INPUT_FIXTURE,
STOP_OUTPUT_FIXTURE,
INTERRUPT_INPUT_FIXTURE,
INTERRUPT_OUTPUT_FIXTURE,
] {
let expected = normalize_newlines(expected_fixture(fixture));
let actual = std::fs::read_to_string(schema_root.join("generated").join(fixture))
@@ -1090,6 +1142,10 @@ mod tests {
&schema_json::<StopCommandInput>().expect("serialize stop input schema"),
)
.expect("parse stop input schema");
let interrupt: Value = serde_json::from_slice(
&schema_json::<InterruptCommandInput>().expect("serialize interrupt input schema"),
)
.expect("parse interrupt input schema");
for schema in [
&pre_tool_use,
@@ -1101,6 +1157,7 @@ mod tests {
&subagent_start,
&subagent_stop,
&stop,
&interrupt,
] {
assert_eq!(schema["properties"]["turn_id"]["type"], "string");
assert!(