use std::future::Future; use std::sync::Arc; use std::time::Duration; use codex_analytics::CompactionTrigger; use codex_analytics::HookRunFact; use codex_analytics::build_track_events_context; use codex_connectors::AppToolPolicyEvaluator; use codex_connectors::AppToolPolicyInput; use codex_core_plugins::executor_plugin_hook_sources; use codex_hooks::InterruptRequest; use codex_hooks::PermissionRequestDecision; use codex_hooks::PermissionRequestOutcome; use codex_hooks::PermissionRequestRequest; use codex_hooks::PostToolUseOutcome; use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; use codex_hooks::PreToolUseRequest; use codex_hooks::SessionStartOutcome; use codex_hooks::StartHookTarget; use codex_hooks::StopHookTarget; use codex_hooks::StopOutcome; use codex_hooks::SubagentHookContext; use codex_hooks::UserPromptSubmitOutcome; use codex_hooks::UserPromptSubmitRequest; use codex_hooks::hook_execution_mode_label; use codex_hooks::hook_handler_type_label; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_otel::HOOK_RUN_DURATION_METRIC; use codex_otel::HOOK_RUN_METRIC; use codex_plugin::ExecutorPluginHookSource; use codex_protocol::items::FunctionCallOutputItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookExecutionMode; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::HookStartedEvent; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::WarningEvent; use codex_rollout::state_db; use codex_thread_store::PersistContext; use codex_thread_store::ReadThreadParams; use serde_json::Map; use serde_json::Value; use tokio::sync::Mutex; use tracing::instrument; use crate::context::ContextualUserFragment; use crate::context::HookAdditionalContext; use crate::event_mapping::parse_turn_item; use crate::session::TurnInput; use crate::session::session::Session; use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::state::TurnState; use crate::tools::hook_names::HookToolName; use crate::tools::sandboxing::PermissionRequestPayload; use crate::turn_metadata::McpTurnMetadataContext; pub(crate) struct HookRuntimeOutcome { pub should_stop: bool, pub additional_contexts: Vec, } pub(crate) enum PreToolUseHookResult { Continue { updated_input: Option }, Blocked(String), } struct ContextInjectingHookOutcome { hook_events: Vec, outcome: HookRuntimeOutcome, } impl From for ContextInjectingHookOutcome { fn from(value: SessionStartOutcome) -> Self { let SessionStartOutcome { hook_events, should_stop, stop_reason: _, additional_contexts, } = value; Self { hook_events, outcome: HookRuntimeOutcome { should_stop, additional_contexts, }, } } } impl From for ContextInjectingHookOutcome { fn from(value: UserPromptSubmitOutcome) -> Self { let UserPromptSubmitOutcome { hook_events, should_stop, stop_reason: _, additional_contexts, } = value; Self { hook_events, outcome: HookRuntimeOutcome { should_stop, additional_contexts, }, } } } #[instrument(level = "trace", skip_all)] pub(crate) async fn run_pending_session_start_hooks( sess: &Arc, turn_context: &Arc, ) -> bool { while let Some(session_start_source) = sess.take_pending_session_start_source().await { // Pending session-start hooks are reused to dispatch thread-spawn subagent // starts. Other subagent sessions are internal/system work and do not run // start hooks. let target = match &turn_context.session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) if matches!( session_start_source, codex_hooks::SessionStartSource::Startup ) => { let context = subagent_hook_context(sess, agent_role); StartHookTarget::SubagentStart { turn_id: turn_context.sub_id.clone(), agent_id: context.agent_id, agent_type: context.agent_type, } } SessionSource::SubAgent(_) => return false, _ => StartHookTarget::SessionStart { source: session_start_source, }, }; let request = codex_hooks::SessionStartRequest { session_id: sess.session_id().into(), #[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), target, }; let hooks = sess.hooks(); let preview_runs = hooks.preview_session_start(&request); if run_context_injecting_hook( sess, turn_context, preview_runs, hooks.run_session_start(request, Some(turn_context.sub_id.clone())), ) .await .record_additional_contexts(sess, turn_context) .await { return true; } } false } /// Runs matching `PreToolUse` hooks before a tool executes. /// /// `tool_name` is the canonical name serialized to hook stdin. Matcher aliases /// are internal compatibility names used only for selecting configured hook /// handlers. pub(crate) async fn run_pre_tool_use_hooks( sess: &Arc, turn_context: &Arc, tool_use_id: String, tool_name: &HookToolName, tool_input: &Value, ) -> PreToolUseHookResult { let request = PreToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[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), tool_name: tool_name.name().to_string(), matcher_aliases: tool_name.matcher_aliases().to_vec(), tool_use_id, tool_input: tool_input.clone(), }; let hooks = sess.hooks(); let preview_runs = hooks.preview_pre_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; let PreToolUseOutcome { hook_events, should_block, block_reason, additional_contexts, updated_input, } = hooks.run_pre_tool_use(request).await; emit_hook_completed_events(sess, turn_context, hook_events).await; record_additional_contexts(sess, turn_context, additional_contexts).await; if !should_block { return PreToolUseHookResult::Continue { updated_input }; } let Some(reason) = block_reason else { return PreToolUseHookResult::Continue { updated_input: None, }; }; if (tool_name.name() == "Bash" || tool_name.name() == "apply_patch") && let Some(command) = tool_input.get("command").and_then(Value::as_str) { PreToolUseHookResult::Blocked(format!( "Command blocked by PreToolUse hook: {reason}. Command: {command}" )) } else { PreToolUseHookResult::Blocked(format!( "Tool call blocked by PreToolUse hook: {reason}. Tool: {}", tool_name.name() )) } } // PermissionRequest hooks share the same preview/start/completed event flow as // other hook types, but they return an optional decision instead of mutating // tool input or post-run state. pub(crate) async fn run_permission_request_hooks( sess: &Arc, turn_context: &Arc, run_id_suffix: &str, payload: PermissionRequestPayload, ) -> Option { let request = PermissionRequestRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info().slug.clone(), permission_mode: hook_permission_mode(turn_context), tool_name: payload.tool_name.name().to_string(), matcher_aliases: payload.tool_name.matcher_aliases().to_vec(), run_id_suffix: run_id_suffix.to_string(), tool_input: payload.tool_input, }; let hooks = sess.hooks(); let preview_runs = hooks.preview_permission_request(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; let PermissionRequestOutcome { hook_events, decision, } = hooks.run_permission_request(request).await; emit_hook_completed_events(sess, turn_context, hook_events).await; decision } /// Runs matching `PostToolUse` hooks after a tool has produced a successful output. /// /// The `tool_name`, matcher aliases, `tool_input`, and `tool_response` values are /// already adapted by the tool handler into the stable hook contract. Passing /// raw internal tool data here would leak implementation details into user hook /// matchers and hook logs. pub(crate) async fn run_post_tool_use_hooks( sess: &Arc, turn_context: &Arc, tool_use_id: String, tool_name: String, matcher_aliases: Vec, tool_input: Value, tool_response: Value, ) -> PostToolUseOutcome { let request = PostToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[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), tool_name, matcher_aliases, tool_use_id, tool_input, tool_response, }; let hooks = sess.hooks(); let preview_runs = hooks.preview_post_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; let outcome = hooks.run_post_tool_use(request).await; emit_hook_completed_events(sess, turn_context, outcome.hook_events.clone()).await; outcome } fn executor_hook_sources_for_step(step_context: &StepContext) -> Vec { step_context .executor_capability_discovery .as_deref() .map(|snapshot| { let app_tool_policy = AppToolPolicyEvaluator::new(&step_context.mcp.config().config_layer_stack); executor_plugin_hook_sources(snapshot, |server, tool| { step_context .mcp .tool_info(server, tool) .filter(|tool_info| { if server != CODEX_APPS_MCP_SERVER_NAME { return true; } let annotations = tool_info.tool.annotations.as_ref(); app_tool_policy .policy(AppToolPolicyInput { connector_id: tool_info.connector_id.as_deref(), link_id: None, tool_name: &tool_info.tool.name, tool_title: tool_info.tool.title.as_deref(), destructive_hint: annotations .and_then(|annotations| annotations.destructive_hint), open_world_hint: annotations .and_then(|annotations| annotations.open_world_hint), }) .enabled }) }) }) .unwrap_or_default() } fn build_request_metadata( step_context: Option<&StepContext>, turn_context: &TurnContext, ) -> Map { 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)] pub(crate) async fn run_turn_stop_hooks( sess: &Arc, step_context: &Arc, stop_hook_active: bool, last_assistant_message: Option, ) -> StopOutcome { let turn_context = &step_context.turn; // Resolve the stop hook kind from the session source before building the // request. Root turns run Stop; thread-spawned child turns run SubagentStop. let (target, transcript_path) = match &turn_context.session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, parent_thread_id, .. }) => { let context = subagent_hook_context(sess, agent_role); let agent_transcript_path = sess.hook_transcript_path().await; let parent_transcript_path = match sess .services .thread_store .read_thread(ReadThreadParams { thread_id: *parent_thread_id, include_archived: true, include_history: false, }) .await { Ok(thread) => thread.rollout_path, Err(error) => { tracing::warn!( parent_thread_id = %parent_thread_id, error = %error, "failed to resolve parent transcript path for subagent hook" ); None } }; ( StopHookTarget::SubagentStop { agent_id: context.agent_id, agent_type: context.agent_type, agent_transcript_path, }, parent_transcript_path, ) } // Internal/synthetic subagents do not expose user-configured lifecycle // hooks, so there is no Stop or SubagentStop request to dispatch. SessionSource::SubAgent(_) => return StopOutcome::default(), SessionSource::Internal(InternalSessionSource::MemoryConsolidation) => ( StopHookTarget::MemoryConsolidation, sess.hook_transcript_path().await, ), _ => (StopHookTarget::Stop, sess.hook_transcript_path().await), }; let request_metadata = build_request_metadata(Some(step_context), turn_context); let request = codex_hooks::StopRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path, model: turn_context.model_info().slug.clone(), permission_mode: hook_permission_mode(turn_context), request_metadata: (!request_metadata.is_empty()).then_some(request_metadata), stop_hook_active, last_assistant_message, target, }; let executor_hook_sources = executor_hook_sources_for_step(step_context); let hooks = sess.hooks().with_executor_hooks(executor_hook_sources); emit_hook_started_events(sess, turn_context, hooks.preview_stop(&request)).await; let mut outcome = hooks.run_stop(request).await; emit_hook_completed_events(sess, turn_context, std::mem::take(&mut outcome.hook_events)).await; outcome } #[instrument(level = "trace", skip_all)] pub(crate) async fn run_session_end_hooks(sess: &Arc) { let hooks = sess.hooks(); let preview_runs = hooks.preview_session_end(); if preview_runs.is_empty() { return; } let turn_context = sess.new_default_turn().await; // SessionEnd is root-only; ThreadSpawn uses SubagentStart/SubagentStop and other subagents // are internal implementation details. if matches!(&turn_context.session_source, SessionSource::SubAgent(_)) { return; } let request = codex_hooks::SessionEndRequest { 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, }; if let Err(err) = sess.flush_rollout().await { tracing::warn!("failed to flush transcript before SessionEnd hook: {err}"); } emit_hook_started_events(sess, &turn_context, preview_runs).await; let outcome = hooks.run_session_end(request).await; emit_hook_completed_events(sess, &turn_context, outcome.hook_events).await; } pub(crate) async fn run_turn_interrupt_hooks( sess: &Arc, turn_context: &Arc, turn_state: &Mutex, ) { if matches!(&turn_context.session_source, SessionSource::SubAgent(_)) { return; } // 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_deref() .map(executor_hook_sources_for_step) .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(); if preview_runs.is_empty() && !has_executor_hooks { return; } let request_metadata = build_request_metadata(last_known_step_context.as_deref(), turn_context); 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), request_metadata: (!request_metadata.is_empty()).then_some(request_metadata), }; if let Err(err) = sess.flush_rollout().await { tracing::warn!("failed to flush transcript before Interrupt hook: {err}"); } emit_hook_started_events(sess, turn_context, preview_runs).await; let outcome = hooks.run_interrupt(request).await; emit_hook_completed_events(sess, turn_context, outcome.hook_events).await; } pub(crate) async fn run_pre_compact_hooks( sess: &Arc, turn_context: &Arc, trigger: CompactionTrigger, ) -> PreCompactHookOutcome { let request = codex_hooks::PreCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info().slug.clone(), trigger: compaction_trigger_label(trigger).to_string(), }; let preview_runs = sess.hooks().preview_pre_compact(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; let outcome = sess.hooks().run_pre_compact(request).await; emit_hook_completed_events(sess, turn_context, outcome.hook_events).await; if outcome.should_stop { PreCompactHookOutcome::Stopped } else { PreCompactHookOutcome::Continue } } pub(crate) enum PreCompactHookOutcome { Continue, Stopped, } pub(crate) enum PostCompactHookOutcome { Continue, Stopped, } pub(crate) async fn run_post_compact_hooks( sess: &Arc, turn_context: &Arc, trigger: CompactionTrigger, ) -> PostCompactHookOutcome { let request = codex_hooks::PostCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info().slug.clone(), trigger: compaction_trigger_label(trigger).to_string(), }; let preview_runs = sess.hooks().preview_post_compact(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; let outcome = sess.hooks().run_post_compact(request).await; emit_hook_completed_events(sess, turn_context, outcome.hook_events).await; if outcome.should_stop { PostCompactHookOutcome::Stopped } else { PostCompactHookOutcome::Continue } } #[instrument(level = "trace", skip_all)] pub(crate) async fn run_legacy_after_agent_hook( sess: &Arc, turn_context: &Arc, input: &[ResponseItem], last_assistant_message: Option, ) -> bool { let mut abort_message = None; let input_messages = input .iter() .filter_map(|item| match parse_turn_item(item) { Some(TurnItem::UserMessage(user_message)) => Some(user_message.message()), _ => None, }) .collect(); let hooks = sess.hooks(); for hook_outcome in hooks .dispatch(codex_hooks::HookPayload { session_id: sess.session_id().into(), #[allow(deprecated)] cwd: turn_context.cwd.clone(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), hook_event: codex_hooks::HookEvent::AfterAgent { event: codex_hooks::HookEventAfterAgent { thread_id: sess.thread_id, turn_id: turn_context.sub_id.clone(), input_messages, last_assistant_message, }, }, }) .await { let hook_name = hook_outcome.hook_name; let (error, should_abort) = match hook_outcome.result { codex_hooks::HookResult::Success => continue, codex_hooks::HookResult::FailedContinue(error) => (error, false), codex_hooks::HookResult::FailedAbort(error) => (error, true), }; let action = if should_abort { "aborting operation" } else { "continuing" }; tracing::warn!( turn_id = %turn_context.sub_id, hook_name = %hook_name, error = %error, "after_agent hook failed; {action}" ); if should_abort && abort_message.is_none() { abort_message = Some(format!( "after_agent hook '{hook_name}' failed and aborted turn completion: {error}" )); } } let Some(message) = abort_message else { return false; }; let event = EventMsg::Error(codex_protocol::protocol::ErrorEvent { misalignment: None, message, codex_error_info: Some(CodexErrorInfo::Other), }); sess.send_event(turn_context, event).await; true } pub(crate) async fn inspect_pending_input( sess: &Arc, turn_context: &Arc, pending_input_item: &TurnInput, ) -> HookRuntimeOutcome { match pending_input_item { TurnInput::UserInput { content, .. } => { let request = UserPromptSubmitRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), subagent: thread_spawn_subagent_hook_context(sess, turn_context), #[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), prompt: UserMessageItem::new(content).message(), }; let hooks = sess.hooks(); let preview_runs = hooks.preview_user_prompt_submit(&request); run_context_injecting_hook( sess, turn_context, preview_runs, hooks.run_user_prompt_submit(request), ) .await } TurnInput::ResponseItem(_) | TurnInput::FunctionCallOutput(_) => HookRuntimeOutcome { should_stop: false, additional_contexts: Vec::new(), }, TurnInput::InterAgentCommunication(_) => HookRuntimeOutcome { should_stop: false, additional_contexts: Vec::new(), }, } } pub(crate) async fn record_pending_input( sess: &Arc, turn_context: &Arc, pending_input: TurnInput, additional_contexts: Vec, persist_context: PersistContext, ) { match pending_input { TurnInput::UserInput { content, client_id, acceptance_order, } => { sess.record_user_prompt_and_emit_turn_item( turn_context.as_ref(), content.as_slice(), client_id, acceptance_order, persist_context, ) .await; } TurnInput::ResponseItem(item) => { sess.record_annotated_conversation_items(turn_context, vec![item]) .await; } TurnInput::FunctionCallOutput(item) => { sess.record_conversation_items(turn_context, std::slice::from_ref(&item)) .await; if let ResponseItem::FunctionCallOutput { id: Some(id), name: Some(name), namespace, output, .. } = item { let item = TurnItem::FunctionCallOutput(FunctionCallOutputItem { id: id.to_string(), name, namespace, output: output.body, }); sess.emit_turn_item_started(turn_context, &item).await; sess.emit_turn_item_completed(turn_context, item).await; } sess.ensure_rollout_materialized(persist_context).await; } TurnInput::InterAgentCommunication(communication) => { sess.record_inter_agent_communication(turn_context, communication) .await; } } record_additional_contexts(sess, turn_context, additional_contexts).await; } /// Processes finished async hook results at a safe turn boundary. /// /// Before the user prompt, records additional context directly into conversation /// history so results from a previous turn appear before the new prompt. After /// sampling, injects context into the active turn's pending-input queue so it /// reaches the next sampling request. Warnings and telemetry are handled in both /// cases. pub(crate) async fn drain_async_hook_results( sess: &Arc, turn_context: &Arc, before_user_prompt: bool, ) { while let Ok(result) = sess.async_hook_results.try_recv() { let additional_contexts = result .run .entries .iter() .filter(|entry| entry.kind == HookOutputEntryKind::Context) .map(|entry| entry.text.clone()) .collect::>(); if before_user_prompt { record_additional_contexts(sess, turn_context, additional_contexts).await; } else if !additional_contexts.is_empty() { let _ = sess .inject_hook_context_if_running(additional_context_messages(additional_contexts)) .await; } for entry in &result.run.entries { if entry.kind == HookOutputEntryKind::Warning { sess.send_event( turn_context, EventMsg::Warning(WarningEvent { message: entry.text.clone(), }), ) .await; } } emit_hook_completed_events(sess, turn_context, vec![result]).await; } } async fn run_context_injecting_hook( sess: &Arc, turn_context: &Arc, preview_runs: Vec, outcome_future: Fut, ) -> HookRuntimeOutcome where Fut: Future, Outcome: Into, { emit_hook_started_events(sess, turn_context, preview_runs).await; let outcome = outcome_future.await.into(); emit_hook_completed_events(sess, turn_context, outcome.hook_events).await; outcome.outcome } impl HookRuntimeOutcome { async fn record_additional_contexts( self, sess: &Arc, turn_context: &Arc, ) -> bool { record_additional_contexts(sess, turn_context, self.additional_contexts).await; self.should_stop } } pub(crate) async fn record_additional_contexts( sess: &Arc, turn_context: &Arc, additional_contexts: Vec, ) { let developer_messages = additional_context_messages(additional_contexts); if developer_messages.is_empty() { return; } sess.record_conversation_items(turn_context, developer_messages.as_slice()) .await; } fn additional_context_messages(additional_contexts: Vec) -> Vec { additional_contexts .into_iter() .map(HookAdditionalContext::new) .map(ContextualUserFragment::into) .collect() } fn should_emit_hook_notification(run: &HookRunSummary) -> bool { !run.builtin && run.execution_mode == HookExecutionMode::Sync } async fn emit_hook_started_events( sess: &Arc, turn_context: &Arc, preview_runs: Vec, ) { for run in preview_runs .into_iter() .filter(should_emit_hook_notification) { sess.send_event( turn_context, EventMsg::HookStarted(HookStartedEvent { turn_id: Some(turn_context.sub_id.clone()), run, }), ) .await; } } pub(crate) async fn emit_hook_completed_events( sess: &Arc, turn_context: &Arc, completed_events: Vec, ) { if turn_context.config.memories.disable_on_external_context && completed_events.iter().any(|completed| { completed.run.handler_type == HookHandlerType::McpTool && matches!( completed.run.status, HookRunStatus::Completed | HookRunStatus::Blocked | HookRunStatus::Stopped ) }) { state_db::mark_thread_memory_mode_polluted( sess.services.state_db.as_deref(), sess.thread_id, "mcp_tool_hook", ) .await; } for completed in completed_events { emit_hook_completed_metrics(turn_context, &completed); track_hook_completed_analytics(sess, turn_context, &completed); if should_emit_hook_notification(&completed.run) { sess.send_event(turn_context, EventMsg::HookCompleted(completed)) .await; } } } fn emit_hook_completed_metrics(turn_context: &TurnContext, completed: &HookCompletedEvent) { let tags = hook_run_metric_tags(&completed.run); turn_context .session_telemetry .counter(HOOK_RUN_METRIC, /*inc*/ 1, &tags); if let Some(duration_ms) = completed.run.duration_ms && let Ok(duration_ms) = u64::try_from(duration_ms) { turn_context.session_telemetry.record_duration( HOOK_RUN_DURATION_METRIC, Duration::from_millis(duration_ms), &tags, ); } } fn track_hook_completed_analytics( sess: &Arc, turn_context: &Arc, completed: &HookCompletedEvent, ) { let (tracking, hook) = hook_run_analytics_payload(sess.thread_id.to_string(), turn_context, completed); sess.services .analytics_events_client .track_hook_run(tracking, hook); } fn hook_run_analytics_payload( thread_id: String, turn_context: &TurnContext, completed: &HookCompletedEvent, ) -> (codex_analytics::TrackEventsContext, HookRunFact) { ( build_track_events_context( turn_context.model_info().slug.clone(), thread_id, completed .turn_id .clone() .unwrap_or_else(|| turn_context.sub_id.clone()), turn_context.originator.clone(), ), HookRunFact { event_name: completed.run.event_name, hook_source: completed.run.source, handler_type: completed.run.handler_type, execution_mode: completed.run.execution_mode, status: completed.run.status, }, ) } fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); 5] { let hook_name = match run.event_name { HookEventName::PreToolUse => "PreToolUse", HookEventName::PermissionRequest => "PermissionRequest", HookEventName::PostToolUse => "PostToolUse", HookEventName::PreCompact => "PreCompact", HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::SessionEnd => "SessionEnd", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::SubagentStart => "SubagentStart", HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", HookEventName::Interrupt => "Interrupt", }; let hook_source = match run.source { HookSource::System => "system", HookSource::User => "user", HookSource::Project => "project", HookSource::Mdm => "mdm", HookSource::SessionFlags => "session_flags", HookSource::Plugin => "plugin", HookSource::CloudRequirements => "cloud_requirements", HookSource::CloudManagedConfig => "cloud_managed_config", HookSource::LegacyManagedConfigFile => "legacy_managed_config_file", HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", HookSource::Unknown => "unknown", }; let status = match run.status { HookRunStatus::Running => "running", HookRunStatus::Completed => "completed", HookRunStatus::Failed => "failed", HookRunStatus::Blocked => "blocked", HookRunStatus::Stopped => "stopped", }; [ ("hook_name", hook_name), ("source", hook_source), ("status", status), ("handler_type", hook_handler_type_label(run.handler_type)), ( "execution_mode", hook_execution_mode_label(run.execution_mode), ), ] } fn hook_permission_mode(turn_context: &TurnContext) -> String { match turn_context.approval_policy() { AskForApproval::Never => "bypassPermissions", AskForApproval::UnlessTrusted | AskForApproval::OnRequest | AskForApproval::Granular(_) => { "default" } } .to_string() } fn thread_spawn_subagent_hook_context( sess: &Arc, turn_context: &TurnContext, ) -> Option { match &turn_context.session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_role, .. }) => { Some(subagent_hook_context(sess, agent_role)) } _ => None, } } fn subagent_hook_context(sess: &Arc, agent_role: &Option) -> SubagentHookContext { SubagentHookContext { agent_id: sess.thread_id().to_string(), agent_type: agent_role .clone() .unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string()), } } fn compaction_trigger_label(value: CompactionTrigger) -> &'static str { match value { CompactionTrigger::Manual => "manual", CompactionTrigger::Auto => "auto", } } #[cfg(test)] mod tests { use std::sync::Arc; use codex_otel::HOOK_RUN_DURATION_METRIC; use codex_otel::HOOK_RUN_METRIC; use codex_otel::MetricsClient; use codex_otel::MetricsConfig; use codex_protocol::models::ContentItem; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookExecutionMode; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookScope; use codex_protocol::protocol::HookSource; use opentelemetry_sdk::metrics::InMemoryMetricExporter; use opentelemetry_sdk::metrics::data::AggregatedMetrics; use opentelemetry_sdk::metrics::data::HistogramDataPoint; use opentelemetry_sdk::metrics::data::MetricData; use opentelemetry_sdk::metrics::data::SumDataPoint; use pretty_assertions::assert_eq; use super::additional_context_messages; use super::emit_hook_completed_events; use super::emit_hook_started_events; use super::hook_run_analytics_payload; use super::hook_run_metric_tags; use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; use codex_protocol::protocol::HookCompletedEvent; use codex_protocol::protocol::HookRunSummary; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; #[test] fn additional_context_messages_stay_separate_and_ordered() { let messages = additional_context_messages(vec![ "first tide note".to_string(), "second tide note".to_string(), ]); assert_eq!(messages.len(), 2); assert_eq!( messages .iter() .map(|message| match message { codex_protocol::models::ResponseItem::Message { role, content, .. } => { let text = content .iter() .map(|item| match item { ContentItem::InputText { text } => text.as_str(), ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } | ContentItem::OutputText { .. } => { panic!("expected input text content, got {item:?}") } }) .collect::(); (role.as_str(), text) } other => panic!("expected developer message, got {other:?}"), }) .collect::>(), vec![ ("developer", "first tide note".to_string()), ("developer", "second tide note".to_string()), ], ); } #[tokio::test] async fn hook_lifecycle_notifications_hide_builtin_and_async_runs_but_preserve_metrics() { let metrics = MetricsClient::new( MetricsConfig::in_memory( "test", "codex-core", env!("CARGO_PKG_VERSION"), InMemoryMetricExporter::default(), ) .with_runtime_reader(), ) .expect("in-memory metrics client"); let (session, mut turn_context, events) = make_session_and_context_with_rx().await; let turn_context_mut = Arc::get_mut(&mut turn_context).expect("single turn context ref"); turn_context_mut.session_telemetry = turn_context_mut .session_telemetry .clone() .with_metrics(metrics.clone()); let mut synchronous_run = sample_hook_run(HookRunStatus::Running, HookSource::User); synchronous_run.id = "synchronous-hook".to_string(); let mut asynchronous_run = synchronous_run.clone(); asynchronous_run.id = "asynchronous-hook".to_string(); asynchronous_run.execution_mode = HookExecutionMode::Async; let mut builtin_run = synchronous_run.clone(); builtin_run.id = "builtin-hook".to_string(); builtin_run.builtin = true; builtin_run.source = HookSource::Plugin; builtin_run.handler_type = HookHandlerType::McpTool; emit_hook_started_events( &session, &turn_context, vec![ builtin_run.clone(), asynchronous_run.clone(), synchronous_run.clone(), ], ) .await; let started = events.try_recv().expect("synchronous hook should start"); assert!(matches!( started.msg, codex_protocol::protocol::EventMsg::HookStarted(event) if event.run.id == synchronous_run.id )); assert!(events.try_recv().is_err()); builtin_run.status = HookRunStatus::Completed; asynchronous_run.status = HookRunStatus::Completed; synchronous_run.status = HookRunStatus::Completed; emit_hook_completed_events( &session, &turn_context, vec![ HookCompletedEvent { turn_id: Some(turn_context.sub_id.clone()), run: builtin_run, }, HookCompletedEvent { turn_id: Some(turn_context.sub_id.clone()), run: asynchronous_run, }, HookCompletedEvent { turn_id: Some(turn_context.sub_id.clone()), run: synchronous_run.clone(), }, ], ) .await; let completed = events.try_recv().expect("synchronous hook should complete"); assert!(matches!( completed.msg, codex_protocol::protocol::EventMsg::HookCompleted(event) if event.run.id == synchronous_run.id )); assert!(events.try_recv().is_err()); let snapshot = metrics.snapshot().expect("metrics snapshot"); let counter = snapshot .scope_metrics() .flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics) .find(|metric| metric.name() == HOOK_RUN_METRIC) .expect("hook run counter"); let AggregatedMetrics::U64(MetricData::Sum(sum)) = counter.data() else { panic!("expected hook run counter"); }; assert_eq!(sum.data_points().map(SumDataPoint::value).sum::(), 3); let duration = snapshot .scope_metrics() .flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics) .find(|metric| metric.name() == HOOK_RUN_DURATION_METRIC) .expect("hook run duration histogram"); let AggregatedMetrics::F64(MetricData::Histogram(histogram)) = duration.data() else { panic!("expected hook run duration histogram"); }; assert_eq!( histogram .data_points() .map(HistogramDataPoint::sum) .sum::(), 81.0, ); } #[tokio::test] async fn hook_run_analytics_payload_uses_completed_turn_id() { let (_session, turn_context) = make_session_and_context().await; let completed = HookCompletedEvent { turn_id: Some("turn-from-hook".to_string()), run: sample_hook_run(HookRunStatus::Blocked, HookSource::Project), }; let (tracking, hook) = hook_run_analytics_payload("thread-123".to_string(), &turn_context, &completed); assert_eq!(tracking.thread_id, "thread-123"); assert_eq!(tracking.turn_id, "turn-from-hook"); assert_eq!(tracking.model_slug, turn_context.model_info().slug); assert_eq!(hook.event_name, HookEventName::Stop); assert_eq!(hook.handler_type, HookHandlerType::Command); assert_eq!(hook.execution_mode, HookExecutionMode::Sync); assert_eq!(hook.hook_source, HookSource::Project); assert_eq!(hook.status, HookRunStatus::Blocked); } #[tokio::test] async fn hook_run_analytics_payload_falls_back_to_turn_context_id() { let (_session, turn_context) = make_session_and_context().await; let mut run = sample_hook_run(HookRunStatus::Failed, HookSource::Unknown); run.handler_type = HookHandlerType::Prompt; run.execution_mode = HookExecutionMode::Async; let completed = HookCompletedEvent { turn_id: None, run }; let (tracking, hook) = hook_run_analytics_payload("thread-123".to_string(), &turn_context, &completed); assert_eq!(tracking.turn_id, turn_context.sub_id); assert_eq!(hook.handler_type, HookHandlerType::Prompt); assert_eq!(hook.execution_mode, HookExecutionMode::Async); assert_eq!(hook.hook_source, HookSource::Unknown); assert_eq!(hook.status, HookRunStatus::Failed); } #[test] fn hook_run_metric_tags_match_analytics_shape() { let mut run = sample_hook_run(HookRunStatus::Blocked, HookSource::Project); run.handler_type = HookHandlerType::McpTool; assert_eq!( hook_run_metric_tags(&run), [ ("hook_name", "Stop"), ("source", "project"), ("status", "blocked"), ("handler_type", "mcp_tool"), ("execution_mode", "sync"), ] ); let cloud_requirements = sample_hook_run(HookRunStatus::Blocked, HookSource::CloudRequirements); assert_eq!( hook_run_metric_tags(&cloud_requirements), [ ("hook_name", "Stop"), ("source", "cloud_requirements"), ("status", "blocked"), ("handler_type", "command"), ("execution_mode", "sync"), ] ); } #[test] fn hook_run_metric_tags_include_expanded_hook_sources() { let mut run = sample_hook_run(HookRunStatus::Completed, HookSource::LegacyManagedConfigMdm); run.execution_mode = HookExecutionMode::Async; assert_eq!( hook_run_metric_tags(&run), [ ("hook_name", "Stop"), ("source", "legacy_managed_config_mdm"), ("status", "completed"), ("handler_type", "command"), ("execution_mode", "async"), ] ); } fn sample_hook_run(status: HookRunStatus, source: HookSource) -> HookRunSummary { HookRunSummary { id: "stop:0:/tmp/hooks.json".to_string(), event_name: HookEventName::Stop, handler_type: HookHandlerType::Command, execution_mode: HookExecutionMode::Sync, builtin: false, scope: HookScope::Turn, source_path: test_path_buf("/tmp/hooks.json").abs(), source, display_order: 0, status, status_message: None, started_at: 10, completed_at: Some(37), duration_ms: Some(27), entries: Vec::new(), } } }