diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index dd6b44bbd3..0613171f4d 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1406,6 +1406,7 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", + HookEventName::Interrupt => "Interrupt", } } diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 50dc26a8eb..9228363870 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2098,7 +2098,8 @@ "userPromptSubmit", "subagentStart", "subagentStop", - "stop" + "stop", + "interrupt" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 55d8b4749b..c5fef02315 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12463,7 +12463,8 @@ "userPromptSubmit", "subagentStart", "subagentStop", - "stop" + "stop", + "interrupt" ], "type": "string" }, @@ -13586,6 +13587,13 @@ }, "ManagedHooksRequirements": { "properties": { + "Interrupt": { + "default": [], + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PermissionRequest": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5d6b9441a8..c104d34eba 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8552,7 +8552,8 @@ "userPromptSubmit", "subagentStart", "subagentStop", - "stop" + "stop", + "interrupt" ], "type": "string" }, @@ -9736,6 +9737,13 @@ }, "ManagedHooksRequirements": { "properties": { + "Interrupt": { + "default": [], + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PermissionRequest": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index b19e72c0db..f61c4ad2b2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -720,6 +720,13 @@ }, "ManagedHooksRequirements": { "properties": { + "Interrupt": { + "default": [], + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PermissionRequest": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 638e4886a7..ad1681a6e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -17,7 +17,8 @@ "userPromptSubmit", "subagentStart", "subagentStop", - "stop" + "stop", + "interrupt" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index a6d1955b60..0f11b12c0c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -17,7 +17,8 @@ "userPromptSubmit", "subagentStart", "subagentStop", - "stop" + "stop", + "interrupt" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index 9ccf201a19..837e0b2660 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -32,7 +32,8 @@ "userPromptSubmit", "subagentStart", "subagentStop", - "stop" + "stop", + "interrupt" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 572d6def09..71d810b707 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -120,7 +120,8 @@ "userPromptSubmit", "subagentStart", "subagentStop", - "stop" + "stop", + "interrupt" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 1404530dda..68253591d5 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index e95edd35a3..003d09c288 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index ae8a7f389f..5425272c30 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop" | "interrupt"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index 6d49d5f0c5..777a5319e2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, SessionEnd: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, SessionEnd: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, Interrupt: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 2e1fe1f564..e36240c4ac 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -589,6 +589,9 @@ pub struct ManagedHooksRequirements { #[serde(rename = "Stop")] #[ts(rename = "Stop")] pub stop: Vec, + #[serde(rename = "Interrupt", default)] + #[ts(rename = "Interrupt")] + pub interrupt: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index 41e34a3f8f..b2f944e1ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -17,7 +17,7 @@ use serde::Serialize; v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, UserPromptSubmit, SubagentStart, SubagentStop, Stop + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, UserPromptSubmit, SubagentStart, SubagentStop, Stop, Interrupt } ); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index c031207c01..f6c2788727 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -72,6 +72,30 @@ fn test_absolute_path() -> AbsolutePathBuf { absolute_path("readable") } +#[test] +fn managed_hooks_requirements_default_interrupt_to_empty() { + let value = json!({ + "managedDir": null, + "windowsManagedDir": null, + "PreToolUse": [], + "PermissionRequest": [], + "PostToolUse": [], + "PreCompact": [], + "PostCompact": [], + "SessionStart": [], + "SessionEnd": [], + "UserPromptSubmit": [], + "SubagentStart": [], + "SubagentStop": [], + "Stop": [] + }); + + let parsed: ManagedHooksRequirements = + serde_json::from_value(value).expect("deserialize managed hooks requirements"); + + assert_eq!(parsed.interrupt, Vec::new()); +} + #[test] fn external_agent_config_detect_response_defaults_connectors_for_older_servers() { let response = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 88b0ce098f..81893abab4 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -597,6 +597,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed subagent_start, subagent_stop, stop, + interrupt, } = hooks; ManagedHooksRequirements { @@ -613,6 +614,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed subagent_start: map_hook_matcher_groups_to_api(subagent_start), subagent_stop: map_hook_matcher_groups_to_api(subagent_stop), stop: map_hook_matcher_groups_to_api(stop), + interrupt: map_hook_matcher_groups_to_api(interrupt), } } diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 9716db0362..7f2d61bc47 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -56,6 +56,8 @@ pub struct HookEventsToml { pub subagent_stop: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, + #[serde(rename = "Interrupt", default)] + pub interrupt: Vec, } impl HookEventsToml { @@ -72,6 +74,7 @@ impl HookEventsToml { subagent_start, subagent_stop, stop, + interrupt, } = self; pre_tool_use.is_empty() && permission_request.is_empty() @@ -84,6 +87,7 @@ impl HookEventsToml { && subagent_start.is_empty() && subagent_stop.is_empty() && stop.is_empty() + && interrupt.is_empty() } pub fn handler_count(&self) -> usize { @@ -99,6 +103,7 @@ impl HookEventsToml { subagent_start, subagent_stop, stop, + interrupt, } = self; [ pre_tool_use, @@ -112,6 +117,7 @@ impl HookEventsToml { subagent_start, subagent_stop, stop, + interrupt, ] .into_iter() .flatten() @@ -119,7 +125,7 @@ impl HookEventsToml { .sum() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 11] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 12] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), @@ -132,6 +138,7 @@ impl HookEventsToml { (HookEventName::SubagentStart, self.subagent_start), (HookEventName::SubagentStop, self.subagent_stop), (HookEventName::Stop, self.stop), + (HookEventName::Interrupt, self.interrupt), ] } } diff --git a/codex-rs/config/src/requirements_layers/hooks.rs b/codex-rs/config/src/requirements_layers/hooks.rs index 10b6c1c09c..01ed3eab47 100644 --- a/codex-rs/config/src/requirements_layers/hooks.rs +++ b/codex-rs/config/src/requirements_layers/hooks.rs @@ -215,6 +215,7 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) - subagent_start, subagent_stop, stop, + interrupt, } = incoming; let mut changed = false; @@ -229,6 +230,7 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) - changed |= append_vec(&mut existing.subagent_start, subagent_start); changed |= append_vec(&mut existing.subagent_stop, subagent_stop); changed |= append_vec(&mut existing.stop, stop); + changed |= append_vec(&mut existing.interrupt, interrupt); changed } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index d419c2b13d..42b9991e9a 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1678,6 +1678,13 @@ }, "HooksToml": { "properties": { + "Interrupt": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "PermissionRequest": { "default": [], "items": { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index ba8c1227e0..3a586260ab 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -6,6 +6,7 @@ use codex_analytics::CompactionTrigger; use codex_analytics::HookRunFact; use codex_analytics::build_track_events_context; use codex_core_plugins::executor_plugin_hook_sources; +use codex_hooks::InterruptRequest; use codex_hooks::PermissionRequestDecision; use codex_hooks::PermissionRequestOutcome; use codex_hooks::PermissionRequestRequest; @@ -427,6 +428,35 @@ pub(crate) async fn run_session_end_hooks(sess: &Arc) { emit_hook_completed_events(sess, &turn_context, outcome.hook_events).await; } +pub(crate) async fn run_turn_interrupt_hooks(sess: &Arc, turn_context: &Arc) { + if matches!(&turn_context.session_source, SessionSource::SubAgent(_)) { + return; + } + + let hooks = sess.hooks(); + let preview_runs = hooks.preview_interrupt(); + if preview_runs.is_empty() { + return; + } + + let request = InterruptRequest { + session_id: sess.session_id().into(), + turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] + cwd: turn_context.cwd.clone(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + }; + if let Err(err) = sess.flush_rollout().await { + tracing::warn!("failed to flush transcript before Interrupt hook: {err}"); + } + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let outcome = hooks.run_interrupt(request).await; + emit_hook_completed_events(sess, turn_context, outcome.hook_events).await; +} + pub(crate) async fn run_pre_compact_hooks( sess: &Arc, turn_context: &Arc, @@ -841,6 +871,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", + HookEventName::Interrupt => "Interrupt", }; let hook_source = match run.source { HookSource::System => "system", diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index d4cafdc80b..f8fae7e986 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -26,6 +26,7 @@ use tracing::warn; use crate::codex_thread::BackgroundTerminalInfo; use crate::config::Config; use crate::context::ContextualUserFragment; +use crate::hook_runtime::run_turn_interrupt_hooks; use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn::run_hooks_and_record_inputs; @@ -791,6 +792,9 @@ impl Session { ThreadIdleCause::Completed }; let event = if let Some(reason) = abort_reason { + if reason == TurnAbortReason::Interrupted { + run_turn_interrupt_hooks(self, &turn_context).await; + } self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) .await; EventMsg::TurnAborted(TurnAbortedEvent { @@ -941,6 +945,10 @@ impl Session { } } + if reason == TurnAbortReason::Interrupted { + run_turn_interrupt_hooks(self, &task.turn_context).await; + } + let started_at = task .turn_context .turn_timing_state diff --git a/codex-rs/core/tests/suite/interrupt_hooks.rs b/codex-rs/core/tests/suite/interrupt_hooks.rs new file mode 100644 index 0000000000..5f39f6884c --- /dev/null +++ b/codex-rs/core/tests/suite/interrupt_hooks.rs @@ -0,0 +1,441 @@ +use std::fs; +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use codex_core::TurnInputRequest; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::user_input::UserInput; +use core_test_support::fs_wait; +use core_test_support::hooks::trust_discovered_hooks; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_wine_exec; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tokio::time::timeout; +use wiremock::MockServer; + +fn write_interrupt_hook(home: &Path, system_message: Option<&str>) -> Result<()> { + let script_path = home.join("interrupt_hook.py"); + let log_path = home.join("interrupt_hook_log.jsonl"); + let transcript_snapshot_path = home.join("interrupt_transcript_snapshot.jsonl"); + let system_message_json = + serde_json::to_string(&system_message).context("serialize interrupt hook message")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +transcript_path = payload.get("transcript_path") +if transcript_path is not None: + snapshot = Path(transcript_path).read_text(encoding="utf-8") + Path(r"{transcript_snapshot_path}").write_text(snapshot, encoding="utf-8") + +message = json.loads({system_message_json:?}) +if message is not None: + print(json.dumps({{"systemMessage": message}})) +"#, + log_path = log_path.display(), + transcript_snapshot_path = transcript_snapshot_path.display(), + system_message_json = system_message_json, + ); + let hooks = json!({ + "hooks": { + "Interrupt": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running interrupt hook", + }] + }] + } + }); + + fs::write(&script_path, script).context("write interrupt hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + +fn read_interrupt_hook_inputs(home: &Path) -> Result> { + fs::read_to_string(home.join("interrupt_hook_log.jsonl")) + .context("read interrupt hook log")? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("parse interrupt hook log line")) + .collect() +} + +async fn build_test(server: &MockServer, system_message: Option<&str>) -> Result { + let system_message = system_message.map(str::to_string); + test_codex() + .with_model("gpt-5.4") + .with_pre_build_hook(move |home| { + write_interrupt_hook(home, system_message.as_deref()) + .unwrap_or_else(|error| panic!("failed to write interrupt hook fixture: {error}")); + }) + .with_config(|config| { + trust_discovered_hooks(config); + config.agent_interrupt_message_enabled = false; + }) + .build_with_auto_env(server) + .await +} + +async fn start_interruptible_turn(test: &TestCodex, server: &MockServer) -> Result<()> { + let tool_args = json!({ + "cmd": "sleep 60", + "yield_time_ms": 60_000, + }) + .to_string(); + _ = mount_sse_once( + server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call("call-1", "exec_command", &tool_args), + ev_completed("resp-1"), + ]), + ) + .await; + + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "interrupt me".to_string(), + text_elements: Vec::new(), + }])) + .await?; + let _ = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ExecCommandBegin(begin) => Some(begin.clone()), + _ => None, + }) + .await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interrupt_hook_runs_before_turn_aborted_and_records_payload() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "command hooks currently require a host-native working directory" + ); + + let server = start_mock_server().await; + let test = build_test(&server, Some("watch the tide")).await?; + start_interruptible_turn(&test, &server).await?; + + test.codex.submit(Op::Interrupt).await?; + + let started = wait_for_event_match(&test.codex, |event| match event { + EventMsg::HookStarted(started) if started.run.event_name == HookEventName::Interrupt => { + Some(started.clone()) + } + _ => None, + }) + .await; + let completed = wait_for_event_match(&test.codex, |event| match event { + EventMsg::HookCompleted(completed) + if completed.run.event_name == HookEventName::Interrupt => + { + Some(completed.clone()) + } + _ => None, + }) + .await; + let _aborted = wait_for_event_match(&test.codex, |event| match event { + EventMsg::TurnAborted(aborted) if aborted.reason == TurnAbortReason::Interrupted => { + Some(aborted.clone()) + } + _ => None, + }) + .await; + + assert_eq!(started.run.event_name, HookEventName::Interrupt); + assert_eq!(completed.run.event_name, HookEventName::Interrupt); + assert_eq!(completed.run.status, HookRunStatus::Completed); + assert_eq!( + completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: "watch the tide".to_string(), + }] + ); + + let hook_inputs = read_interrupt_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 1); + let payload = &hook_inputs[0]; + assert_eq!( + payload.get("hook_event_name"), + Some(&Value::String("Interrupt".to_string())) + ); + assert_eq!( + payload.get("model"), + Some(&Value::String("gpt-5.4".to_string())) + ); + assert!( + payload + .get("turn_id") + .and_then(Value::as_str) + .is_some_and(|turn_id| !turn_id.is_empty()) + ); + assert!( + payload + .get("transcript_path") + .and_then(Value::as_str) + .is_some_and(|path| !path.is_empty()) + ); + assert!(payload.get("last_assistant_message").is_none()); + + let transcript_snapshot = fs::read_to_string( + test.codex_home_path() + .join("interrupt_transcript_snapshot.jsonl"), + )?; + assert!( + transcript_snapshot.contains("interrupt me"), + "the interrupted turn must be durable before the hook reads its transcript", + ); + assert!( + !transcript_snapshot.contains(""), + "disabled interrupt markers should remain absent from the hook transcript", + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn timed_out_interrupt_hook_fails_before_turn_aborted() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "command hooks currently require a host-native working directory" + ); + + let server = start_mock_server().await; + let test = build_test(&server, /*system_message*/ None).await?; + fs::write( + test.codex_home_path().join("interrupt_hook.py"), + "import time\ntime.sleep(60)\n", + )?; + start_interruptible_turn(&test, &server).await?; + + test.codex.submit(Op::Interrupt).await?; + let completed = timeout( + Duration::from_secs(5), + wait_for_event_match(&test.codex, |event| match event { + EventMsg::HookCompleted(completed) + if completed.run.event_name == HookEventName::Interrupt => + { + Some(completed.clone()) + } + _ => None, + }), + ) + .await + .context("interrupt hook should time out promptly")?; + + assert_eq!(completed.run.status, HookRunStatus::Failed); + assert_eq!( + completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "hook timed out after 1s".to_string(), + }] + ); + + timeout( + Duration::from_secs(5), + wait_for_event_match(&test.codex, |event| match event { + EventMsg::TurnAborted(aborted) if aborted.reason == TurnAbortReason::Interrupted => { + Some(aborted.clone()) + } + _ => None, + }), + ) + .await + .context("a timed-out interrupt hook must not prevent TurnAborted")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_interrupt_hook_runs_without_delaying_turn_aborted() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "command hooks currently require a host-native working directory" + ); + + let server = start_mock_server().await; + let test = test_codex() + .with_pre_build_hook(|home| { + write_interrupt_hook(home, Some("async interrupt completed")) + .expect("write interrupt hook fixture"); + let hooks_path = home.join("hooks.json"); + let mut hooks: Value = serde_json::from_str( + &fs::read_to_string(&hooks_path).expect("read interrupt hook configuration"), + ) + .expect("parse interrupt hook configuration"); + hooks["hooks"]["Interrupt"][0]["hooks"][0]["async"] = json!(true); + hooks["hooks"]["Interrupt"][0]["hooks"][0]["timeout"] = json!(3); + fs::write(hooks_path, hooks.to_string()).expect("write async interrupt configuration"); + + let script_path = home.join("interrupt_hook.py"); + let original_script = fs::read_to_string(&script_path).expect("read interrupt script"); + let release_path = serde_json::to_string(&home.join("async_interrupt_release")) + .expect("serialize async interrupt release path"); + let finished_path = serde_json::to_string(&home.join("async_interrupt_finished")) + .expect("serialize async interrupt finished path"); + let gated_script = format!( + "import time\nfrom pathlib import Path\nwhile not Path({release_path}).exists(): time.sleep(0.01)\n{original_script}\nPath({finished_path}).touch()\n" + ); + fs::write(script_path, gated_script).expect("write gated async interrupt script"); + }) + .with_config(trust_discovered_hooks) + .build_with_auto_env(&server) + .await?; + start_interruptible_turn(&test, &server).await?; + + test.codex.submit(Op::Interrupt).await?; + timeout( + Duration::from_secs(5), + wait_for_event_match(&test.codex, |event| match event { + EventMsg::TurnAborted(aborted) if aborted.reason == TurnAbortReason::Interrupted => { + Some(aborted.clone()) + } + _ => None, + }), + ) + .await + .context("an async interrupt hook must not delay TurnAborted")?; + + assert!( + !test + .codex_home_path() + .join("interrupt_hook_log.jsonl") + .exists(), + "the gated async hook must not finish before the turn abort is emitted" + ); + + fs::write( + test.codex_home_path().join("async_interrupt_release"), + "ready", + )?; + fs_wait::wait_for_path_exists( + test.codex_home_path().join("async_interrupt_finished"), + Duration::from_secs(5), + ) + .await + .context("async interrupt hook should finish after the turn abort")?; + assert_eq!(read_interrupt_hook_inputs(test.codex_home_path())?.len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn self_aborted_turn_runs_interrupt_hook_before_turn_aborted() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "command hooks currently require a host-native working directory" + ); + + let server = start_mock_server().await; + let test = test_codex() + .with_model("gpt-5.4") + .with_pre_build_hook(|home| { + write_interrupt_hook(home, Some("compaction was interrupted")) + .expect("write interrupt hook fixture"); + let hooks_path = home.join("hooks.json"); + let mut hooks: Value = serde_json::from_str( + &fs::read_to_string(&hooks_path).expect("read interrupt hook configuration"), + ) + .expect("parse interrupt hook configuration"); + hooks["hooks"]["PreCompact"] = json!([{ + "matcher": "manual", + "hooks": [{ + "type": "command", + "command": r#"python3 -c 'import json; print(json.dumps({"continue": False, "stopReason": "stop compaction"}))'"#, + }] + }]); + fs::write(hooks_path, hooks.to_string()).expect("write pre-compact hook configuration"); + }) + .with_config(trust_discovered_hooks) + .build_with_auto_env(&server) + .await?; + + test.codex.submit(Op::Compact).await?; + let pre_compact = wait_for_event_match(&test.codex, |event| match event { + EventMsg::HookCompleted(completed) + if completed.run.event_name == HookEventName::PreCompact => + { + Some(completed.clone()) + } + _ => None, + }) + .await; + assert_eq!(pre_compact.run.status, HookRunStatus::Stopped); + + let interrupt = wait_for_event_match(&test.codex, |event| match event { + EventMsg::HookCompleted(completed) + if completed.run.event_name == HookEventName::Interrupt => + { + Some(completed.clone()) + } + _ => None, + }) + .await; + assert_eq!(interrupt.run.status, HookRunStatus::Completed); + + let _aborted = wait_for_event_match(&test.codex, |event| match event { + EventMsg::TurnAborted(aborted) if aborted.reason == TurnAbortReason::Interrupted => { + Some(aborted.clone()) + } + _ => None, + }) + .await; + assert_eq!(read_interrupt_hook_inputs(test.codex_home_path())?.len(), 1); + + Ok(()) +} + +#[tokio::test] +async fn startup_interrupt_without_active_turn_does_not_run_interrupt_hook() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let test = build_test(&server, Some("should not run")).await?; + + test.codex.submit(Op::Interrupt).await?; + test.codex.shutdown_and_wait().await?; + + assert!( + !test + .codex_home_path() + .join("interrupt_hook_log.jsonl") + .exists(), + "startup interrupt should not invoke Interrupt hooks without an active turn", + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 52fcac4bc2..47c1b598cf 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -84,6 +84,8 @@ mod hooks_executor; mod hooks_mcp; mod image_rollout; mod injected_models_cache; +#[cfg(not(target_os = "windows"))] +mod interrupt_hooks; mod items; mod json_result; mod live_cli; diff --git a/codex-rs/hooks/schema/generated/interrupt.command.input.schema.json b/codex-rs/hooks/schema/generated/interrupt.command.input.schema.json new file mode 100644 index 0000000000..e0ac2a96bb --- /dev/null +++ b/codex-rs/hooks/schema/generated/interrupt.command.input.schema.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/interrupt.command.output.schema.json b/codex-rs/hooks/schema/generated/interrupt.command.output.schema.json new file mode 100644 index 0000000000..4d03957745 --- /dev/null +++ b/codex-rs/hooks/schema/generated/interrupt.command.output.schema.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 2031e7909c..656612d2fb 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -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, source_path: &Path, warnings: &mut Vec, ) -> 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![1, 3] + ); + assert_eq!( + entries + .iter() + .map(|entry| entry.timeout_sec) + .collect::>(), + 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, @@ -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![(3, None, HookExecutionMode::Async)] + ); + assert_eq!( + hook_entries + .iter() + .map(|entry| (entry.timeout_sec, entry.matcher.as_deref())) + .collect::>(), + 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(); diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 575ee46b43..8e77e9bbca 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -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![ diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index b7c44303a0..5934f46322 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -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 { + 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)] diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index a43c415a1c..574939f700 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -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), + }] + ); +} diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index d4c71a172b..aaefa46dc1 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -67,8 +67,14 @@ pub(crate) struct StatelessHookOutput { pub invalid_reason: Option, } +#[derive(Debug, Clone)] +pub(crate) struct InterruptOutput { + pub system_message: Option, +} + 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 { }) } +pub(crate) fn parse_interrupt(stdout: &str) -> Option { + let wire: InterruptCommandOutputWire = parse_json(stdout)?; + Some(InterruptOutput { + system_message: wire.system_message, + }) +} + pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option { let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?; let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block)); diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index e5fbf42d2c..7fce9a3c43 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -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"); } } diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index 085f1390ce..7b4c4dcab3 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -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] diff --git a/codex-rs/hooks/src/events/interrupt.rs b/codex-rs/hooks/src/events/interrupt.rs new file mode 100644 index 0000000000..3abea204bf --- /dev/null +++ b/codex-rs/hooks/src/events/interrupt.rs @@ -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, + pub model: String, + pub permission_mode: String, +} + +#[derive(Debug, Default)] +pub struct InterruptOutcome { + pub hook_events: Vec, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct InterruptHandlerData; + +pub(crate) fn preview(handlers: &[ConfiguredHandler]) -> Vec { + 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, +) -> dispatcher::ParsedHandler { + 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; diff --git a/codex-rs/hooks/src/events/interrupt_tests.rs b/codex-rs/hooks/src/events/interrupt_tests.rs new file mode 100644 index 0000000000..5e1c61776e --- /dev/null +++ b/codex-rs/hooks/src/events/interrupt_tests.rs @@ -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, 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, +) -> crate::engine::dispatcher::ParsedHandler { + 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, 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, + } +} diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index eb5c727d31..3d5ea1baad 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -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; diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 8d128a86cf..6abc173535 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -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", } } diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 5290e50f81..b322da55d8 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -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 { + 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 { diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 3847cc9181..5558b6665b 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -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, } +#[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, +} + #[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::()?, )?; + write_schema( + &generated_dir.join(INTERRUPT_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(INTERRUPT_OUTPUT_FIXTURE), + schema_json::()?, + )?; 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::().expect("serialize stop input schema"), ) .expect("parse stop input schema"); + let interrupt: Value = serde_json::from_slice( + &schema_json::().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!( diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 8ea5a4ac75..2f089e7176 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1519,6 +1519,7 @@ pub enum HookEventName { SubagentStart, SubagentStop, Stop, + Interrupt, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 2a765613bf..88318fb08a 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -779,6 +779,7 @@ fn event_label(event_name: HookEventName) -> &'static str { HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", + HookEventName::Interrupt => "Interrupt", } } @@ -795,6 +796,7 @@ fn event_description(event_name: HookEventName) -> &'static str { HookEventName::SubagentStart => "When a subagent is created", HookEventName::SubagentStop => "Right before a subagent ends its turn", HookEventName::Stop => "Right before Codex ends its turn", + HookEventName::Interrupt => "Right before an interrupted turn is aborted", } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap index 0677d09117..4e7fb4917d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -18,5 +18,6 @@ expression: "render_lines(&view, 112)" SubagentStart 0 0 When a subagent is created SubagentStop 0 0 Right before a subagent ends its turn Stop 0 0 Right before Codex ends its turn + Interrupt 0 0 Right before an interrupted turn is aborted Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap index d719280b4d..81762244d8 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -22,5 +22,6 @@ expression: "render_lines(&view, 112)" SubagentStart 0 0 When a subagent is created SubagentStop 0 0 Right before a subagent ends its turn Stop 0 0 Right before Codex ends its turn + Interrupt 0 0 Right before an interrupted turn is aborted Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap index 78e2e90c6d..4ad2307aff 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap @@ -20,5 +20,6 @@ expression: "render_lines(&view, 112)" SubagentStart 0 0 0 When a subagent is created SubagentStop 0 0 0 Right before a subagent ends its turn Stop 0 0 0 Right before Codex ends its turn + Interrupt 0 0 0 Right before an interrupted turn is aborted Press t to trust all; enter to review hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap index b52b4bf1ee..13c983b0b5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap @@ -21,5 +21,6 @@ expression: popup SubagentStart 0 0 When a subagent is created SubagentStop 0 0 Right before a subagent ends its turn Stop 0 0 Right before Codex ends its turn + Interrupt 0 0 Right before an interrupted turn is aborted Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_hook_events_render_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_hook_events_render_snapshot.snap new file mode 100644 index 0000000000..9cb557b103 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupt_hook_events_render_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/helpers.rs +expression: combined +--- +• Interrupt (completed) says: Heads up from the hook diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 7fd2cc91f1..71ea89420d 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1670,6 +1670,16 @@ pub(super) async fn assert_hook_events_snapshot( "hook start should render in the live hook cell" ); + let mut entries = vec![codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Warning, + text: "Heads up from the hook".to_string(), + }]; + if event_name != codex_app_server_protocol::HookEventName::Interrupt { + entries.push(codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Context, + text: "Remember the startup checklist.".to_string(), + }); + } handle_hook_completed( &mut chat, hook_run( @@ -1677,16 +1687,7 @@ pub(super) async fn assert_hook_events_snapshot( event_name, codex_app_server_protocol::HookRunStatus::Completed, status_message, - vec![ - codex_app_server_protocol::HookOutputEntry { - kind: codex_app_server_protocol::HookOutputEntryKind::Warning, - text: "Heads up from the hook".to_string(), - }, - codex_app_server_protocol::HookOutputEntry { - kind: codex_app_server_protocol::HookOutputEntryKind::Context, - text: "Remember the startup checklist.".to_string(), - }, - ], + entries, ), ); @@ -1711,5 +1712,6 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart", codex_app_server_protocol::HookEventName::SubagentStop => "SubagentStop", codex_app_server_protocol::HookEventName::Stop => "Stop", + codex_app_server_protocol::HookEventName::Interrupt => "Interrupt", } } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index a0f5de420c..bb65b85b56 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -4810,6 +4810,17 @@ async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() { assert!(!chat.bottom_pane.status_indicator_visible()); } +#[tokio::test] +async fn interrupt_hook_events_render_snapshot() { + assert_hook_events_snapshot( + codex_app_server_protocol::HookEventName::Interrupt, + "interrupt:0:/tmp/hooks.json", + "cleaning up the interrupted turn", + "interrupt_hook_events_render_snapshot", + ) + .await; +} + #[tokio::test] async fn pre_tool_use_hook_events_render_snapshot() { assert_hook_events_snapshot( diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index 7b3868ed28..97567b18bf 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -846,6 +846,7 @@ fn hook_event_label(event_name: HookEventName) -> &'static str { HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", + HookEventName::Interrupt => "Interrupt", } }