mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Add hooks for interrupted turns (#40511)
## What changed - Add an `Interrupt` hook event that runs for an active top-level turn before its interrupted abort event is emitted. - Flush the turn transcript before invoking the hook and provide the session, turn, transcript, working directory, model, and permission mode in its input. - Support command and MCP handlers, including asynchronous commands, with a one-second default timeout and a three-second maximum. - Expose the event through hook configuration, managed requirements, app-server notifications, generated schemas, analytics, and the TUI hook views. ## Testing - Cover handler discovery, timeout normalization, output parsing, protocol compatibility, TUI rendering, and interrupt execution ordering. GitOrigin-RevId: 163fa7c098d94ac2775f6d137f8e916f8ea9b6eb
This commit is contained in:
committed by
copyberry
parent
9b2ef38f54
commit
cbfd999db7
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2098,7 +2098,8 @@
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
"stop"
|
||||
"stop",
|
||||
"interrupt"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+9
-1
@@ -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"
|
||||
|
||||
+9
-1
@@ -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"
|
||||
|
||||
+7
@@ -720,6 +720,13 @@
|
||||
},
|
||||
"ManagedHooksRequirements": {
|
||||
"properties": {
|
||||
"Interrupt": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PermissionRequest": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
|
||||
+2
-1
@@ -17,7 +17,8 @@
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
"stop"
|
||||
"stop",
|
||||
"interrupt"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
"stop"
|
||||
"stop",
|
||||
"interrupt"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
"stop"
|
||||
"stop",
|
||||
"interrupt"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -120,7 +120,8 @@
|
||||
"userPromptSubmit",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
"stop"
|
||||
"stop",
|
||||
"interrupt"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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";
|
||||
|
||||
+1
-1
@@ -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<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, SessionEnd: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
|
||||
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, SessionEnd: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, Interrupt: Array<ConfiguredHookMatcherGroup>, };
|
||||
|
||||
@@ -589,6 +589,9 @@ pub struct ManagedHooksRequirements {
|
||||
#[serde(rename = "Stop")]
|
||||
#[ts(rename = "Stop")]
|
||||
pub stop: Vec<ConfiguredHookMatcherGroup>,
|
||||
#[serde(rename = "Interrupt", default)]
|
||||
#[ts(rename = "Interrupt")]
|
||||
pub interrupt: Vec<ConfiguredHookMatcherGroup>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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::<ExternalAgentConfigDetectResponse>(json!({
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ pub struct HookEventsToml {
|
||||
pub subagent_stop: Vec<MatcherGroup>,
|
||||
#[serde(rename = "Stop", default)]
|
||||
pub stop: Vec<MatcherGroup>,
|
||||
#[serde(rename = "Interrupt", default)]
|
||||
pub interrupt: Vec<MatcherGroup>,
|
||||
}
|
||||
|
||||
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<MatcherGroup>); 11] {
|
||||
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1678,6 +1678,13 @@
|
||||
},
|
||||
"HooksToml": {
|
||||
"properties": {
|
||||
"Interrupt": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/MatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PermissionRequest": {
|
||||
"default": [],
|
||||
"items": {
|
||||
|
||||
@@ -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<Session>) {
|
||||
emit_hook_completed_events(sess, &turn_context, outcome.hook_events).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn run_turn_interrupt_hooks(sess: &Arc<Session>, turn_context: &Arc<TurnContext>) {
|
||||
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<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Vec<Value>> {
|
||||
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<TestCodex> {
|
||||
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("<turn_aborted>"),
|
||||
"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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ pub(crate) fn matcher_pattern_for_event(
|
||||
| HookEventName::SubagentStop
|
||||
| HookEventName::PreCompact
|
||||
| HookEventName::PostCompact => matcher,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop | HookEventName::Interrupt => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,10 @@ mod tests {
|
||||
matcher_pattern_for_event(HookEventName::Stop, Some("^done$")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::Interrupt, Some("^interrupted$")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use super::common;
|
||||
use crate::engine::ClaudeHooksEngine;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::HandlerRunResult;
|
||||
use crate::engine::dispatcher;
|
||||
use crate::engine::output_parser;
|
||||
use crate::schema::InterruptCommandInput;
|
||||
use crate::schema::NullableString;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InterruptRequest {
|
||||
pub session_id: ThreadId,
|
||||
pub turn_id: String,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
pub transcript_path: Option<PathBuf>,
|
||||
pub model: String,
|
||||
pub permission_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InterruptOutcome {
|
||||
pub hook_events: Vec<HookCompletedEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct InterruptHandlerData;
|
||||
|
||||
pub(crate) fn preview(handlers: &[ConfiguredHandler]) -> Vec<HookRunSummary> {
|
||||
dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::Interrupt,
|
||||
/*matcher_input*/ None,
|
||||
)
|
||||
.into_iter()
|
||||
.map(|handler| dispatcher::running_summary(&handler))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn run(engine: &ClaudeHooksEngine, request: InterruptRequest) -> InterruptOutcome {
|
||||
let matched = dispatcher::select_handlers(
|
||||
&engine.handlers,
|
||||
HookEventName::Interrupt,
|
||||
/*matcher_input*/ None,
|
||||
);
|
||||
if matched.is_empty() {
|
||||
return InterruptOutcome::default();
|
||||
}
|
||||
|
||||
let InterruptRequest {
|
||||
session_id,
|
||||
turn_id,
|
||||
cwd,
|
||||
transcript_path,
|
||||
model,
|
||||
permission_mode,
|
||||
} = request;
|
||||
let input_json = match serde_json::to_string(&InterruptCommandInput {
|
||||
session_id: session_id.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
transcript_path: NullableString::from_path(transcript_path),
|
||||
cwd: cwd.display().to_string(),
|
||||
hook_event_name: "Interrupt".to_string(),
|
||||
model,
|
||||
permission_mode,
|
||||
}) {
|
||||
Ok(input_json) => input_json,
|
||||
Err(error) => {
|
||||
return InterruptOutcome {
|
||||
hook_events: common::serialization_failure_hook_events(
|
||||
matched,
|
||||
Some(turn_id),
|
||||
format!("failed to serialize interrupt hook input: {error}"),
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
engine,
|
||||
matched,
|
||||
input_json,
|
||||
cwd.as_path(),
|
||||
Some(turn_id),
|
||||
parse_completed,
|
||||
)
|
||||
.await;
|
||||
|
||||
InterruptOutcome {
|
||||
hook_events: results.into_iter().map(|result| result.completed).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_completed(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: HandlerRunResult,
|
||||
turn_id: Option<String>,
|
||||
) -> dispatcher::ParsedHandler<InterruptHandlerData> {
|
||||
let mut entries = Vec::new();
|
||||
let mut status = HookRunStatus::Completed;
|
||||
|
||||
match run_result.error.as_deref() {
|
||||
Some(error) => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: error.to_string(),
|
||||
});
|
||||
}
|
||||
None => match run_result.exit_code {
|
||||
Some(0) => {
|
||||
let trimmed_stdout = run_result.stdout.trim();
|
||||
if trimmed_stdout.is_empty() {
|
||||
} else if let Some(parsed) = output_parser::parse_interrupt(&run_result.stdout) {
|
||||
if let Some(system_message) = parsed.system_message {
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: system_message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
status = HookRunStatus::Failed;
|
||||
let text = if output_parser::looks_like_json(&run_result.stdout) {
|
||||
"hook returned invalid interrupt hook JSON output"
|
||||
} else {
|
||||
"Interrupt hook returned non-JSON stdout"
|
||||
};
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(exit_code) => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: format!("hook exited with code {exit_code}"),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook exited without a status code".to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
let completed = HookCompletedEvent {
|
||||
turn_id,
|
||||
run: dispatcher::completed_summary(handler, &run_result, status, entries),
|
||||
};
|
||||
|
||||
dispatcher::ParsedHandler {
|
||||
completed,
|
||||
data: InterruptHandlerData,
|
||||
completion_order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "interrupt_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,127 @@
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::InterruptHandlerData;
|
||||
use super::parse_completed;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::ConfiguredHandlerKind;
|
||||
use crate::engine::HandlerRunResult;
|
||||
|
||||
#[test]
|
||||
fn empty_stdout_succeeds() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), "", ""),
|
||||
/*turn_id*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(parsed.data, InterruptHandlerData);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
|
||||
assert!(parsed.completed.run.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_message_becomes_warning() {
|
||||
let parsed = parse(r#"{"systemMessage":"watch the tide"}"#, Some(0));
|
||||
|
||||
assert_eq!(parsed.data, InterruptHandlerData);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![warning("watch the tide")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_outputs_fail() {
|
||||
for stdout in [
|
||||
r#"{"continue":true}"#,
|
||||
r#"{"stopReason":null}"#,
|
||||
r#"{"suppressOutput":false}"#,
|
||||
r#"{"decision":"block"}"#,
|
||||
r#"{"systemMessage":"watch the tide""#,
|
||||
] {
|
||||
assert_failed(
|
||||
stdout,
|
||||
Some(0),
|
||||
"hook returned invalid interrupt hook JSON output",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_failures_use_standard_errors() {
|
||||
for (exit_code, stdout, expected) in [
|
||||
(Some(0), "aloha", "Interrupt hook returned non-JSON stdout"),
|
||||
(Some(2), "", "hook exited with code 2"),
|
||||
] {
|
||||
assert_failed(stdout, exit_code, expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_failed(stdout: &str, exit_code: Option<i32>, expected: &str) {
|
||||
let parsed = parse(stdout, exit_code);
|
||||
assert_eq!(parsed.data, InterruptHandlerData);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(parsed.completed.run.entries, vec![error(expected)]);
|
||||
}
|
||||
|
||||
fn parse(
|
||||
stdout: &str,
|
||||
exit_code: Option<i32>,
|
||||
) -> crate::engine::dispatcher::ParsedHandler<InterruptHandlerData> {
|
||||
parse_completed(
|
||||
&handler(),
|
||||
run_result(exit_code, stdout, "ignored"),
|
||||
Some("turn-1".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn warning(text: &str) -> HookOutputEntry {
|
||||
HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn error(text: &str) -> HookOutputEntry {
|
||||
HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::Interrupt,
|
||||
matcher: None,
|
||||
timeout_sec: 600,
|
||||
status_message: None,
|
||||
additional_context_limit: Default::default(),
|
||||
source_path: test_path_buf("/tmp/hooks.json").abs().into(),
|
||||
source: codex_protocol::protocol::HookSource::User,
|
||||
display_order: 0,
|
||||
kind: ConfiguredHandlerKind::Command {
|
||||
command: "echo hook".to_string(),
|
||||
r#async: false,
|
||||
env: std::collections::HashMap::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> HandlerRunResult {
|
||||
HandlerRunResult {
|
||||
started_at: 1,
|
||||
completed_at: 2,
|
||||
duration_ms: 1,
|
||||
exit_code,
|
||||
stdout: stdout.to_string(),
|
||||
stderr: stderr.to_string(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub(crate) mod common;
|
||||
pub mod compact;
|
||||
pub mod interrupt;
|
||||
pub mod permission_request;
|
||||
pub mod post_tool_use;
|
||||
pub mod pre_tool_use;
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -1519,6 +1519,7 @@ pub enum HookEventName {
|
||||
SubagentStart,
|
||||
SubagentStop,
|
||||
Stop,
|
||||
Interrupt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/helpers.rs
|
||||
expression: combined
|
||||
---
|
||||
• Interrupt (completed) says: Heads up from the hook
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user