From 9d14b483ec60bbd1afc63f5712d5432c8f444f0c Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 3 Jun 2026 16:31:18 -0700 Subject: [PATCH] Activate prompt hooks --- .../request_processors/config_processor.rs | 2 +- codex-rs/config/src/hook_config.rs | 12 +- codex-rs/core/config.schema.json | 22 +++ codex-rs/core/src/client.rs | 21 +- codex-rs/core/src/client_tests.rs | 19 ++ codex-rs/core/src/hook_prompt.rs | 168 ++++++++++++++++ codex-rs/core/src/hook_runtime.rs | 30 ++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/tests/suite/hooks.rs | 97 ++++++++++ codex-rs/hooks/src/declarations.rs | 8 +- codex-rs/hooks/src/engine/discovery.rs | 180 +++++++++++++++++- codex-rs/hooks/src/engine/mod.rs | 15 +- codex-rs/hooks/src/registry.rs | 8 + 13 files changed, 564 insertions(+), 19 deletions(-) create mode 100644 codex-rs/core/src/hook_prompt.rs diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index b6dbb30b0834..01a80264b69d 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -465,7 +465,7 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand r#async, status_message, }, - CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {}, + CoreHookHandlerConfig::Prompt { .. } => ConfiguredHookHandler::Prompt {}, CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {}, } } diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 8e18cb935a4f..5f9bdde00c74 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -153,7 +153,17 @@ pub enum HookHandlerConfig { status_message: Option, }, #[serde(rename = "prompt")] - Prompt {}, + Prompt { + prompt: String, + #[serde(default)] + model: Option, + #[serde(default, rename = "timeout", alias = "timeoutSec")] + timeout_sec: Option, + #[serde(default, rename = "statusMessage")] + status_message: Option, + #[serde(default, rename = "continueOnBlock")] + continue_on_block: bool, + }, #[serde(rename = "agent")] Agent {}, } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 4994d50ba1cb..8332ec41994f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1210,6 +1210,27 @@ }, { "properties": { + "continueOnBlock": { + "default": false, + "type": "boolean" + }, + "model": { + "default": null, + "type": "string" + }, + "prompt": { + "type": "string" + }, + "statusMessage": { + "default": null, + "type": "string" + }, + "timeout": { + "default": null, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, "type": { "enum": [ "prompt" @@ -1218,6 +1239,7 @@ } }, "required": [ + "prompt", "type" ], "type": "object" diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 251b7c1dcf9d..b8cd46bdc7a2 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -272,6 +272,7 @@ pub struct ModelClient { pub struct ModelClientSession { client: ModelClient, websocket_session: WebsocketSession, + cache_websocket_session_on_drop: bool, /// Turn state for sticky routing. /// /// This is an `OnceLock` that stores the turn state value received from the server @@ -480,6 +481,18 @@ impl ModelClient { ModelClientSession { client: self.clone(), websocket_session: self.take_cached_websocket_session(), + cache_websocket_session_on_drop: true, + turn_state: Arc::new(OnceLock::new()), + } + } + + /// Creates a fresh streaming session that does not read or write the session's cached + /// WebSocket state. + pub(crate) fn new_isolated_session(&self) -> ModelClientSession { + ModelClientSession { + client: self.clone(), + websocket_session: WebsocketSession::default(), + cache_websocket_session_on_drop: false, turn_state: Arc::new(OnceLock::new()), } } @@ -1107,9 +1120,11 @@ impl ModelClient { impl Drop for ModelClientSession { fn drop(&mut self) { - let websocket_session = std::mem::take(&mut self.websocket_session); - self.client - .store_cached_websocket_session(websocket_session); + if self.cache_websocket_session_on_drop { + let websocket_session = std::mem::take(&mut self.websocket_session); + self.client + .store_cached_websocket_session(websocket_session); + } } } diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index ddf9c4ebec10..c83da8936db5 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -4,6 +4,7 @@ use super::ModelClient; use super::PendingUnauthorizedRetry; use super::Prompt; use super::UnauthorizedRecoveryExecution; +use super::WebsocketSession; use super::X_CODEX_INSTALLATION_ID_HEADER; use super::X_CODEX_PARENT_THREAD_ID_HEADER; use super::X_CODEX_TURN_METADATA_HEADER; @@ -276,6 +277,24 @@ fn test_model_info() -> ModelInfo { .expect("deserialize test model info") } +#[test] +fn isolated_session_does_not_touch_cached_websocket_state() { + let model_client = test_model_client(SessionSource::Cli); + let cached_websocket_session = WebsocketSession::default(); + cached_websocket_session.set_connection_reused(/*connection_reused*/ true); + model_client.store_cached_websocket_session(cached_websocket_session); + + let isolated_session = model_client.new_isolated_session(); + assert!(!isolated_session.websocket_session.connection_reused()); + drop(isolated_session); + + assert!( + model_client + .take_cached_websocket_session() + .connection_reused() + ); +} + fn test_session_telemetry() -> SessionTelemetry { SessionTelemetry::new( ThreadId::new(), diff --git a/codex-rs/core/src/hook_prompt.rs b/codex-rs/core/src/hook_prompt.rs new file mode 100644 index 000000000000..0f0c0703d211 --- /dev/null +++ b/codex-rs/core/src/hook_prompt.rs @@ -0,0 +1,168 @@ +use std::sync::Arc; + +use codex_hooks::PromptHookRequest; +use codex_hooks::PromptHookRunner; +use codex_models_manager::ModelsManagerConfig; +use codex_models_manager::manager::SharedModelsManager; +use codex_otel::SessionTelemetry; +use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::models::BaseInstructions; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_rollout_trace::InferenceTraceContext; +use futures::StreamExt; +use serde_json::json; + +use crate::client::ModelClient; +use crate::client_common::Prompt; +use crate::client_common::ResponseEvent; +use crate::config::Config; +use crate::responses_metadata::CodexResponsesMetadata; +use crate::stream_events_utils::raw_assistant_output_text_from_item; + +const PROMPT_HOOK_BASE_INSTRUCTIONS: &str = r#"You evaluate a Codex prompt hook. + +The user message contains: +1. The hook author's instructions. +2. The hook input JSON. + +Decide whether the hook input satisfies the hook author's instructions. + +Return only JSON: +{"ok": true} +or +{"ok": false, "reason": "concise actionable reason"} + +Use ok:false only when the hook criteria fail. Do not answer the user's task. Do not include Markdown or extra text."#; + +pub(crate) fn build_prompt_hook_runner( + model_client: ModelClient, + models_manager: SharedModelsManager, + config: &Config, + session_telemetry: SessionTelemetry, + service_tier: Option, + responses_metadata: CodexResponsesMetadata, +) -> PromptHookRunner { + let models_manager_config = config.to_models_manager_config(); + PromptHookRunner::new(move |request| { + let model_client = model_client.clone(); + let models_manager = Arc::clone(&models_manager); + let models_manager_config = models_manager_config.clone(); + let session_telemetry = session_telemetry.clone(); + let service_tier = service_tier.clone(); + let responses_metadata = responses_metadata.clone(); + async move { + run_prompt_hook( + model_client, + models_manager, + models_manager_config, + session_telemetry, + service_tier, + responses_metadata, + request, + ) + .await + } + }) +} + +/// Run the hook as an isolated model request: resolve the configured model, +/// send the rendered hook prompt as the only user message, disable all +/// tools and reasoning summaries, and constrain the final answer to the +/// small `{ ok, reason }` contract that `codex-hooks` maps back into existing +/// hook output semantics. +async fn run_prompt_hook( + model_client: ModelClient, + models_manager: SharedModelsManager, + models_manager_config: ModelsManagerConfig, + session_telemetry: SessionTelemetry, + service_tier: Option, + responses_metadata: CodexResponsesMetadata, + request: PromptHookRequest, +) -> anyhow::Result { + let model_info = models_manager + .get_model_info(request.model.as_str(), &models_manager_config) + .await; + let prompt = Prompt { + input: vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: request.prompt, + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }], + tools: Vec::new(), + parallel_tool_calls: false, + base_instructions: BaseInstructions { + text: PROMPT_HOOK_BASE_INSTRUCTIONS.to_string(), + }, + output_schema: Some(prompt_hook_output_schema()), + output_schema_strict: false, + }; + + let disabled_trace = InferenceTraceContext::disabled(); + let mut client_session = model_client.new_isolated_session(); + let mut stream = client_session + .stream( + &prompt, + &model_info, + &session_telemetry, + /*effort*/ None, + ReasoningSummaryConfig::None, + service_tier, + &responses_metadata, + &disabled_trace, + ) + .await?; + let mut delta_text = String::new(); + let mut item_texts = Vec::new(); + while let Some(event) = stream.next().await { + match event? { + ResponseEvent::OutputItemDone(item) => { + if let Some(text) = raw_assistant_output_text_from_item(&item) { + item_texts.push(text); + } + } + ResponseEvent::OutputTextDelta(delta) => delta_text.push_str(delta.as_str()), + ResponseEvent::Completed { .. } => break, + ResponseEvent::Created + | ResponseEvent::SafetyBuffering(_) + | ResponseEvent::TurnModerationMetadata(_) + | ResponseEvent::ReasoningSummaryDone { .. } + | ResponseEvent::OutputItemAdded(_) + | ResponseEvent::ServerModel(_) + | ResponseEvent::ModelVerifications(_) + | ResponseEvent::ServerReasoningIncluded(_) + | ResponseEvent::ToolCallInputDelta { .. } + | ResponseEvent::ReasoningSummaryDelta { .. } + | ResponseEvent::ReasoningContentDelta { .. } + | ResponseEvent::ReasoningSummaryPartAdded { .. } + | ResponseEvent::RateLimits(_) + | ResponseEvent::ModelsEtag(_) => {} + } + } + + if item_texts.is_empty() { + Ok(delta_text) + } else { + Ok(item_texts.join("")) + } +} + +fn prompt_hook_output_schema() -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "reason": { + "type": "string" + } + }, + "required": ["ok"], + "additionalProperties": false + }) +} diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 342e4d4b8f44..2ca6e3079954 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -5,6 +5,7 @@ use std::time::Duration; use codex_analytics::CompactionTrigger; use codex_analytics::HookRunFact; use codex_analytics::build_track_events_context; +use codex_hooks::Hooks; use codex_hooks::PermissionRequestDecision; use codex_hooks::PermissionRequestOutcome; use codex_hooks::PermissionRequestRequest; @@ -42,6 +43,7 @@ use tracing::instrument; use crate::context::ContextualUserFragment; use crate::context::HookAdditionalContext; use crate::event_mapping::parse_turn_item; +use crate::responses_metadata::CodexResponsesRequestKind; use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -99,6 +101,24 @@ impl From for ContextInjectingHookOutcome { } } +async fn hooks_with_prompt_runner(sess: &Arc, turn_context: &Arc) -> Hooks { + let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata( + sess.installation_id.clone(), + sess.current_window_id().await, + CodexResponsesRequestKind::Turn, + ); + let prompt_hook_runner = crate::hook_prompt::build_prompt_hook_runner( + sess.services.model_client.clone(), + Arc::clone(&sess.services.models_manager), + turn_context.config.as_ref(), + turn_context.session_telemetry.clone(), + turn_context.config.service_tier.clone(), + responses_metadata, + ); + sess.hooks() + .clone_with_prompt_hook_runner(prompt_hook_runner) +} + #[instrument(level = "trace", skip_all)] pub(crate) async fn run_pending_session_start_hooks( sess: &Arc, @@ -181,7 +201,7 @@ pub(crate) async fn run_pre_tool_use_hooks( tool_use_id, tool_input: tool_input.clone(), }; - let hooks = sess.hooks(); + let hooks = hooks_with_prompt_runner(sess, turn_context).await; let preview_runs = hooks.preview_pre_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; @@ -242,7 +262,7 @@ pub(crate) async fn run_permission_request_hooks( run_id_suffix: run_id_suffix.to_string(), tool_input: payload.tool_input, }; - let hooks = sess.hooks(); + let hooks = hooks_with_prompt_runner(sess, turn_context).await; let preview_runs = hooks.preview_permission_request(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; @@ -285,7 +305,7 @@ pub(crate) async fn run_post_tool_use_hooks( tool_input, tool_response, }; - let hooks = sess.hooks(); + let hooks = hooks_with_prompt_runner(sess, turn_context).await; let preview_runs = hooks.preview_post_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; @@ -357,7 +377,7 @@ pub(crate) async fn run_turn_stop_hooks( last_assistant_message, target, }; - let hooks = sess.hooks(); + let hooks = hooks_with_prompt_runner(sess, turn_context).await; emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await; let mut outcome = hooks.run_stop(request).await; @@ -515,7 +535,7 @@ pub(crate) async fn inspect_pending_input( permission_mode: hook_permission_mode(turn_context), prompt: UserMessageItem::new(content).message(), }; - let hooks = sess.hooks(); + let hooks = hooks_with_prompt_runner(sess, turn_context).await; let preview_runs = hooks.preview_user_prompt_submit(&request); run_context_injecting_hook( sess, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index cfc26c571164..a869390e11e3 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -49,6 +49,7 @@ mod exec_policy; #[cfg(test)] mod git_info_tests; mod guardian; +mod hook_prompt; mod hook_runtime; mod image_preparation; mod installation_id; diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index a5c82d5767ec..185c38576483 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -46,6 +46,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; @@ -259,6 +260,23 @@ if payload.get("prompt") == {blocked_prompt_json}: Ok(()) } +fn write_user_prompt_submit_prompt_hook(home: &Path) -> Result<()> { + let hooks = serde_json::json!({ + "hooks": { + "UserPromptSubmit": [{ + "hooks": [{ + "type": "prompt", + "prompt": "Reject prompts that mention secrets: $ARGUMENTS", + "statusMessage": "checking prompt", + }] + }] + } + }); + + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_session_start_and_user_prompt_submit_order_hooks(home: &Path) -> Result<()> { let session_start_script_path = home.join("session_start_order_hook.py"); let user_prompt_submit_script_path = home.join("user_prompt_submit_order_hook.py"); @@ -1705,6 +1723,85 @@ async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() - Ok(()) } +#[tokio::test] +async fn user_prompt_submit_prompt_hook_runs_isolated_request() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("prompt-hook-resp"), + ev_assistant_message( + "prompt-hook-msg", + r#"{"ok":false,"reason":"do not mention secrets"}"#, + ), + ev_completed("prompt-hook-resp"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_model("gpt-5.4") + .with_pre_build_hook(|home| { + if let Err(error) = write_user_prompt_submit_prompt_hook(home) { + panic!("failed to write user prompt submit prompt hook fixture: {error}"); + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + let prompt = "secret launch codes"; + + test.submit_turn(prompt).await?; + + let request = response.single_request(); + let body = request.body_json(); + let turn_metadata: Value = serde_json::from_str( + body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .context("prompt hook turn metadata")?, + )?; + assert_eq!(turn_metadata["request_kind"], json!("turn")); + assert!( + turn_metadata["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()), + "prompt hook request should carry the current turn id", + ); + assert_eq!( + turn_metadata["window_id"].as_str(), + request.header("x-codex-window-id").as_deref(), + "prompt hook request should carry the current context window id", + ); + assert_eq!(body["model"], json!("gpt-5.4")); + assert_eq!(body["tools"], json!([])); + assert!( + request + .instructions_text() + .starts_with("You evaluate a Codex prompt hook."), + "prompt hook request should use isolated hook instructions", + ); + let user_inputs = request.message_input_texts("user"); + assert_eq!( + user_inputs.len(), + 1, + "prompt hook request should contain exactly one user message", + ); + assert_eq!( + request.message_input_texts("developer"), + Vec::::new(), + "prompt hook request should not inherit developer context", + ); + let hook_input = user_inputs.first().context("prompt hook user input")?; + assert!(hook_input.contains("Reject prompts that mention secrets:")); + assert!( + hook_input.contains(prompt), + "prompt hook request should include the submitted prompt", + ); + + Ok(()) +} + #[tokio::test] async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/src/declarations.rs b/codex-rs/hooks/src/declarations.rs index 468fbc947a79..19b5b09c4f69 100644 --- a/codex-rs/hooks/src/declarations.rs +++ b/codex-rs/hooks/src/declarations.rs @@ -62,7 +62,13 @@ mod tests { pre_tool_use: vec![MatcherGroup { matcher: None, hooks: vec![ - HookHandlerConfig::Prompt {}, + HookHandlerConfig::Prompt { + prompt: "check $ARGUMENTS".to_string(), + model: None, + timeout_sec: None, + status_message: None, + continue_on_block: false, + }, HookHandlerConfig::Command { command: "echo hi".to_string(), command_windows: None, diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index ee32674850cd..899e1f354597 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -25,9 +25,12 @@ use serde::Serialize; use super::ConfiguredHandler; use super::ConfiguredHandlerKind; use super::HookListEntry; +use super::prompt_runner::PromptHookBehavior; +use super::prompt_runner::prompt_hook_behavior; use crate::config_rules::hook_states_from_stack; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; +use crate::schema::hook_event_wire_name; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookSource; @@ -513,6 +516,9 @@ fn append_matcher_groups( handler_type: HookHandlerType::Command, matcher: matcher.map(ToOwned::to_owned), command: Some(command.clone()), + prompt: None, + model: None, + continue_on_block: None, timeout_sec, status_message: status_message.clone(), source_path: source.path.clone(), @@ -547,10 +553,92 @@ fn append_matcher_groups( } *display_order += 1; } - HookHandlerConfig::Prompt {} => warnings.push(format!( - "skipping prompt hook in {}: prompt hooks are not supported yet", - source.path.display() - )), + HookHandlerConfig::Prompt { + prompt, + model, + timeout_sec, + status_message, + continue_on_block, + } => { + if matches!( + prompt_hook_behavior(event_name), + PromptHookBehavior::Unsupported + ) { + warnings.push(format!( + "skipping prompt hook in {}: prompt hooks are not supported for {}", + source.path.display(), + hook_event_wire_name(event_name) + )); + continue; + } + if prompt.trim().is_empty() { + warnings.push(format!( + "skipping empty hook prompt in {}", + source.path.display() + )); + continue; + } + let timeout_sec = timeout_sec.unwrap_or(30).max(1); + let normalized_handler = HookHandlerConfig::Prompt { + prompt: prompt.clone(), + model: model.clone(), + timeout_sec: Some(timeout_sec), + status_message: status_message.clone(), + continue_on_block, + }; + let current_hash = hook_hash(event_name, matcher, &group, normalized_handler); + let key = + crate::hook_key(&source.key_source, event_name, group_index, handler_index); + let state = source.hook_states.get(&key); + let enabled = hook_enabled(source.is_managed, state); + let trusted_hash = hook_trusted_hash(source.is_managed, state); + let trust_status = + hook_trust_status(source.is_managed, ¤t_hash, trusted_hash); + hook_entries.push(HookListEntry { + key, + event_name, + handler_type: HookHandlerType::Prompt, + matcher: matcher.map(ToOwned::to_owned), + command: None, + prompt: Some(prompt.clone()), + model: model.clone(), + continue_on_block: Some(continue_on_block), + timeout_sec, + status_message: status_message.clone(), + source_path: source.path.clone(), + source: source.source, + plugin_id: source.plugin_id.clone(), + display_order: *display_order, + enabled, + is_managed: source.is_managed, + current_hash, + trust_status, + }); + if enabled + && (source.bypass_hook_trust + || matches!( + trust_status, + HookTrustStatus::Managed | HookTrustStatus::Trusted + )) + { + handlers.push(ConfiguredHandler { + event_name, + matcher: matcher.map(ToOwned::to_owned), + kind: ConfiguredHandlerKind::Prompt { + prompt, + model, + timeout_sec, + continue_on_block, + }, + status_message, + source_path: source.path.clone(), + source: source.source, + display_order: *display_order, + env: source.env.clone(), + }); + } + *display_order += 1; + } HookHandlerConfig::Agent {} => warnings.push(format!( "skipping agent hook in {}: agent hooks are not supported yet", source.path.display() @@ -673,6 +761,7 @@ mod tests { use codex_config::HookStateToml; use codex_config::MatcherGroup; use codex_config::TomlValue; + use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookTrustStatus; fn source_path() -> AbsolutePathBuf { @@ -764,6 +853,19 @@ mod tests { } } + fn prompt_group(matcher: Option<&str>) -> MatcherGroup { + MatcherGroup { + matcher: matcher.map(str::to_string), + hooks: vec![HookHandlerConfig::Prompt { + prompt: "Check this hook input: $ARGUMENTS".to_string(), + model: Some("gpt-5-mini".to_string()), + timeout_sec: None, + status_message: Some("Checking prompt hook".to_string()), + continue_on_block: true, + }], + } + } + #[test] fn user_prompt_submit_ignores_invalid_matcher_during_discovery() { let mut handlers = Vec::new(); @@ -951,6 +1053,76 @@ mod tests { assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write")); } + #[test] + fn permission_request_prompt_hook_is_discovered_as_supported() { + 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, + &hook_handler_source(&source_path, &hook_states), + HookEventName::PermissionRequest, + vec![prompt_group(Some(".*"))], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers.len(), 1); + assert_eq!(hook_entries.len(), 1); + assert_eq!(hook_entries[0].handler_type, HookHandlerType::Prompt); + assert_eq!(hook_entries[0].command, None); + assert_eq!( + hook_entries[0].prompt.as_deref(), + Some("Check this hook input: $ARGUMENTS") + ); + assert_eq!(hook_entries[0].timeout_sec, 30); + assert_eq!( + handlers[0].kind, + ConfiguredHandlerKind::Prompt { + prompt: "Check this hook input: $ARGUMENTS".to_string(), + model: Some("gpt-5-mini".to_string()), + timeout_sec: 30, + continue_on_block: true, + } + ); + } + + #[test] + fn unsupported_prompt_hook_is_skipped_during_discovery() { + 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, + &hook_handler_source(&source_path, &hook_states), + HookEventName::SessionStart, + vec![prompt_group(/*matcher*/ None)], + ); + + assert_eq!(handlers, Vec::::new()); + assert_eq!(hook_entries.len(), 0); + assert_eq!( + warnings, + vec![format!( + "skipping prompt hook in {}: prompt hooks are not supported for SessionStart", + source_path.display() + )] + ); + } + #[test] fn toml_hook_discovery_ignores_malformed_state_entries() { let layer = ConfigLayerEntry::new( diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 2b2ce0d3ed2d..e7552db5cda3 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -60,10 +60,6 @@ pub(crate) enum ConfiguredHandlerKind { command: String, timeout_sec: u64, }, - #[allow( - dead_code, - reason = "constructed by prompt-hook discovery in the follow-up" - )] Prompt { prompt: String, model: Option, @@ -127,6 +123,9 @@ pub struct HookListEntry { pub handler_type: HookHandlerType, pub matcher: Option, pub command: Option, + pub prompt: Option, + pub model: Option, + pub continue_on_block: Option, pub timeout_sec: u64, pub status_message: Option, pub source_path: AbsolutePathBuf, @@ -183,6 +182,14 @@ impl ClaudeHooksEngine { output_spiller: HookOutputSpiller::new(), } } + pub(crate) fn clone_with_prompt_hook_runner( + &self, + prompt_hook_runner: PromptHookRunner, + ) -> Self { + let mut engine = self.clone(); + engine.prompt_hook_runner = Some(prompt_hook_runner); + engine + } pub(crate) fn warnings(&self) -> &[String] { &self.warnings diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index b47094590a47..72e2eff33be2 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -83,6 +83,14 @@ impl Hooks { engine, } } + pub fn clone_with_prompt_hook_runner(&self, prompt_hook_runner: PromptHookRunner) -> Self { + Self { + after_agent: self.after_agent.clone(), + engine: self + .engine + .clone_with_prompt_hook_runner(prompt_hook_runner), + } + } pub fn startup_warnings(&self) -> &[String] { self.engine.warnings()