mirror of
https://github.com/openai/codex.git
synced 2026-11-09 08:40:33 +00:00
Run executor hooks for interrupted turns (#41432)
## Why Executor-scoped cleanup hooks need to observe an interrupted turn as well as a normally completed one. ## What changed - Allow bundled executor plugins to register the allowlisted `Interrupt` hook alongside `Stop`, while preserving multiple allowed hook entries from the selected executor environment. - Run interrupt hooks with the interrupted turn's last step discovery and turn metadata. Turns without a step context do not reuse discovery from an earlier turn. - Keep executor-scoped interrupt hooks asynchronous and omit public hook lifecycle events for them, matching executor `Stop` hook behavior. ## Testing Added coverage for hook filtering and registration, interrupt execution and metadata propagation, environment selection, and turns without a step context. GitOrigin-RevId: f0c536b3382a37cf3bf693c4573d21f6f66ace4e
This commit is contained in:
1 parent
0d22692962
commit
c2abf869d5
9 files changed
+473
-165
No files matched your search
@@ -125,20 +125,27 @@ impl HookEventsToml {
|
|||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 12] {
|
pub fn into_matcher_groups(mut self) -> [(HookEventName, Vec<MatcherGroup>); 12] {
|
||||||
|
self.matcher_groups_mut()
|
||||||
|
.map(|(event, groups)| (event, std::mem::take(groups)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matcher_groups_mut(&mut self) -> [(HookEventName, &mut Vec<MatcherGroup>); 12] {
|
||||||
|
use HookEventName as Event;
|
||||||
|
|
||||||
[
|
[
|
||||||
(HookEventName::PreToolUse, self.pre_tool_use),
|
(Event::PreToolUse, &mut self.pre_tool_use),
|
||||||
(HookEventName::PermissionRequest, self.permission_request),
|
(Event::PermissionRequest, &mut self.permission_request),
|
||||||
(HookEventName::PostToolUse, self.post_tool_use),
|
(Event::PostToolUse, &mut self.post_tool_use),
|
||||||
(HookEventName::PreCompact, self.pre_compact),
|
(Event::PreCompact, &mut self.pre_compact),
|
||||||
(HookEventName::PostCompact, self.post_compact),
|
(Event::PostCompact, &mut self.post_compact),
|
||||||
(HookEventName::SessionStart, self.session_start),
|
(Event::SessionStart, &mut self.session_start),
|
||||||
(HookEventName::SessionEnd, self.session_end),
|
(Event::SessionEnd, &mut self.session_end),
|
||||||
(HookEventName::UserPromptSubmit, self.user_prompt_submit),
|
(Event::UserPromptSubmit, &mut self.user_prompt_submit),
|
||||||
(HookEventName::SubagentStart, self.subagent_start),
|
(Event::SubagentStart, &mut self.subagent_start),
|
||||||
(HookEventName::SubagentStop, self.subagent_stop),
|
(Event::SubagentStop, &mut self.subagent_stop),
|
||||||
(HookEventName::Stop, self.stop),
|
(Event::Stop, &mut self.stop),
|
||||||
(HookEventName::Interrupt, self.interrupt),
|
(Event::Interrupt, &mut self.interrupt),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
use codex_config::HookEventsToml;
|
|
||||||
use codex_config::HookHandlerConfig;
|
use codex_config::HookHandlerConfig;
|
||||||
use codex_config::MatcherGroup;
|
|
||||||
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
|
use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
|
||||||
use codex_plugin::ExecutorPluginHookSource;
|
use codex_plugin::ExecutorPluginHookSource;
|
||||||
use codex_plugin::PluginId;
|
use codex_plugin::PluginId;
|
||||||
@@ -18,7 +16,7 @@ struct AllowlistedExecutorPluginHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Executor plugin manifests are unsigned, so temporarily hardcode the expected
|
// Executor plugin manifests are unsigned, so temporarily hardcode the expected
|
||||||
// bundled plugin identities and cleanup MCP target until plugin signing lands.
|
// bundled plugin identities, events, and MCP targets until plugin signing lands.
|
||||||
const ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS: &[AllowlistedExecutorPluginHook] = &[
|
const ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS: &[AllowlistedExecutorPluginHook] = &[
|
||||||
AllowlistedExecutorPluginHook {
|
AllowlistedExecutorPluginHook {
|
||||||
plugin_id: "browser@openai-bundled",
|
plugin_id: "browser@openai-bundled",
|
||||||
@@ -26,30 +24,60 @@ const ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS: &[AllowlistedExecutorPluginHook] = &[
|
|||||||
server: "node_repl",
|
server: "node_repl",
|
||||||
tool: "turn_ended",
|
tool: "turn_ended",
|
||||||
},
|
},
|
||||||
|
AllowlistedExecutorPluginHook {
|
||||||
|
plugin_id: "browser@openai-bundled",
|
||||||
|
event: HookEventName::Interrupt,
|
||||||
|
server: "node_repl",
|
||||||
|
tool: "turn_ended",
|
||||||
|
},
|
||||||
AllowlistedExecutorPluginHook {
|
AllowlistedExecutorPluginHook {
|
||||||
plugin_id: "chrome@openai-bundled",
|
plugin_id: "chrome@openai-bundled",
|
||||||
event: HookEventName::Stop,
|
event: HookEventName::Stop,
|
||||||
server: "node_repl",
|
server: "node_repl",
|
||||||
tool: "turn_ended",
|
tool: "turn_ended",
|
||||||
},
|
},
|
||||||
|
AllowlistedExecutorPluginHook {
|
||||||
|
plugin_id: "chrome@openai-bundled",
|
||||||
|
event: HookEventName::Interrupt,
|
||||||
|
server: "node_repl",
|
||||||
|
tool: "turn_ended",
|
||||||
|
},
|
||||||
AllowlistedExecutorPluginHook {
|
AllowlistedExecutorPluginHook {
|
||||||
plugin_id: "chrome-dev@openai-bundled",
|
plugin_id: "chrome-dev@openai-bundled",
|
||||||
event: HookEventName::Stop,
|
event: HookEventName::Stop,
|
||||||
server: "node_repl",
|
server: "node_repl",
|
||||||
tool: "turn_ended",
|
tool: "turn_ended",
|
||||||
},
|
},
|
||||||
|
AllowlistedExecutorPluginHook {
|
||||||
|
plugin_id: "chrome-dev@openai-bundled",
|
||||||
|
event: HookEventName::Interrupt,
|
||||||
|
server: "node_repl",
|
||||||
|
tool: "turn_ended",
|
||||||
|
},
|
||||||
AllowlistedExecutorPluginHook {
|
AllowlistedExecutorPluginHook {
|
||||||
plugin_id: "chrome-internal@openai-bundled",
|
plugin_id: "chrome-internal@openai-bundled",
|
||||||
event: HookEventName::Stop,
|
event: HookEventName::Stop,
|
||||||
server: "node_repl",
|
server: "node_repl",
|
||||||
tool: "turn_ended",
|
tool: "turn_ended",
|
||||||
},
|
},
|
||||||
|
AllowlistedExecutorPluginHook {
|
||||||
|
plugin_id: "chrome-internal@openai-bundled",
|
||||||
|
event: HookEventName::Interrupt,
|
||||||
|
server: "node_repl",
|
||||||
|
tool: "turn_ended",
|
||||||
|
},
|
||||||
AllowlistedExecutorPluginHook {
|
AllowlistedExecutorPluginHook {
|
||||||
plugin_id: "computer-use@openai-bundled",
|
plugin_id: "computer-use@openai-bundled",
|
||||||
event: HookEventName::Stop,
|
event: HookEventName::Stop,
|
||||||
server: "node_repl",
|
server: "node_repl",
|
||||||
tool: "turn_ended",
|
tool: "turn_ended",
|
||||||
},
|
},
|
||||||
|
AllowlistedExecutorPluginHook {
|
||||||
|
plugin_id: "computer-use@openai-bundled",
|
||||||
|
event: HookEventName::Interrupt,
|
||||||
|
server: "node_repl",
|
||||||
|
tool: "turn_ended",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Returns accepted inline hook sources from executor-discovered plugin manifests.
|
/// Returns accepted inline hook sources from executor-discovered plugin manifests.
|
||||||
@@ -113,31 +141,27 @@ pub fn executor_plugin_hook_sources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn allowlisted_source(mut source: ExecutorPluginHookSource) -> Option<ExecutorPluginHookSource> {
|
fn allowlisted_source(mut source: ExecutorPluginHookSource) -> Option<ExecutorPluginHookSource> {
|
||||||
let allowlisted_hook = ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS.iter().find(|hook| {
|
let plugin_id = source.plugin_id.as_key();
|
||||||
hook.plugin_id == source.plugin_id.as_key() && hook.event == HookEventName::Stop
|
for (event, groups) in source.hooks.matcher_groups_mut() {
|
||||||
})?;
|
groups.retain_mut(|group| {
|
||||||
let handler = source
|
if group.matcher.is_some() {
|
||||||
.hooks
|
return false;
|
||||||
.stop
|
}
|
||||||
.into_iter()
|
group.hooks.retain(|handler| {
|
||||||
.filter(|group| group.matcher.is_none())
|
let HookHandlerConfig::McpTool { server, tool, .. } = handler else {
|
||||||
.flat_map(|group| group.hooks)
|
return false;
|
||||||
.find(|handler| {
|
};
|
||||||
matches!(
|
ALLOWLISTED_EXECUTOR_PLUGIN_HOOKS.iter().any(|hook| {
|
||||||
handler,
|
hook.plugin_id == plugin_id
|
||||||
HookHandlerConfig::McpTool { server, tool, .. }
|
&& hook.event == event
|
||||||
if server == allowlisted_hook.server && tool == allowlisted_hook.tool
|
&& hook.server == server
|
||||||
)
|
&& hook.tool == tool
|
||||||
})?;
|
})
|
||||||
|
});
|
||||||
source.hooks = HookEventsToml {
|
!group.hooks.is_empty()
|
||||||
stop: vec![MatcherGroup {
|
});
|
||||||
matcher: None,
|
}
|
||||||
hooks: vec![handler],
|
(!source.hooks.is_empty()).then_some(source)
|
||||||
}],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
Some(source)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use codex_config::HookEventsToml;
|
||||||
|
use codex_config::MatcherGroup;
|
||||||
use codex_exec_server::CapabilityRootDiscovery;
|
use codex_exec_server::CapabilityRootDiscovery;
|
||||||
use codex_exec_server::CapabilityTextFile;
|
use codex_exec_server::CapabilityTextFile;
|
||||||
use codex_exec_server::DiscoveredPluginFiles;
|
use codex_exec_server::DiscoveredPluginFiles;
|
||||||
@@ -105,24 +107,20 @@ fn expected_source(index: usize) -> ExecutorPluginHookSource {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discovers_allowlisted_executor_plugin_hook_sources() {
|
fn discovers_allowlisted_executor_plugin_hook_sources() {
|
||||||
let mut manifest = cleanup_hook_manifest();
|
let cleanup_hooks = cleanup_hook_manifest()["hooks"]["hooks"]["Stop"].clone();
|
||||||
manifest["hooks"]["hooks"]["Stop"][0]["hooks"]
|
let manifest = json!({
|
||||||
.as_array_mut()
|
"name": "computer-use",
|
||||||
.expect("stop hooks")
|
"hooks": [
|
||||||
.push(json!({
|
{ "hooks": { "Stop": cleanup_hooks } },
|
||||||
"type": "command",
|
{ "hooks": { "Interrupt": cleanup_hooks } },
|
||||||
"command": "echo ignored"
|
{ "hooks": {
|
||||||
}));
|
"Stop": cleanup_hooks,
|
||||||
manifest["hooks"]["hooks"]["UserPromptSubmit"] = json!([{
|
"Interrupt": cleanup_hooks,
|
||||||
"hooks": [{
|
"UserPromptSubmit": cleanup_hooks
|
||||||
"type": "mcp_tool",
|
} },
|
||||||
"server": "other",
|
{ "hooks": { "UserPromptSubmit": cleanup_hooks } }
|
||||||
"tool": "ignored"
|
]
|
||||||
}]
|
});
|
||||||
}]);
|
|
||||||
let second_hook_file = cleanup_hook_manifest()["hooks"].clone();
|
|
||||||
let first_hook_file = manifest["hooks"].take();
|
|
||||||
manifest["hooks"] = json!([first_hook_file, second_hook_file]);
|
|
||||||
let snapshot = snapshot_for_manifest(
|
let snapshot = snapshot_for_manifest(
|
||||||
"computer-use@openai-bundled",
|
"computer-use@openai-bundled",
|
||||||
"executor-a",
|
"executor-a",
|
||||||
@@ -131,13 +129,55 @@ fn discovers_allowlisted_executor_plugin_hook_sources() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let sources = executor_plugin_hook_sources(&snapshot);
|
let sources = executor_plugin_hook_sources(&snapshot);
|
||||||
|
let mut interrupt_source = expected_source(/*index*/ 1);
|
||||||
|
interrupt_source.hooks.interrupt = std::mem::take(&mut interrupt_source.hooks.stop);
|
||||||
|
let mut stop_and_interrupt_source = expected_source(/*index*/ 2);
|
||||||
|
stop_and_interrupt_source.hooks.interrupt = stop_and_interrupt_source.hooks.stop.clone();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sources,
|
sources,
|
||||||
vec![expected_source(/*index*/ 0), expected_source(/*index*/ 1)]
|
vec![
|
||||||
|
expected_source(/*index*/ 0),
|
||||||
|
interrupt_source,
|
||||||
|
stop_and_interrupt_source,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_mixed_handlers_without_rewriting_allowed_groups() {
|
||||||
|
let mut expected = expected_source(/*index*/ 0);
|
||||||
|
let mut second_handler = expected.hooks.stop[0].hooks[0].clone();
|
||||||
|
let HookHandlerConfig::McpTool { input, .. } = &mut second_handler else {
|
||||||
|
panic!("expected an MCP tool hook");
|
||||||
|
};
|
||||||
|
input.insert("order".to_string(), json!(2));
|
||||||
|
expected.hooks.stop[0].hooks.push(second_handler.clone());
|
||||||
|
expected.hooks.stop.push(MatcherGroup {
|
||||||
|
matcher: None,
|
||||||
|
hooks: vec![second_handler],
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut manifest = cleanup_hook_manifest();
|
||||||
|
manifest["hooks"]["hooks"]["Stop"] =
|
||||||
|
serde_json::to_value(&expected.hooks.stop).expect("serialize stop hooks");
|
||||||
|
manifest["hooks"]["hooks"]["Stop"][0]["hooks"]
|
||||||
|
.as_array_mut()
|
||||||
|
.expect("stop handlers")
|
||||||
|
.insert(
|
||||||
|
/*index*/ 0,
|
||||||
|
json!({ "type": "mcp_tool", "server": "node_repl", "tool": "other" }),
|
||||||
|
);
|
||||||
|
let snapshot = snapshot_for_manifest(
|
||||||
|
"computer-use@openai-bundled",
|
||||||
|
"executor-a",
|
||||||
|
"file:///plugins/computer-use/.codex-plugin/plugin.json",
|
||||||
|
manifest,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(executor_plugin_hook_sources(&snapshot), vec![expected]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preserves_allowlisted_executor_plugin_hook_options() {
|
fn preserves_allowlisted_executor_plugin_hook_options() {
|
||||||
let mut manifest = cleanup_hook_manifest();
|
let mut manifest = cleanup_hook_manifest();
|
||||||
@@ -145,6 +185,7 @@ fn preserves_allowlisted_executor_plugin_hook_options() {
|
|||||||
handler["input"] = json!({ "untrusted": "manifest-provided input" });
|
handler["input"] = json!({ "untrusted": "manifest-provided input" });
|
||||||
handler["timeout"] = json!(30);
|
handler["timeout"] = json!(30);
|
||||||
handler["statusMessage"] = json!("Cleaning up Computer Use");
|
handler["statusMessage"] = json!("Cleaning up Computer Use");
|
||||||
|
manifest["hooks"]["hooks"]["Interrupt"] = manifest["hooks"]["hooks"]["Stop"].clone();
|
||||||
let snapshot = snapshot_for_manifest(
|
let snapshot = snapshot_for_manifest(
|
||||||
"computer-use@openai-bundled",
|
"computer-use@openai-bundled",
|
||||||
"executor-a",
|
"executor-a",
|
||||||
@@ -161,6 +202,7 @@ fn preserves_allowlisted_executor_plugin_hook_options() {
|
|||||||
timeout_sec: Some(30),
|
timeout_sec: Some(30),
|
||||||
status_message: Some("Cleaning up Computer Use".to_string()),
|
status_message: Some("Cleaning up Computer Use".to_string()),
|
||||||
};
|
};
|
||||||
|
expected.hooks.interrupt = expected.hooks.stop.clone();
|
||||||
|
|
||||||
assert_eq!(executor_plugin_hook_sources(&snapshot), vec![expected]);
|
assert_eq!(executor_plugin_hook_sources(&snapshot), vec![expected]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ use codex_protocol::protocol::WarningEvent;
|
|||||||
use codex_rollout::state_db;
|
use codex_rollout::state_db;
|
||||||
use codex_thread_store::PersistContext;
|
use codex_thread_store::PersistContext;
|
||||||
use codex_thread_store::ReadThreadParams;
|
use codex_thread_store::ReadThreadParams;
|
||||||
|
use serde_json::Map;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
use crate::context::ContextualUserFragment;
|
use crate::context::ContextualUserFragment;
|
||||||
@@ -58,6 +60,7 @@ use crate::session::TurnInput;
|
|||||||
use crate::session::session::Session;
|
use crate::session::session::Session;
|
||||||
use crate::session::step_context::StepContext;
|
use crate::session::step_context::StepContext;
|
||||||
use crate::session::turn_context::TurnContext;
|
use crate::session::turn_context::TurnContext;
|
||||||
|
use crate::state::TurnState;
|
||||||
use crate::tools::hook_names::HookToolName;
|
use crate::tools::hook_names::HookToolName;
|
||||||
use crate::tools::sandboxing::PermissionRequestPayload;
|
use crate::tools::sandboxing::PermissionRequestPayload;
|
||||||
use crate::turn_metadata::McpTurnMetadataContext;
|
use crate::turn_metadata::McpTurnMetadataContext;
|
||||||
@@ -308,6 +311,29 @@ pub(crate) async fn run_post_tool_use_hooks(
|
|||||||
outcome
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_request_metadata(
|
||||||
|
step_context: Option<&StepContext>,
|
||||||
|
turn_context: &TurnContext,
|
||||||
|
) -> Map<String, Value> {
|
||||||
|
let settings = step_context
|
||||||
|
.map(|step_context| step_context.settings.as_ref())
|
||||||
|
.unwrap_or(turn_context.initial_settings.as_ref());
|
||||||
|
turn_context
|
||||||
|
.turn_metadata_state
|
||||||
|
.current_meta_value_for_mcp_request(McpTurnMetadataContext {
|
||||||
|
model: settings.model_info.slug.as_str(),
|
||||||
|
reasoning_effort: settings.effective_reasoning_effort(),
|
||||||
|
node_repl_disabled: settings.model_info.node_repl_disabled,
|
||||||
|
})
|
||||||
|
.map(|turn_metadata| {
|
||||||
|
Map::from_iter([(
|
||||||
|
crate::X_CODEX_TURN_METADATA_HEADER.to_string(),
|
||||||
|
turn_metadata,
|
||||||
|
)])
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(level = "trace", skip_all)]
|
#[instrument(level = "trace", skip_all)]
|
||||||
pub(crate) async fn run_turn_stop_hooks(
|
pub(crate) async fn run_turn_stop_hooks(
|
||||||
sess: &Arc<Session>,
|
sess: &Arc<Session>,
|
||||||
@@ -364,19 +390,7 @@ pub(crate) async fn run_turn_stop_hooks(
|
|||||||
),
|
),
|
||||||
_ => (StopHookTarget::Stop, sess.hook_transcript_path().await),
|
_ => (StopHookTarget::Stop, sess.hook_transcript_path().await),
|
||||||
};
|
};
|
||||||
let request_metadata = turn_context
|
let request_metadata = build_request_metadata(Some(step_context), turn_context);
|
||||||
.turn_metadata_state
|
|
||||||
.current_meta_value_for_mcp_request(McpTurnMetadataContext {
|
|
||||||
model: step_context.settings.model_info.slug.as_str(),
|
|
||||||
reasoning_effort: step_context.settings.effective_reasoning_effort(),
|
|
||||||
node_repl_disabled: step_context.settings.model_info.node_repl_disabled,
|
|
||||||
})
|
|
||||||
.map(|turn_metadata| {
|
|
||||||
serde_json::Map::from_iter([(
|
|
||||||
crate::X_CODEX_TURN_METADATA_HEADER.to_string(),
|
|
||||||
turn_metadata,
|
|
||||||
)])
|
|
||||||
});
|
|
||||||
let request = codex_hooks::StopRequest {
|
let request = codex_hooks::StopRequest {
|
||||||
session_id: sess.session_id().into(),
|
session_id: sess.session_id().into(),
|
||||||
turn_id: turn_context.sub_id.clone(),
|
turn_id: turn_context.sub_id.clone(),
|
||||||
@@ -385,7 +399,7 @@ pub(crate) async fn run_turn_stop_hooks(
|
|||||||
transcript_path,
|
transcript_path,
|
||||||
model: turn_context.model_info().slug.clone(),
|
model: turn_context.model_info().slug.clone(),
|
||||||
permission_mode: hook_permission_mode(turn_context),
|
permission_mode: hook_permission_mode(turn_context),
|
||||||
request_metadata,
|
request_metadata: (!request_metadata.is_empty()).then_some(request_metadata),
|
||||||
stop_hook_active,
|
stop_hook_active,
|
||||||
last_assistant_message,
|
last_assistant_message,
|
||||||
target,
|
target,
|
||||||
@@ -435,17 +449,30 @@ pub(crate) async fn run_session_end_hooks(sess: &Arc<Session>) {
|
|||||||
emit_hook_completed_events(sess, &turn_context, outcome.hook_events).await;
|
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>) {
|
pub(crate) async fn run_turn_interrupt_hooks(
|
||||||
|
sess: &Arc<Session>,
|
||||||
|
turn_context: &Arc<TurnContext>,
|
||||||
|
turn_state: &Mutex<TurnState>,
|
||||||
|
) {
|
||||||
if matches!(&turn_context.session_source, SessionSource::SubAgent(_)) {
|
if matches!(&turn_context.session_source, SessionSource::SubAgent(_)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hooks = sess.hooks();
|
// The active turn has already been detached. Reuse only its last executing step's discovery.
|
||||||
|
let last_known_step_context = turn_state.lock().await.last_known_step_context.clone();
|
||||||
|
let executor_hook_sources = last_known_step_context
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|step_context| step_context.executor_capability_discovery.as_deref())
|
||||||
|
.map(executor_plugin_hook_sources)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let has_executor_hooks = !executor_hook_sources.is_empty();
|
||||||
|
let hooks = sess.hooks().with_executor_hooks(executor_hook_sources);
|
||||||
let preview_runs = hooks.preview_interrupt();
|
let preview_runs = hooks.preview_interrupt();
|
||||||
if preview_runs.is_empty() {
|
if preview_runs.is_empty() && !has_executor_hooks {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let request_metadata = build_request_metadata(last_known_step_context.as_deref(), turn_context);
|
||||||
let request = InterruptRequest {
|
let request = InterruptRequest {
|
||||||
session_id: sess.session_id().into(),
|
session_id: sess.session_id().into(),
|
||||||
turn_id: turn_context.sub_id.clone(),
|
turn_id: turn_context.sub_id.clone(),
|
||||||
@@ -454,6 +481,7 @@ pub(crate) async fn run_turn_interrupt_hooks(sess: &Arc<Session>, turn_context:
|
|||||||
transcript_path: sess.hook_transcript_path().await,
|
transcript_path: sess.hook_transcript_path().await,
|
||||||
model: turn_context.model_info().slug.clone(),
|
model: turn_context.model_info().slug.clone(),
|
||||||
permission_mode: hook_permission_mode(turn_context),
|
permission_mode: hook_permission_mode(turn_context),
|
||||||
|
request_metadata: (!request_metadata.is_empty()).then_some(request_metadata),
|
||||||
};
|
};
|
||||||
if let Err(err) = sess.flush_rollout().await {
|
if let Err(err) = sess.flush_rollout().await {
|
||||||
tracing::warn!("failed to flush transcript before Interrupt hook: {err}");
|
tracing::warn!("failed to flush transcript before Interrupt hook: {err}");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use codex_diagnostics::Gauge;
|
|||||||
use codex_extension_api::ThreadIdleCause;
|
use codex_extension_api::ThreadIdleCause;
|
||||||
use futures::future::BoxFuture;
|
use futures::future::BoxFuture;
|
||||||
use tokio::select;
|
use tokio::select;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tokio_util::task::AbortOnDropHandle;
|
use tokio_util::task::AbortOnDropHandle;
|
||||||
@@ -35,6 +36,7 @@ use crate::session::turn_context::TurnContext;
|
|||||||
use crate::state::ActiveTurn;
|
use crate::state::ActiveTurn;
|
||||||
use crate::state::RunningTask;
|
use crate::state::RunningTask;
|
||||||
use crate::state::TaskKind;
|
use crate::state::TaskKind;
|
||||||
|
use crate::state::TurnState;
|
||||||
use codex_analytics::TurnProfileFact;
|
use codex_analytics::TurnProfileFact;
|
||||||
use codex_analytics::TurnTokenUsageFact;
|
use codex_analytics::TurnTokenUsageFact;
|
||||||
use codex_context_fragments::RenderedFragment;
|
use codex_context_fragments::RenderedFragment;
|
||||||
@@ -518,7 +520,8 @@ impl Session {
|
|||||||
aborted_turn = task.is_some();
|
aborted_turn = task.is_some();
|
||||||
turn_context = task.as_ref().map(|task| Arc::clone(&task.turn_context));
|
turn_context = task.as_ref().map(|task| Arc::clone(&task.turn_context));
|
||||||
if let Some(task) = task {
|
if let Some(task) = task {
|
||||||
self.handle_task_abort(task, reason.clone()).await;
|
self.handle_task_abort(task, reason.clone(), &active_turn.turn_state)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
if aborted_turn {
|
if aborted_turn {
|
||||||
active_turn_to_clear = Some(active_turn);
|
active_turn_to_clear = Some(active_turn);
|
||||||
@@ -569,7 +572,8 @@ impl Session {
|
|||||||
let task = active_turn.task.take();
|
let task = active_turn.task.take();
|
||||||
let turn_context = task.as_ref().map(|task| Arc::clone(&task.turn_context));
|
let turn_context = task.as_ref().map(|task| Arc::clone(&task.turn_context));
|
||||||
if let Some(task) = task {
|
if let Some(task) = task {
|
||||||
self.handle_task_abort(task, reason.clone()).await;
|
self.handle_task_abort(task, reason.clone(), &active_turn.turn_state)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
if let Some(turn_context) = turn_context.as_deref() {
|
if let Some(turn_context) = turn_context.as_deref() {
|
||||||
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
|
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
|
||||||
@@ -805,7 +809,7 @@ impl Session {
|
|||||||
};
|
};
|
||||||
let event = if let Some(reason) = abort_reason {
|
let event = if let Some(reason) = abort_reason {
|
||||||
if reason == TurnAbortReason::Interrupted {
|
if reason == TurnAbortReason::Interrupted {
|
||||||
run_turn_interrupt_hooks(self, &turn_context).await;
|
run_turn_interrupt_hooks(self, &turn_context, &turn_state).await;
|
||||||
}
|
}
|
||||||
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
|
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
|
||||||
.await;
|
.await;
|
||||||
@@ -898,7 +902,12 @@ impl Session {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_task_abort(self: &Arc<Self>, task: RunningTask, reason: TurnAbortReason) {
|
async fn handle_task_abort(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
task: RunningTask,
|
||||||
|
reason: TurnAbortReason,
|
||||||
|
turn_state: &Mutex<TurnState>,
|
||||||
|
) {
|
||||||
let sub_id = task.turn_context.sub_id.clone();
|
let sub_id = task.turn_context.sub_id.clone();
|
||||||
if task.cancellation_token.is_cancelled() {
|
if task.cancellation_token.is_cancelled() {
|
||||||
return;
|
return;
|
||||||
@@ -958,7 +967,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if reason == TurnAbortReason::Interrupted {
|
if reason == TurnAbortReason::Interrupted {
|
||||||
run_turn_interrupt_hooks(self, &task.turn_context).await;
|
run_turn_interrupt_hooks(self, &task.turn_context, turn_state).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let started_at = task
|
let started_at = task
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use anyhow::Context;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use codex_config::McpServerConfig;
|
use codex_config::McpServerConfig;
|
||||||
use codex_core::EnvironmentConfig;
|
use codex_core::EnvironmentConfig;
|
||||||
|
use codex_core::TurnInputRequest;
|
||||||
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
||||||
use codex_exec_server::CreateDirectoryOptions;
|
use codex_exec_server::CreateDirectoryOptions;
|
||||||
use codex_exec_server::ExecServerRuntimePaths;
|
use codex_exec_server::ExecServerRuntimePaths;
|
||||||
@@ -11,20 +12,26 @@ use codex_protocol::capabilities::SelectedCapabilityRoot;
|
|||||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||||
use codex_protocol::models::PermissionProfileSnapshot;
|
use codex_protocol::models::PermissionProfileSnapshot;
|
||||||
use codex_protocol::protocol::EnvironmentConfigState;
|
use codex_protocol::protocol::EnvironmentConfigState;
|
||||||
|
use codex_protocol::protocol::EventMsg;
|
||||||
|
use codex_protocol::protocol::Op;
|
||||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||||
use codex_protocol::protocol::TurnEnvironmentSelection;
|
use codex_protocol::protocol::TurnEnvironmentSelection;
|
||||||
use codex_protocol::protocol::TurnEnvironmentSelections;
|
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||||
|
use codex_protocol::user_input::UserInput;
|
||||||
use core_test_support::responses::ResponseMock;
|
use core_test_support::responses::ResponseMock;
|
||||||
use core_test_support::responses::ev_assistant_message;
|
use core_test_support::responses::ev_assistant_message;
|
||||||
use core_test_support::responses::ev_completed;
|
use core_test_support::responses::ev_completed;
|
||||||
use core_test_support::responses::ev_response_created;
|
use core_test_support::responses::ev_response_created;
|
||||||
use core_test_support::responses::mount_sse_sequence;
|
use core_test_support::responses::mount_response_sequence;
|
||||||
use core_test_support::responses::sse;
|
use core_test_support::responses::sse;
|
||||||
|
use core_test_support::responses::sse_response;
|
||||||
use core_test_support::responses::start_mock_server;
|
use core_test_support::responses::start_mock_server;
|
||||||
use core_test_support::skip_if_no_network;
|
use core_test_support::skip_if_no_network;
|
||||||
|
use core_test_support::skip_if_remote;
|
||||||
use core_test_support::submit_thread_settings;
|
use core_test_support::submit_thread_settings;
|
||||||
use core_test_support::test_codex::TestCodex;
|
use core_test_support::test_codex::TestCodex;
|
||||||
use core_test_support::test_codex::test_codex;
|
use core_test_support::test_codex::test_codex;
|
||||||
|
use core_test_support::wait_for_event;
|
||||||
use core_test_support::wait_for_mcp_server;
|
use core_test_support::wait_for_mcp_server;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -99,6 +106,93 @@ async fn executor_stop_hook_runs_after_attachment() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn executor_interrupt_hook_runs_after_attachment() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let fixture = executor_hook_fixture(vec![
|
||||||
|
completed_turn_response("turn").set_delay(Duration::from_secs(60)),
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
fixture.attach().await?;
|
||||||
|
fixture
|
||||||
|
.test
|
||||||
|
.codex
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: "interrupt this turn".to_string(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// The model request confirms discovery was saved, without requiring a target shell.
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
while fixture.responses.requests().is_empty() {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("interrupted turn should reach the model request")?;
|
||||||
|
fixture.test.codex.submit(Op::Interrupt).await?;
|
||||||
|
wait_for_event(&fixture.test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnAborted(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
fixture.wait_for_hook_call().await?;
|
||||||
|
|
||||||
|
let calls = fixture.calls().await?;
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
let call = &calls[0]["params"];
|
||||||
|
let response = fixture.responses.single_request().body_json();
|
||||||
|
let turn_id = &response["client_metadata"]["turn_id"];
|
||||||
|
assert_eq!(call["name"], "turn_ended");
|
||||||
|
assert_eq!(
|
||||||
|
call["arguments"],
|
||||||
|
json!({
|
||||||
|
"hook_event_name": "Interrupt",
|
||||||
|
"session_id": fixture.test.session_configured.thread_id.to_string(),
|
||||||
|
"turn_id": turn_id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(call["_meta"]["x-codex-turn-metadata"]["turn_id"], *turn_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn executor_interrupt_hook_skips_turn_without_step_context() -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
skip_if_remote!(
|
||||||
|
Ok(()),
|
||||||
|
"standalone user shell commands require a local environment"
|
||||||
|
);
|
||||||
|
|
||||||
|
let fixture = executor_hook_fixture(vec![completed_turn_response("first-turn")]).await?;
|
||||||
|
fixture.attach().await?;
|
||||||
|
fixture
|
||||||
|
.test
|
||||||
|
.submit_text_turn("populate executor discovery")
|
||||||
|
.await?;
|
||||||
|
fixture.wait_for_hook_call().await?;
|
||||||
|
|
||||||
|
// Standalone shell turns have no model step, despite the previous turn's discovery.
|
||||||
|
fixture
|
||||||
|
.test
|
||||||
|
.codex
|
||||||
|
.submit(Op::RunUserShellCommand {
|
||||||
|
command: "sleep 60".to_string(),
|
||||||
|
timeout_ms: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
fixture.interrupt_running_command().await?;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_secs(1), fixture.hook_called.notified())
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"executor hooks must not reuse a previous turn's step context",
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn executor_stop_hook_stops_after_disconnection() -> Result<()> {
|
async fn executor_stop_hook_stops_after_disconnection() -> Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
@@ -256,7 +350,24 @@ async fn executor_stop_hook_rejects_mismatched_environment() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn executor_stop_hook_fixture() -> Result<ExecutorStopHookFixture> {
|
fn completed_turn_response(id: &str) -> ResponseTemplate {
|
||||||
|
sse_response(sse(vec![
|
||||||
|
ev_response_created(id),
|
||||||
|
ev_assistant_message(id, "done"),
|
||||||
|
ev_completed(id),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn executor_stop_hook_fixture() -> Result<ExecutorHookFixture> {
|
||||||
|
executor_hook_fixture(
|
||||||
|
["first-turn", "second-turn"]
|
||||||
|
.map(completed_turn_response)
|
||||||
|
.to_vec(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn executor_hook_fixture(responses: Vec<ResponseTemplate>) -> Result<ExecutorHookFixture> {
|
||||||
let server = start_mock_server().await;
|
let server = start_mock_server().await;
|
||||||
let hook_called = Arc::new(Notify::new());
|
let hook_called = Arc::new(Notify::new());
|
||||||
let hook_called_for_server = Arc::clone(&hook_called);
|
let hook_called_for_server = Arc::clone(&hook_called);
|
||||||
@@ -337,37 +448,38 @@ async fn executor_stop_hook_fixture() -> Result<ExecutorStopHookFixture> {
|
|||||||
&manifest_path,
|
&manifest_path,
|
||||||
serde_json::to_vec(&json!({
|
serde_json::to_vec(&json!({
|
||||||
"name": "computer-use",
|
"name": "computer-use",
|
||||||
"hooks": { "hooks": { "Stop": [{ "hooks": [{
|
// Separate entries must both survive registration in the same environment.
|
||||||
"type": "mcp_tool",
|
"hooks": [
|
||||||
"server": "node_repl",
|
{ "hooks": { "Interrupt": [{ "hooks": [{
|
||||||
"tool": "turn_ended",
|
"type": "mcp_tool",
|
||||||
"input": {
|
"server": "node_repl",
|
||||||
"hook_event_name": "${hook_event_name}",
|
"tool": "turn_ended",
|
||||||
"session_id": "${session_id}",
|
"input": {
|
||||||
"turn_id": "${turn_id}",
|
"hook_event_name": "${hook_event_name}",
|
||||||
},
|
"session_id": "${session_id}",
|
||||||
}] }] } },
|
"turn_id": "${turn_id}",
|
||||||
|
},
|
||||||
|
}] }] } },
|
||||||
|
{ "hooks": { "Stop": [{ "hooks": [{
|
||||||
|
"type": "mcp_tool",
|
||||||
|
"server": "node_repl",
|
||||||
|
"tool": "turn_ended",
|
||||||
|
"input": {
|
||||||
|
"hook_event_name": "${hook_event_name}",
|
||||||
|
"session_id": "${session_id}",
|
||||||
|
"turn_id": "${turn_id}",
|
||||||
|
},
|
||||||
|
}] }] } },
|
||||||
|
],
|
||||||
}))?,
|
}))?,
|
||||||
Default::default(),
|
Default::default(),
|
||||||
/*sandbox*/ None,
|
/*sandbox*/ None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let responses = mount_sse_sequence(
|
let responses = mount_response_sequence(&server, responses).await;
|
||||||
&server,
|
|
||||||
["first-turn", "second-turn"]
|
|
||||||
.map(|id| {
|
|
||||||
sse(vec![
|
|
||||||
ev_response_created(id),
|
|
||||||
ev_assistant_message(id, "done"),
|
|
||||||
ev_completed(id),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
.to_vec(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(ExecutorStopHookFixture {
|
Ok(ExecutorHookFixture {
|
||||||
server,
|
server,
|
||||||
test,
|
test,
|
||||||
responses,
|
responses,
|
||||||
@@ -375,14 +487,14 @@ async fn executor_stop_hook_fixture() -> Result<ExecutorStopHookFixture> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecutorStopHookFixture {
|
struct ExecutorHookFixture {
|
||||||
server: MockServer,
|
server: MockServer,
|
||||||
test: TestCodex,
|
test: TestCodex,
|
||||||
responses: ResponseMock,
|
responses: ResponseMock,
|
||||||
hook_called: Arc<Notify>,
|
hook_called: Arc<Notify>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExecutorStopHookFixture {
|
impl ExecutorHookFixture {
|
||||||
async fn attach(&self) -> Result<TurnEnvironmentSelection> {
|
async fn attach(&self) -> Result<TurnEnvironmentSelection> {
|
||||||
let selection = self
|
let selection = self
|
||||||
.test
|
.test
|
||||||
@@ -427,10 +539,23 @@ impl ExecutorStopHookFixture {
|
|||||||
Ok(selection)
|
Ok(selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn interrupt_running_command(&self) -> Result<()> {
|
||||||
|
wait_for_event(&self.test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::ExecCommandBegin(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
self.test.codex.submit(Op::Interrupt).await?;
|
||||||
|
wait_for_event(&self.test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnAborted(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn wait_for_hook_call(&self) -> Result<()> {
|
async fn wait_for_hook_call(&self) -> Result<()> {
|
||||||
tokio::time::timeout(Duration::from_secs(10), self.hook_called.notified())
|
tokio::time::timeout(Duration::from_secs(10), self.hook_called.notified())
|
||||||
.await
|
.await
|
||||||
.context("attached executor Stop hook should call turn_ended")?;
|
.context("attached executor hook should call turn_ended")?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ use codex_protocol::protocol::HookTrustStatus;
|
|||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -265,62 +266,73 @@ impl ClaudeHooksEngine {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
// FIXME: Remove this restriction once executor hooks support multiple environments.
|
// All events share the first environment, even if it has no handler for an event.
|
||||||
if executor_hooks.len() > 1 {
|
let Some(selected_environment_id) = executor_hooks
|
||||||
tracing::warn!(
|
.first()
|
||||||
executor_hook_count = executor_hooks.len(),
|
.map(|source| source.environment_id.clone())
|
||||||
"multiple executor hooks found; only executing the first"
|
else {
|
||||||
);
|
|
||||||
}
|
|
||||||
let Some(source) = executor_hooks.into_iter().next() else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(handler) = source
|
let environment_count = executor_hooks
|
||||||
.hooks
|
|
||||||
.stop
|
|
||||||
.into_iter()
|
|
||||||
.flat_map(|group| group.hooks)
|
|
||||||
.next()
|
|
||||||
else {
|
|
||||||
unreachable!("allowlisted executor hook source must contain a Stop handler");
|
|
||||||
};
|
|
||||||
let HookHandlerConfig::McpTool {
|
|
||||||
server,
|
|
||||||
tool,
|
|
||||||
input,
|
|
||||||
timeout_sec,
|
|
||||||
status_message,
|
|
||||||
} = handler
|
|
||||||
else {
|
|
||||||
unreachable!("allowlisted executor Stop handler must be an MCP tool");
|
|
||||||
};
|
|
||||||
|
|
||||||
let display_order = self
|
|
||||||
.handlers
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|handler| handler.display_order)
|
.map(|source| &source.environment_id)
|
||||||
.max()
|
.collect::<HashSet<_>>()
|
||||||
.map_or(0, |display_order| display_order.saturating_add(1));
|
.len();
|
||||||
self.handlers.push(ConfiguredHandler {
|
// FIXME: Remove this restriction once executor hooks support multiple environments.
|
||||||
event_name: HookEventName::Stop,
|
if environment_count > 1 {
|
||||||
matcher: None,
|
tracing::warn!(
|
||||||
timeout_sec: timeout_sec.unwrap_or(5).max(1),
|
executor_environment_count = environment_count,
|
||||||
status_message,
|
selected_environment_id = %selected_environment_id,
|
||||||
additional_context_limit: Default::default(),
|
"multiple executor environments found; only executing hooks in the first"
|
||||||
source_path: HandlerSourcePath::ExecutorScoped {
|
);
|
||||||
plugin_id: source.plugin_id,
|
}
|
||||||
environment_id: source.environment_id,
|
for source in executor_hooks {
|
||||||
manifest_path: source.manifest_path,
|
if source.environment_id != selected_environment_id {
|
||||||
source_relative_path: source.source_relative_path,
|
continue;
|
||||||
},
|
}
|
||||||
source: HookSource::Plugin,
|
for (event_name, groups) in source.hooks.into_matcher_groups() {
|
||||||
display_order,
|
let Some(handler) = groups.into_iter().flat_map(|group| group.hooks).next() else {
|
||||||
kind: ConfiguredHandlerKind::McpTool {
|
continue;
|
||||||
server,
|
};
|
||||||
tool,
|
let HookHandlerConfig::McpTool {
|
||||||
input,
|
server,
|
||||||
},
|
tool,
|
||||||
});
|
input,
|
||||||
|
timeout_sec,
|
||||||
|
status_message,
|
||||||
|
} = handler
|
||||||
|
else {
|
||||||
|
unreachable!("allowlisted executor handler must be an MCP tool");
|
||||||
|
};
|
||||||
|
|
||||||
|
let display_order = self
|
||||||
|
.handlers
|
||||||
|
.iter()
|
||||||
|
.map(|handler| handler.display_order)
|
||||||
|
.max()
|
||||||
|
.map_or(0, |display_order| display_order.saturating_add(1));
|
||||||
|
self.handlers.push(ConfiguredHandler {
|
||||||
|
event_name,
|
||||||
|
matcher: None,
|
||||||
|
timeout_sec: timeout_sec.unwrap_or(5).max(1),
|
||||||
|
status_message,
|
||||||
|
additional_context_limit: Default::default(),
|
||||||
|
source_path: HandlerSourcePath::ExecutorScoped {
|
||||||
|
plugin_id: source.plugin_id.clone(),
|
||||||
|
environment_id: source.environment_id.clone(),
|
||||||
|
manifest_path: source.manifest_path.clone(),
|
||||||
|
source_relative_path: source.source_relative_path.clone(),
|
||||||
|
},
|
||||||
|
source: HookSource::Plugin,
|
||||||
|
display_order,
|
||||||
|
kind: ConfiguredHandlerKind::McpTool {
|
||||||
|
server,
|
||||||
|
tool,
|
||||||
|
input,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn required_load_errors(&self) -> &[String] {
|
pub(crate) fn required_load_errors(&self) -> &[String] {
|
||||||
|
|||||||
@@ -2219,6 +2219,58 @@ fn executor_stop_hooks_register_only_the_first_environment_and_handler() {
|
|||||||
assert_eq!(engine.handlers, expected_handlers);
|
assert_eq!(engine.handlers, expected_handlers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn executor_interrupt_hooks_register_and_run() {
|
||||||
|
let (mut engine, calls, stop_request, expected_stop_call, mut source) =
|
||||||
|
executor_stop_hook_fixture();
|
||||||
|
source.hooks.interrupt = source.hooks.stop.clone();
|
||||||
|
engine.set_executor_hooks(vec![source]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
engine
|
||||||
|
.handlers
|
||||||
|
.iter()
|
||||||
|
.map(|handler| handler.event_name)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![HookEventName::Stop, HookEventName::Interrupt]
|
||||||
|
);
|
||||||
|
assert_eq!(engine.preview_interrupt(), Vec::new());
|
||||||
|
|
||||||
|
let outcome = engine
|
||||||
|
.run_interrupt(InterruptRequest {
|
||||||
|
session_id: stop_request.session_id,
|
||||||
|
turn_id: stop_request.turn_id,
|
||||||
|
cwd: stop_request.cwd,
|
||||||
|
transcript_path: stop_request.transcript_path,
|
||||||
|
model: stop_request.model,
|
||||||
|
permission_mode: stop_request.permission_mode,
|
||||||
|
request_metadata: stop_request.request_metadata,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
wait_for_mcp_calls(&calls, /*count*/ 1).await;
|
||||||
|
|
||||||
|
assert!(outcome.hook_events.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
*calls.lock().expect("lock MCP calls"),
|
||||||
|
vec![expected_stop_call]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn executor_hooks_use_one_environment_for_all_events() {
|
||||||
|
let (mut engine, _, _, _, mut first_source) = executor_stop_hook_fixture();
|
||||||
|
first_source.hooks.interrupt = std::mem::take(&mut first_source.hooks.stop);
|
||||||
|
engine.set_executor_hooks(vec![first_source.clone()]);
|
||||||
|
let expected_handlers = engine.handlers.clone();
|
||||||
|
let mut second_source = first_source.clone();
|
||||||
|
second_source.environment_id = "executor-b".to_string();
|
||||||
|
second_source.hooks.stop = std::mem::take(&mut second_source.hooks.interrupt);
|
||||||
|
|
||||||
|
engine.set_executor_hooks(vec![first_source, second_source]);
|
||||||
|
|
||||||
|
assert_eq!(engine.handlers, expected_handlers);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn memory_consolidation_stop_preserves_policy_and_executor_cleanup() {
|
async fn memory_consolidation_stop_preserves_policy_and_executor_cleanup() {
|
||||||
for (source, runs_policy) in [
|
for (source, runs_policy) in [
|
||||||
@@ -2482,6 +2534,7 @@ async fn mcp_interrupt_hooks_expand_event_input_and_bound_timeout() {
|
|||||||
transcript_path: None,
|
transcript_path: None,
|
||||||
model: "gpt-test".to_string(),
|
model: "gpt-test".to_string(),
|
||||||
permission_mode: "default".to_string(),
|
permission_mode: "default".to_string(),
|
||||||
|
request_metadata: None,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ use codex_protocol::protocol::HookOutputEntryKind;
|
|||||||
use codex_protocol::protocol::HookRunStatus;
|
use codex_protocol::protocol::HookRunStatus;
|
||||||
use codex_protocol::protocol::HookRunSummary;
|
use codex_protocol::protocol::HookRunSummary;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
|
use serde_json::Map;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::common;
|
use super::common;
|
||||||
use crate::engine::ClaudeHooksEngine;
|
use crate::engine::ClaudeHooksEngine;
|
||||||
use crate::engine::ConfiguredHandler;
|
use crate::engine::ConfiguredHandler;
|
||||||
use crate::engine::HandlerRunResult;
|
use crate::engine::HandlerRunResult;
|
||||||
|
use crate::engine::HandlerSourcePath;
|
||||||
use crate::engine::dispatcher;
|
use crate::engine::dispatcher;
|
||||||
use crate::engine::output_parser;
|
use crate::engine::output_parser;
|
||||||
use crate::schema::InterruptCommandInput;
|
use crate::schema::InterruptCommandInput;
|
||||||
@@ -26,6 +29,7 @@ pub struct InterruptRequest {
|
|||||||
pub transcript_path: Option<PathBuf>,
|
pub transcript_path: Option<PathBuf>,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub permission_mode: String,
|
pub permission_mode: String,
|
||||||
|
pub request_metadata: Option<Map<String, Value>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -43,6 +47,8 @@ pub(crate) fn preview(handlers: &[ConfiguredHandler]) -> Vec<HookRunSummary> {
|
|||||||
/*matcher_input*/ None,
|
/*matcher_input*/ None,
|
||||||
)
|
)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
// Executor-scoped hooks run asynchronously and do not emit public hook lifecycle events.
|
||||||
|
.filter(|handler| matches!(handler.source_path, HandlerSourcePath::Local(_)))
|
||||||
.map(|handler| dispatcher::running_summary(&handler))
|
.map(|handler| dispatcher::running_summary(&handler))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -64,6 +70,7 @@ pub(crate) async fn run(engine: &ClaudeHooksEngine, request: InterruptRequest) -
|
|||||||
transcript_path,
|
transcript_path,
|
||||||
model,
|
model,
|
||||||
permission_mode,
|
permission_mode,
|
||||||
|
request_metadata,
|
||||||
} = request;
|
} = request;
|
||||||
let input_json = match serde_json::to_string(&InterruptCommandInput {
|
let input_json = match serde_json::to_string(&InterruptCommandInput {
|
||||||
session_id: session_id.to_string(),
|
session_id: session_id.to_string(),
|
||||||
@@ -86,12 +93,13 @@ pub(crate) async fn run(engine: &ClaudeHooksEngine, request: InterruptRequest) -
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let results = dispatcher::execute_handlers(
|
let results = dispatcher::execute_handlers_with_metadata(
|
||||||
engine,
|
engine,
|
||||||
matched,
|
matched,
|
||||||
input_json,
|
input_json,
|
||||||
cwd.as_path(),
|
cwd.as_path(),
|
||||||
Some(turn_id),
|
Some(turn_id),
|
||||||
|
request_metadata.as_ref(),
|
||||||
parse_completed,
|
parse_completed,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
Reference in new issue
Block a user