mirror of
https://github.com/openai/codex.git
synced 2026-05-24 04:54:52 +00:00
# What `SubagentStart` runs once when Codex creates a thread-spawned subagent, before that child sends its first model request. Thread-spawned subagents use `SubagentStart` instead of the normal root-agent `SessionStart` hook. Configured handlers match on the subagent `agent_type`, using the same value passed to `spawn_agent`. When no agent type is specified, Codex uses the default agent type. Hook input includes the normal session-start fields plus: - `agent_id`: the child thread id. - `agent_type`: the resolved subagent type. `SubagentStart` may return `hookSpecificOutput.additionalContext`. That context is added to the child conversation before the first model request. # Lifecycle Scope Only thread-spawned subagents run `SubagentStart`. Internal/system subagents such as Review, Compact, MemoryConsolidation, and Other do not run normal `SessionStart` hooks and do not run `SubagentStart`. This avoids exposing synthetic matcher labels for internal implementation paths. Also the `SessionStart` hook no longer fires for subagents, this matches behavior with other coding agents' implementation # Stack 1. This PR: add `SubagentStart`. 2. #22873: add `SubagentStop`. 3. #22882: add subagent identity to normal hook inputs.
833 lines
28 KiB
Rust
833 lines
28 KiB
Rust
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_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::StopOutcome;
|
|
use codex_hooks::StopRequest;
|
|
use codex_hooks::UserPromptSubmitOutcome;
|
|
use codex_hooks::UserPromptSubmitRequest;
|
|
use codex_otel::HOOK_RUN_DURATION_METRIC;
|
|
use codex_otel::HOOK_RUN_METRIC;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::items::UserMessageItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::HookCompletedEvent;
|
|
use codex_protocol::protocol::HookEventName;
|
|
use codex_protocol::protocol::HookRunStatus;
|
|
use codex_protocol::protocol::HookRunSummary;
|
|
use codex_protocol::protocol::HookSource;
|
|
use codex_protocol::protocol::HookStartedEvent;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use serde_json::Value;
|
|
|
|
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::turn_context::TurnContext;
|
|
use crate::tools::hook_names::HookToolName;
|
|
use crate::tools::sandboxing::PermissionRequestPayload;
|
|
|
|
pub(crate) struct HookRuntimeOutcome {
|
|
pub should_stop: bool,
|
|
pub additional_contexts: Vec<String>,
|
|
}
|
|
|
|
pub(crate) enum PreToolUseHookResult {
|
|
Continue { updated_input: Option<Value> },
|
|
Blocked(String),
|
|
}
|
|
|
|
struct ContextInjectingHookOutcome {
|
|
hook_events: Vec<HookCompletedEvent>,
|
|
outcome: HookRuntimeOutcome,
|
|
}
|
|
|
|
impl From<SessionStartOutcome> 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<UserPromptSubmitOutcome> 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,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn run_pending_session_start_hooks(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
) -> bool {
|
|
let Some(session_start_source) = sess.take_pending_session_start_source().await else {
|
|
return false;
|
|
};
|
|
|
|
// 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 agent_type = agent_role
|
|
.clone()
|
|
.unwrap_or_else(|| crate::agent::role::DEFAULT_ROLE_NAME.to_string());
|
|
StartHookTarget::SubagentStart {
|
|
turn_id: turn_context.sub_id.clone(),
|
|
agent_id: sess.thread_id().to_string(),
|
|
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);
|
|
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
|
|
}
|
|
|
|
/// 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<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
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(),
|
|
#[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<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
run_id_suffix: &str,
|
|
payload: PermissionRequestPayload,
|
|
) -> Option<PermissionRequestDecision> {
|
|
let request = PermissionRequestRequest {
|
|
session_id: sess.session_id().into(),
|
|
turn_id: turn_context.sub_id.clone(),
|
|
#[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<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
tool_use_id: String,
|
|
tool_name: String,
|
|
matcher_aliases: Vec<String>,
|
|
tool_input: Value,
|
|
tool_response: Value,
|
|
) -> PostToolUseOutcome {
|
|
let request = PostToolUseRequest {
|
|
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),
|
|
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
|
|
}
|
|
|
|
pub(crate) async fn run_pre_compact_hooks(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
trigger: CompactionTrigger,
|
|
) -> PreCompactHookOutcome {
|
|
let request = codex_hooks::PreCompactRequest {
|
|
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(),
|
|
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 {
|
|
reason: outcome.stop_reason,
|
|
}
|
|
} else {
|
|
PreCompactHookOutcome::Continue
|
|
}
|
|
}
|
|
|
|
pub(crate) enum PreCompactHookOutcome {
|
|
Continue,
|
|
Stopped { reason: Option<String> },
|
|
}
|
|
|
|
pub(crate) enum PostCompactHookOutcome {
|
|
Continue,
|
|
Stopped,
|
|
}
|
|
|
|
pub(crate) async fn run_post_compact_hooks(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
trigger: CompactionTrigger,
|
|
) -> PostCompactHookOutcome {
|
|
let request = codex_hooks::PostCompactRequest {
|
|
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(),
|
|
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
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn run_stop_hooks(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
stop_hook_active: bool,
|
|
last_assistant_message: Option<String>,
|
|
) -> StopOutcome {
|
|
let request = StopRequest {
|
|
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),
|
|
stop_hook_active,
|
|
last_assistant_message,
|
|
};
|
|
let hooks = sess.hooks();
|
|
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
|
|
}
|
|
|
|
pub(crate) async fn run_legacy_after_agent_hook(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
input: &[ResponseItem],
|
|
last_assistant_message: Option<String>,
|
|
) -> 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.conversation_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 {
|
|
message,
|
|
codex_error_info: None,
|
|
});
|
|
sess.send_event(turn_context, event).await;
|
|
true
|
|
}
|
|
|
|
pub(crate) async fn inspect_pending_input(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
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(),
|
|
#[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::ResponseInputItem(_) => HookRuntimeOutcome {
|
|
should_stop: false,
|
|
additional_contexts: Vec::new(),
|
|
},
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn record_pending_input(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
pending_input: TurnInput,
|
|
additional_contexts: Vec<String>,
|
|
) {
|
|
match pending_input {
|
|
TurnInput::UserInput(content) => {
|
|
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), content.as_slice())
|
|
.await;
|
|
}
|
|
TurnInput::ResponseInputItem(input) => {
|
|
let response_item = ResponseItem::from(input);
|
|
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
|
|
.await;
|
|
}
|
|
}
|
|
record_additional_contexts(sess, turn_context, additional_contexts).await;
|
|
}
|
|
|
|
async fn run_context_injecting_hook<Fut, Outcome>(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
preview_runs: Vec<HookRunSummary>,
|
|
outcome_future: Fut,
|
|
) -> HookRuntimeOutcome
|
|
where
|
|
Fut: Future<Output = Outcome>,
|
|
Outcome: Into<ContextInjectingHookOutcome>,
|
|
{
|
|
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<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
) -> bool {
|
|
record_additional_contexts(sess, turn_context, self.additional_contexts).await;
|
|
|
|
self.should_stop
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn record_additional_contexts(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
additional_contexts: Vec<String>,
|
|
) {
|
|
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<String>) -> Vec<ResponseItem> {
|
|
additional_contexts
|
|
.into_iter()
|
|
.map(HookAdditionalContext::new)
|
|
.map(ContextualUserFragment::into)
|
|
.collect()
|
|
}
|
|
|
|
async fn emit_hook_started_events(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
preview_runs: Vec<HookRunSummary>,
|
|
) {
|
|
for run in preview_runs {
|
|
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<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
completed_events: Vec<HookCompletedEvent>,
|
|
) {
|
|
for completed in completed_events {
|
|
emit_hook_completed_metrics(turn_context, &completed);
|
|
track_hook_completed_analytics(sess, turn_context, &completed);
|
|
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<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
completed: &HookCompletedEvent,
|
|
) {
|
|
let (tracking, hook) =
|
|
hook_run_analytics_payload(sess.conversation_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()),
|
|
),
|
|
HookRunFact {
|
|
event_name: completed.run.event_name,
|
|
hook_source: completed.run.source,
|
|
status: completed.run.status,
|
|
},
|
|
)
|
|
}
|
|
|
|
fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); 3] {
|
|
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::UserPromptSubmit => "UserPromptSubmit",
|
|
HookEventName::SubagentStart => "SubagentStart",
|
|
HookEventName::Stop => "Stop",
|
|
};
|
|
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::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),
|
|
]
|
|
}
|
|
|
|
fn hook_permission_mode(turn_context: &TurnContext) -> String {
|
|
match turn_context.approval_policy.value() {
|
|
AskForApproval::Never => "bypassPermissions",
|
|
AskForApproval::UnlessTrusted
|
|
| AskForApproval::OnFailure
|
|
| AskForApproval::OnRequest
|
|
| AskForApproval::Granular(_) => "default",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
fn compaction_trigger_label(value: CompactionTrigger) -> &'static str {
|
|
match value {
|
|
CompactionTrigger::Manual => "manual",
|
|
CompactionTrigger::Auto => "auto",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
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 pretty_assertions::assert_eq;
|
|
|
|
use super::additional_context_messages;
|
|
use super::hook_run_analytics_payload;
|
|
use super::hook_run_metric_tags;
|
|
use crate::session::tests::make_session_and_context;
|
|
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::OutputText { .. } => {
|
|
panic!("expected input text content, got {item:?}")
|
|
}
|
|
})
|
|
.collect::<String>();
|
|
(role.as_str(), text)
|
|
}
|
|
other => panic!("expected developer message, got {other:?}"),
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
("developer", "first tide note".to_string()),
|
|
("developer", "second tide note".to_string()),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[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.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 completed = HookCompletedEvent {
|
|
turn_id: None,
|
|
run: sample_hook_run(HookRunStatus::Failed, HookSource::Unknown),
|
|
};
|
|
|
|
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.hook_source, HookSource::Unknown);
|
|
assert_eq!(hook.status, HookRunStatus::Failed);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_run_metric_tags_match_analytics_shape() {
|
|
let run = sample_hook_run(HookRunStatus::Blocked, HookSource::Project);
|
|
|
|
assert_eq!(
|
|
hook_run_metric_tags(&run),
|
|
[
|
|
("hook_name", "Stop"),
|
|
("source", "project"),
|
|
("status", "blocked"),
|
|
]
|
|
);
|
|
|
|
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"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hook_run_metric_tags_include_expanded_hook_sources() {
|
|
let run = sample_hook_run(HookRunStatus::Completed, HookSource::LegacyManagedConfigMdm);
|
|
|
|
assert_eq!(
|
|
hook_run_metric_tags(&run),
|
|
[
|
|
("hook_name", "Stop"),
|
|
("source", "legacy_managed_config_mdm"),
|
|
("status", "completed"),
|
|
]
|
|
);
|
|
}
|
|
|
|
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,
|
|
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(),
|
|
}
|
|
}
|
|
}
|