mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Add observation-only model request lifecycle hooks (#55144)
## What changed - Add `PreModelRequest` and `PostModelRequest` hooks around model request attempts, including retries. Pass model, session, turn, and optional subagent context; report completion, failure, or cancellation with errors and token usage when available. - Treat hook output as diagnostic data without allowing it to veto requests or alter model context. Wait for synchronous post-request hooks before dispatching tools, while asynchronous hooks allow streaming tool dispatch to continue. - Apply memory-consolidation hook filtering and expose the new events through configuration, protocol schemas, generated clients, and the hooks browser. ## Testing Add integration coverage for failures and retries, ignored control directives, cancellation, synchronous and asynchronous tool ordering, interrupted-turn resume and token accounting, and memory-consolidation filtering. Update hook schema fixtures and hooks browser snapshots. GitOrigin-RevId: 6ed7b39fc8bb94cf467e350d07d97fcc729c21ce
This commit is contained in:
@@ -1488,6 +1488,8 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PostToolUse => "PostToolUse",
|
||||
HookEventName::PreCompact => "PreCompact",
|
||||
HookEventName::PostCompact => "PostCompact",
|
||||
HookEventName::PreModelRequest => "PreModelRequest",
|
||||
HookEventName::PostModelRequest => "PostModelRequest",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::SessionEnd => "SessionEnd",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
|
||||
@@ -2346,6 +2346,8 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preModelRequest",
|
||||
"postModelRequest",
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
|
||||
+16
@@ -13325,6 +13325,8 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preModelRequest",
|
||||
"postModelRequest",
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
@@ -14502,6 +14504,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PostModelRequest": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PostToolUse": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||
@@ -14514,6 +14523,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PreModelRequest": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PreToolUse": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
|
||||
|
||||
+16
@@ -9252,6 +9252,8 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preModelRequest",
|
||||
"postModelRequest",
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
@@ -10494,6 +10496,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PostModelRequest": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PostToolUse": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
@@ -10506,6 +10515,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PreModelRequest": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PreToolUse": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
|
||||
+14
@@ -829,6 +829,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PostModelRequest": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PostToolUse": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
@@ -841,6 +848,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PreModelRequest": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PreToolUse": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preModelRequest",
|
||||
"postModelRequest",
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preModelRequest",
|
||||
"postModelRequest",
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preModelRequest",
|
||||
"postModelRequest",
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
|
||||
@@ -110,6 +110,8 @@
|
||||
},
|
||||
"HookEventName": {
|
||||
"enum": [
|
||||
"preModelRequest",
|
||||
"postModelRequest",
|
||||
"preToolUse",
|
||||
"permissionRequest",
|
||||
"postToolUse",
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop" | "interrupt";
|
||||
export type HookEventName = "preModelRequest" | "postModelRequest" | "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "sessionEnd" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop" | "interrupt";
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup";
|
||||
|
||||
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, SessionEnd: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, Interrupt: Array<ConfiguredHookMatcherGroup>, };
|
||||
export type ManagedHooksRequirements = { PreModelRequest: Array<ConfiguredHookMatcherGroup>, PostModelRequest: Array<ConfiguredHookMatcherGroup>, managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, SessionEnd: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, Interrupt: Array<ConfiguredHookMatcherGroup>, };
|
||||
|
||||
@@ -439,7 +439,7 @@ pub struct ConfigRequirements {
|
||||
pub in_app_browser: Option<InAppBrowserRequirements>,
|
||||
pub feature_requirements: Option<BTreeMap<String, bool>>,
|
||||
#[experimental("configRequirements/read.hooks")]
|
||||
pub hooks: Option<ManagedHooksRequirements>,
|
||||
pub hooks: Option<Box<ManagedHooksRequirements>>,
|
||||
pub enforce_residency: Option<ResidencyRequirement>,
|
||||
#[experimental("configRequirements/read.network")]
|
||||
pub network: Option<NetworkRequirements>,
|
||||
@@ -612,6 +612,12 @@ pub struct InAppBrowserRequirements {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ManagedHooksRequirements {
|
||||
#[serde(rename = "PreModelRequest", default)]
|
||||
#[ts(rename = "PreModelRequest")]
|
||||
pub pre_model_request: Vec<ConfiguredHookMatcherGroup>,
|
||||
#[serde(rename = "PostModelRequest", default)]
|
||||
#[ts(rename = "PostModelRequest")]
|
||||
pub post_model_request: Vec<ConfiguredHookMatcherGroup>,
|
||||
pub managed_dir: Option<PathBuf>,
|
||||
pub windows_managed_dir: Option<PathBuf>,
|
||||
#[serde(rename = "PreToolUse")]
|
||||
|
||||
@@ -17,7 +17,7 @@ use serde::Serialize;
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum HookEventName from CoreHookEventName {
|
||||
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, UserPromptSubmit, SubagentStart, SubagentStop, Stop, Interrupt
|
||||
PreModelRequest, PostModelRequest, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, UserPromptSubmit, SubagentStart, SubagentStop, Stop, Interrupt
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -559,7 +559,10 @@ fn map_requirements_to_api(
|
||||
feature_requirements: requirements
|
||||
.feature_requirements
|
||||
.map(|requirements| requirements.entries),
|
||||
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
|
||||
hooks: requirements
|
||||
.hooks
|
||||
.map(map_hooks_requirements_to_api)
|
||||
.map(Box::new),
|
||||
enforce_residency: requirements
|
||||
.enforce_residency
|
||||
.map(map_residency_requirement_to_api),
|
||||
@@ -714,6 +717,8 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
|
||||
hooks,
|
||||
} = hooks;
|
||||
let HookEventsToml {
|
||||
pre_model_request,
|
||||
post_model_request,
|
||||
pre_tool_use,
|
||||
permission_request,
|
||||
post_tool_use,
|
||||
@@ -726,11 +731,13 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed
|
||||
subagent_stop,
|
||||
stop,
|
||||
interrupt,
|
||||
} = hooks;
|
||||
} = *hooks;
|
||||
|
||||
ManagedHooksRequirements {
|
||||
managed_dir,
|
||||
windows_managed_dir,
|
||||
pre_model_request: map_hook_matcher_groups_to_api(pre_model_request),
|
||||
post_model_request: map_hook_matcher_groups_to_api(post_model_request),
|
||||
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
|
||||
permission_request: map_hook_matcher_groups_to_api(permission_request),
|
||||
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
|
||||
|
||||
@@ -2195,7 +2195,6 @@ mod tests {
|
||||
use crate::ComputerUseMacosRequirementsToml;
|
||||
use crate::ComputerUseWindowsExeRequirementToml;
|
||||
use crate::ComputerUseWindowsRequirementsToml;
|
||||
use crate::HookEventsToml;
|
||||
use crate::McpServerCommandMatcher;
|
||||
use crate::McpServerIdentity;
|
||||
use crate::McpServerValueMatcher;
|
||||
@@ -4406,7 +4405,7 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
.set(ManagedHooksRequirementsToml {
|
||||
managed_dir: Some(std::path::PathBuf::from("/other/hooks")),
|
||||
windows_managed_dir: None,
|
||||
hooks: HookEventsToml::default(),
|
||||
hooks: Default::default(),
|
||||
})
|
||||
.expect_err("managed hooks should reject drift");
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ pub struct HookStateToml {
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct HookEventsToml {
|
||||
#[serde(rename = "PreModelRequest", default)]
|
||||
pub pre_model_request: Vec<MatcherGroup>,
|
||||
#[serde(rename = "PostModelRequest", default)]
|
||||
pub post_model_request: Vec<MatcherGroup>,
|
||||
#[serde(rename = "PreToolUse", default)]
|
||||
pub pre_tool_use: Vec<MatcherGroup>,
|
||||
#[serde(rename = "PermissionRequest", default)]
|
||||
@@ -66,6 +70,8 @@ pub struct HookEventsToml {
|
||||
impl HookEventsToml {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let Self {
|
||||
pre_model_request,
|
||||
post_model_request,
|
||||
pre_tool_use,
|
||||
permission_request,
|
||||
post_tool_use,
|
||||
@@ -79,7 +85,9 @@ impl HookEventsToml {
|
||||
stop,
|
||||
interrupt,
|
||||
} = self;
|
||||
pre_tool_use.is_empty()
|
||||
pre_model_request.is_empty()
|
||||
&& post_model_request.is_empty()
|
||||
&& pre_tool_use.is_empty()
|
||||
&& permission_request.is_empty()
|
||||
&& post_tool_use.is_empty()
|
||||
&& pre_compact.is_empty()
|
||||
@@ -95,6 +103,8 @@ impl HookEventsToml {
|
||||
|
||||
pub fn handler_count(&self) -> usize {
|
||||
let Self {
|
||||
pre_model_request,
|
||||
post_model_request,
|
||||
pre_tool_use,
|
||||
permission_request,
|
||||
post_tool_use,
|
||||
@@ -109,6 +119,8 @@ impl HookEventsToml {
|
||||
interrupt,
|
||||
} = self;
|
||||
[
|
||||
pre_model_request,
|
||||
post_model_request,
|
||||
pre_tool_use,
|
||||
permission_request,
|
||||
post_tool_use,
|
||||
@@ -128,15 +140,17 @@ impl HookEventsToml {
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn into_matcher_groups(mut self) -> [(HookEventName, Vec<MatcherGroup>); 12] {
|
||||
pub fn into_matcher_groups(mut self) -> [(HookEventName, Vec<MatcherGroup>); 14] {
|
||||
self.matcher_groups_mut()
|
||||
.map(|(event, groups)| (event, std::mem::take(groups)))
|
||||
}
|
||||
|
||||
pub fn matcher_groups_mut(&mut self) -> [(HookEventName, &mut Vec<MatcherGroup>); 12] {
|
||||
pub fn matcher_groups_mut(&mut self) -> [(HookEventName, &mut Vec<MatcherGroup>); 14] {
|
||||
use HookEventName as Event;
|
||||
|
||||
[
|
||||
(Event::PreModelRequest, &mut self.pre_model_request),
|
||||
(Event::PostModelRequest, &mut self.post_model_request),
|
||||
(Event::PreToolUse, &mut self.pre_tool_use),
|
||||
(Event::PermissionRequest, &mut self.permission_request),
|
||||
(Event::PostToolUse, &mut self.post_tool_use),
|
||||
@@ -224,7 +238,7 @@ pub struct ManagedHooksRequirementsToml {
|
||||
pub managed_dir: Option<PathBuf>,
|
||||
pub windows_managed_dir: Option<PathBuf>,
|
||||
#[serde(flatten)]
|
||||
pub hooks: HookEventsToml,
|
||||
pub hooks: Box<HookEventsToml>,
|
||||
}
|
||||
|
||||
impl ManagedHooksRequirementsToml {
|
||||
|
||||
@@ -257,7 +257,7 @@ command = "python3 /enterprise/place/pre.py"
|
||||
ManagedHooksRequirementsToml {
|
||||
managed_dir: Some(std::path::PathBuf::from("/enterprise/place")),
|
||||
windows_managed_dir: None,
|
||||
hooks: HookEventsToml {
|
||||
hooks: Box::new(HookEventsToml {
|
||||
pre_tool_use: vec![MatcherGroup {
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
hooks: vec![HookHandlerConfig::Command {
|
||||
@@ -270,7 +270,7 @@ command = "python3 /enterprise/place/pre.py"
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ impl HookMergeState {
|
||||
incoming_inactive_dir,
|
||||
source,
|
||||
);
|
||||
changed |= append_hook_events(&mut existing.value.hooks, incoming.hooks);
|
||||
changed |= append_hook_events(&mut existing.value.hooks, *incoming.hooks);
|
||||
if changed {
|
||||
merge_output_source(&mut existing.source, source);
|
||||
}
|
||||
@@ -204,6 +204,8 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) -
|
||||
// Destructure without `..` so new hook events cannot be introduced without
|
||||
// deciding whether requirements layer merging should append them.
|
||||
let HookEventsToml {
|
||||
pre_model_request,
|
||||
post_model_request,
|
||||
pre_tool_use,
|
||||
permission_request,
|
||||
post_tool_use,
|
||||
@@ -219,6 +221,8 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) -
|
||||
} = incoming;
|
||||
|
||||
let mut changed = false;
|
||||
changed |= append_vec(&mut existing.pre_model_request, pre_model_request);
|
||||
changed |= append_vec(&mut existing.post_model_request, post_model_request);
|
||||
changed |= append_vec(&mut existing.pre_tool_use, pre_tool_use);
|
||||
changed |= append_vec(&mut existing.permission_request, permission_request);
|
||||
changed |= append_vec(&mut existing.post_tool_use, post_tool_use);
|
||||
|
||||
@@ -2307,6 +2307,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PostModelRequest": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/MatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PostToolUse": {
|
||||
"default": [],
|
||||
"items": {
|
||||
@@ -2321,6 +2328,13 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PreModelRequest": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/definitions/MatcherGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PreToolUse": {
|
||||
"default": [],
|
||||
"items": {
|
||||
|
||||
@@ -994,6 +994,8 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
|
||||
HookEventName::PostToolUse => "PostToolUse",
|
||||
HookEventName::PreCompact => "PreCompact",
|
||||
HookEventName::PostCompact => "PostCompact",
|
||||
HookEventName::PreModelRequest => "PreModelRequest",
|
||||
HookEventName::PostModelRequest => "PostModelRequest",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::SessionEnd => "SessionEnd",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
@@ -1072,6 +1074,42 @@ fn compaction_trigger_label(value: CompactionTrigger) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hooks_for_model_request(
|
||||
session: &Session,
|
||||
step: &StepContext,
|
||||
) -> Arc<codex_hooks::Hooks> {
|
||||
let hooks = session.hooks();
|
||||
match step.turn.session_source {
|
||||
SessionSource::Internal(InternalSessionSource::MemoryConsolidation)
|
||||
| SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => {
|
||||
Arc::new(hooks.for_memory_consolidation(executor_hook_sources_for_step(step)))
|
||||
}
|
||||
_ => hooks,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_model_request_hooks(
|
||||
session: &Arc<Session>,
|
||||
step: &StepContext,
|
||||
hooks: &codex_hooks::Hooks,
|
||||
event: codex_hooks::ModelRequestHookEvent,
|
||||
) {
|
||||
let subagent = thread_spawn_subagent_hook_context(session, &step.turn);
|
||||
let request = codex_hooks::ModelRequestHookInput {
|
||||
session_id: session.session_id().to_string(),
|
||||
turn_id: step.turn.sub_id.clone(),
|
||||
cwd: tool_hook_cwd(&step.environments, &step.turn),
|
||||
transcript_path: session.hook_transcript_path().await,
|
||||
model: step.settings.model_info.slug.clone(),
|
||||
event,
|
||||
agent_id: subagent.as_ref().map(|context| context.agent_id.clone()),
|
||||
agent_type: subagent.map(|context| context.agent_type),
|
||||
};
|
||||
emit_hook_started_events(session, &step.turn, hooks.preview_model_request(&request)).await;
|
||||
let events = hooks.run_model_request(request).await;
|
||||
emit_hook_completed_events(session, &step.turn, events).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -13088,6 +13088,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
|
||||
step_context: StepContext::for_test(Arc::clone(&tc)),
|
||||
turn_store: Arc::new(codex_extension_api::ExtensionData::new(tc.sub_id.clone())),
|
||||
tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)),
|
||||
defer_tool_dispatch: false,
|
||||
cancellation_token: CancellationToken::new(),
|
||||
};
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ use crate::cyber_access_program;
|
||||
use crate::environment_selection::TurnEnvironmentSnapshot;
|
||||
use crate::feedback_tags;
|
||||
use crate::hook_runtime::drain_async_hook_results;
|
||||
use crate::hook_runtime::hooks_for_model_request;
|
||||
use crate::hook_runtime::inspect_pending_input;
|
||||
use crate::hook_runtime::record_additional_contexts;
|
||||
use crate::hook_runtime::record_pending_input;
|
||||
use crate::hook_runtime::run_legacy_after_agent_hook;
|
||||
use crate::hook_runtime::run_model_request_hooks;
|
||||
use crate::hook_runtime::run_pending_session_start_hooks;
|
||||
use crate::hook_runtime::run_turn_stop_hooks;
|
||||
use crate::mcp_skill_dependencies::maybe_prompt_and_install_mcp_dependencies;
|
||||
@@ -79,6 +81,9 @@ use codex_extension_api::TurnInputEnvironment;
|
||||
use codex_features::Feature;
|
||||
use codex_file_system::FindUpErrorPolicy;
|
||||
use codex_file_system::find_nearest_ancestor_with_markers;
|
||||
use codex_hooks::ModelRequestHookEvent::PostModelRequest;
|
||||
use codex_hooks::ModelRequestHookEvent::PreModelRequest;
|
||||
use codex_hooks::ModelRequestStatus;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider::RemoteCompactionSupport;
|
||||
use codex_protocol::ResponseItemId;
|
||||
@@ -101,6 +106,8 @@ use codex_protocol::protocol::AgentReasoningSectionBreakEvent;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookExecutionMode;
|
||||
use codex_protocol::protocol::InternalSessionSource;
|
||||
use codex_protocol::protocol::PlanDeltaEvent;
|
||||
use codex_protocol::protocol::ReasoningContentDeltaEvent;
|
||||
@@ -2648,7 +2655,6 @@ async fn try_run_sampling_request(
|
||||
step_context.settings.model_info.slug.as_str(),
|
||||
turn_context.provider.info().name.as_str(),
|
||||
);
|
||||
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
|
||||
if client_session.inference_tools_changed(&step_context.tool_router.model_visible_specs()) {
|
||||
turn_context.turn_timing_state.record_tools_change();
|
||||
}
|
||||
@@ -2668,7 +2674,45 @@ async fn try_run_sampling_request(
|
||||
let effort = sess
|
||||
.reasoning_effort_for_request(&step_context.settings, super::RequestEffortUsage::Sampling)
|
||||
.await;
|
||||
let mut stream = client_session
|
||||
let hooks = hooks_for_model_request(&sess, &step_context);
|
||||
let post_mode = hooks.execution_mode_for_event(HookEventName::PostModelRequest);
|
||||
let defer_tool_dispatch = post_mode == Some(HookExecutionMode::Sync);
|
||||
let finish_post_request = post_mode.map(|_| {
|
||||
let (post_request, completed) = tokio::sync::oneshot::channel();
|
||||
let (hook_done, wait_for_hook) = tokio::sync::oneshot::channel();
|
||||
hooks.schedule_async_task({
|
||||
let hooks = Arc::clone(&hooks);
|
||||
let session = Arc::clone(&sess);
|
||||
let step = Arc::clone(&step_context);
|
||||
async move {
|
||||
let event = completed.await.unwrap_or(PostModelRequest {
|
||||
status: ModelRequestStatus::Cancelled,
|
||||
error: None,
|
||||
token_usage: None,
|
||||
});
|
||||
run_model_request_hooks(&session, &step, &hooks, event).await;
|
||||
let _ = hook_done.send(());
|
||||
}
|
||||
});
|
||||
let cancellation_token = cancellation_token.clone();
|
||||
async move |event| {
|
||||
let _ = post_request.send(event);
|
||||
if defer_tool_dispatch {
|
||||
// Interrupt the wait, not the session-owned hook or final token accounting.
|
||||
let _ = wait_for_hook.or_cancel(&cancellation_token).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
if hooks
|
||||
.execution_mode_for_event(HookEventName::PreModelRequest)
|
||||
.is_some()
|
||||
{
|
||||
run_model_request_hooks(&sess, &step_context, &hooks, PreModelRequest).await;
|
||||
}
|
||||
let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling();
|
||||
let mut response_completed = false;
|
||||
let mut request_token_usage = None;
|
||||
let mut stream = match client_session
|
||||
.stream(
|
||||
prompt,
|
||||
&step_context.settings.model_info,
|
||||
@@ -2681,7 +2725,21 @@ async fn try_run_sampling_request(
|
||||
)
|
||||
.instrument(trace_span!("stream_request"))
|
||||
.or_cancel(&cancellation_token)
|
||||
.await??;
|
||||
.await?
|
||||
{
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
if let Some(finish_post_request) = finish_post_request {
|
||||
finish_post_request(PostModelRequest {
|
||||
status: ModelRequestStatus::Failed,
|
||||
error: Some(error.to_string()),
|
||||
token_usage: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if cancellation_token.is_cancelled() {
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
@@ -2771,6 +2829,7 @@ async fn try_run_sampling_request(
|
||||
Some(Ok(event)) => event,
|
||||
Some(Err(err)) => {
|
||||
if let CodexErrorDetails::ResponseIncomplete(incomplete) = err.details() {
|
||||
request_token_usage = incomplete.token_usage.clone();
|
||||
sess.record_incomplete_response_usage(
|
||||
&turn_context,
|
||||
&step_context.settings,
|
||||
@@ -2866,6 +2925,7 @@ async fn try_run_sampling_request(
|
||||
step_context: Arc::clone(&step_context),
|
||||
turn_store: Arc::clone(&turn_store),
|
||||
tool_runtime: tool_runtime.clone(),
|
||||
defer_tool_dispatch,
|
||||
cancellation_token: cancellation_token.child_token(),
|
||||
};
|
||||
|
||||
@@ -3075,6 +3135,8 @@ async fn try_run_sampling_request(
|
||||
usage_metadata,
|
||||
end_turn,
|
||||
} => {
|
||||
response_completed = true;
|
||||
request_token_usage = token_usage.clone();
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_code_mode_tool_call(
|
||||
@@ -3271,7 +3333,21 @@ async fn try_run_sampling_request(
|
||||
};
|
||||
drop(sampling_span);
|
||||
drop(sampling_timing_guard);
|
||||
|
||||
if let Some(finish_post_request) = finish_post_request {
|
||||
let (status, error) = match (response_completed, &outcome) {
|
||||
(true, _) => (ModelRequestStatus::Completed, None),
|
||||
(false, Err(error)) if !matches!(error.details(), CodexErrorDetails::TurnAborted) => {
|
||||
(ModelRequestStatus::Failed, Some(error.to_string()))
|
||||
}
|
||||
_ => (ModelRequestStatus::Cancelled, None),
|
||||
};
|
||||
finish_post_request(PostModelRequest {
|
||||
status,
|
||||
error,
|
||||
token_usage: request_token_usage,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
flush_assistant_text_segments_all(
|
||||
&sess,
|
||||
&turn_context,
|
||||
|
||||
@@ -231,6 +231,7 @@ pub(crate) struct HandleOutputCtx {
|
||||
pub step_context: Arc<StepContext>,
|
||||
pub turn_store: Arc<ExtensionData>,
|
||||
pub tool_runtime: ToolCallRuntime,
|
||||
pub defer_tool_dispatch: bool,
|
||||
pub cancellation_token: CancellationToken,
|
||||
}
|
||||
|
||||
@@ -347,11 +348,22 @@ pub(crate) async fn handle_output_item_done(
|
||||
.await;
|
||||
|
||||
let cancellation_token = ctx.cancellation_token.child_token();
|
||||
let tool_future: InFlightFuture<'static> = Box::pin(
|
||||
ctx.tool_runtime
|
||||
.clone()
|
||||
.handle_tool_call(call, cancellation_token),
|
||||
);
|
||||
let runtime = ctx.tool_runtime.clone();
|
||||
// handle_tool_call dispatches immediately, not when its future is polled.
|
||||
// With a synchronous post-model hook, dispatch only when the turn drains tools.
|
||||
let tool_future: InFlightFuture<'static> = if ctx.defer_tool_dispatch {
|
||||
Box::pin(async move {
|
||||
if cancellation_token.is_cancelled() {
|
||||
// The call is already in history; resume needs its output even if we never dispatch.
|
||||
let output =
|
||||
ToolCallRuntime::aborted_response(&call, /*elapsed_secs*/ None);
|
||||
return Ok(output.into_response());
|
||||
}
|
||||
runtime.handle_tool_call(call, cancellation_token).await
|
||||
})
|
||||
} else {
|
||||
Box::pin(runtime.handle_tool_call(call, cancellation_token))
|
||||
};
|
||||
|
||||
output.needs_follow_up = true;
|
||||
output.tool_future = Some(tool_future);
|
||||
|
||||
@@ -314,6 +314,7 @@ fn output_context(session: Arc<Session>, turn_context: Arc<TurnContext>) -> Hand
|
||||
step_context,
|
||||
turn_store: Arc::new(ExtensionData::new(turn_context.sub_id.clone())),
|
||||
tool_runtime,
|
||||
defer_tool_dispatch: false,
|
||||
cancellation_token: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ impl ToolCallRuntime {
|
||||
match dispatch_handle.await {
|
||||
Ok(result) => result,
|
||||
Err(err) if err.is_cancelled() => {
|
||||
let response = Self::aborted_response(&call, secs);
|
||||
let response = Self::aborted_response(&call, Some(secs));
|
||||
call_trace::result_ready(
|
||||
thread_id,
|
||||
&abort_turn.sub_id,
|
||||
@@ -313,7 +313,7 @@ impl ToolCallRuntime {
|
||||
.await;
|
||||
result = Ok(Self::aborted_response(
|
||||
&call,
|
||||
started.elapsed().as_secs_f32().max(0.1),
|
||||
Some(started.elapsed().as_secs_f32().max(0.1)),
|
||||
));
|
||||
}
|
||||
// Use one completion measurement for logging and response formatting.
|
||||
@@ -362,18 +362,21 @@ impl ToolCallRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn aborted_response(call: &ToolCall, secs: f32) -> AnyToolResult {
|
||||
pub(crate) fn aborted_response(call: &ToolCall, elapsed_secs: Option<f32>) -> AnyToolResult {
|
||||
AnyToolResult {
|
||||
call_id: call.call_id.clone(),
|
||||
payload: call.payload.clone(),
|
||||
result: Box::new(AbortedToolOutput {
|
||||
message: Self::abort_message(call, secs),
|
||||
message: Self::abort_message(call, elapsed_secs),
|
||||
}),
|
||||
post_tool_use_payload: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn abort_message(call: &ToolCall, secs: f32) -> String {
|
||||
fn abort_message(call: &ToolCall, elapsed_secs: Option<f32>) -> String {
|
||||
let Some(secs) = elapsed_secs else {
|
||||
return "aborted by user before dispatch".to_string();
|
||||
};
|
||||
if call.tool_name.is_default_namespace() && call.tool_name.name == "exec_command" {
|
||||
format!("Wall time: {secs:.1} seconds\naborted by user")
|
||||
} else {
|
||||
|
||||
@@ -31,6 +31,7 @@ use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::InternalSessionSource;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
@@ -5985,3 +5986,298 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_lifecycle_hooks(home: &Path, events: &[&str]) -> Result<()> {
|
||||
let mut hooks = serde_json::Map::new();
|
||||
for event in events {
|
||||
let log = home.join(format!("{event}.jsonl"));
|
||||
let command = format!(
|
||||
"cat >> '{log}'; echo >> '{log}'; printf '%s' '{{\"continue\":false,\"decision\":\"block\",\"reason\":\"ignored-observer-control\"}}'",
|
||||
log = log.display(),
|
||||
);
|
||||
hooks.insert(
|
||||
(*event).into(),
|
||||
json!([{"hooks": [{"type": "command", "command": command}]}]),
|
||||
);
|
||||
}
|
||||
fs::write(home.join("hooks.json"), json!({"hooks": hooks}).to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_hook_inputs(home: &Path, event: &str, count: usize) -> Result<Vec<Value>> {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Ok(inputs) = read_hook_inputs_from_log(&home.join(format!("{event}.jsonl")))
|
||||
&& inputs.len() >= count
|
||||
{
|
||||
return Ok(inputs);
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_request_hooks_report_failures_and_retries_but_cannot_veto() -> Result<()> {
|
||||
core_test_support::skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let requests = mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![ev_response_created("failed-attempt")]),
|
||||
sse(vec![
|
||||
ev_response_created("successful-attempt"),
|
||||
ev_assistant_message("answer", "done"),
|
||||
ev_completed_with_tokens("successful-attempt", /*total_tokens*/ 42),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
write_lifecycle_hooks(home, &["PreModelRequest", "PostModelRequest"]).unwrap()
|
||||
})
|
||||
.with_config(trust_discovered_hooks);
|
||||
let test = builder.build(&server).await?;
|
||||
test.submit_turn("respond").await?;
|
||||
for request in requests.requests() {
|
||||
assert!(
|
||||
!request
|
||||
.body_json()
|
||||
.to_string()
|
||||
.contains("ignored-observer-control")
|
||||
);
|
||||
}
|
||||
let pre = wait_for_hook_inputs(test.codex_home_path(), "PreModelRequest", /*count*/ 2).await?;
|
||||
let post =
|
||||
wait_for_hook_inputs(test.codex_home_path(), "PostModelRequest", /*count*/ 2).await?;
|
||||
assert_eq!(pre.len(), 2);
|
||||
assert_eq!(post.len(), 2);
|
||||
assert_eq!(post[0]["status"], "failed");
|
||||
assert!(post[0]["error"].is_string());
|
||||
assert_eq!(post[1]["status"], "completed");
|
||||
assert_eq!(post[1]["error"], Value::Null);
|
||||
assert_eq!(post[0]["token_usage"], Value::Null);
|
||||
assert_eq!(
|
||||
post[1]["token_usage"],
|
||||
json!({
|
||||
"input_tokens": 42,
|
||||
"cached_input_tokens": 0,
|
||||
"cache_write_input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"reasoning_output_tokens": 0,
|
||||
"total_tokens": 42,
|
||||
})
|
||||
);
|
||||
for (before, after) in pre.iter().zip(&post) {
|
||||
let mut common = after.clone();
|
||||
common["hook_event_name"] = json!("PreModelRequest");
|
||||
let fields = common.as_object_mut().unwrap();
|
||||
fields.remove("status");
|
||||
fields.remove("error");
|
||||
fields.remove("token_usage");
|
||||
assert_eq!(before, &common);
|
||||
assert!(before["session_id"].is_string());
|
||||
assert!(before["cwd"].is_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_request_observer_reports_cancellation() -> Result<()> {
|
||||
core_test_support::skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let request_received = Arc::new(tokio::sync::Notify::new());
|
||||
let notify = Arc::clone(&request_received);
|
||||
responses::mount_response_once_match(
|
||||
&server,
|
||||
move |_: &wiremock::Request| {
|
||||
notify.notify_one();
|
||||
true
|
||||
},
|
||||
responses::sse_response(responses::sse_completed("initial"))
|
||||
.set_delay(Duration::from_secs(60)),
|
||||
)
|
||||
.await;
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
write_lifecycle_hooks(home, &["PreModelRequest", "PostModelRequest"]).unwrap()
|
||||
})
|
||||
.with_config(trust_discovered_hooks);
|
||||
let test = builder.build(&server).await?;
|
||||
test.codex
|
||||
.start_or_steer_turn(codex_core::TurnInputRequest::user_input(vec![
|
||||
codex_protocol::user_input::UserInput::Text {
|
||||
text: "respond".into(),
|
||||
text_elements: vec![],
|
||||
},
|
||||
]))
|
||||
.await?;
|
||||
timeout(Duration::from_secs(10), request_received.notified()).await?;
|
||||
test.codex
|
||||
.submit(codex_protocol::protocol::Op::Interrupt)
|
||||
.await?;
|
||||
let post =
|
||||
wait_for_hook_inputs(test.codex_home_path(), "PostModelRequest", /*count*/ 1).await?;
|
||||
assert_eq!(post.len(), 1);
|
||||
assert_eq!(post[0]["status"], "cancelled");
|
||||
assert_eq!(post[0]["token_usage"], Value::Null);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case(Some(false), false; "sync finishes before dispatch")]
|
||||
#[test_case::test_case(Some(false), true; "resume after interrupt preserves aborted custom call and usage")]
|
||||
#[test_case::test_case(Some(true), false; "async dispatches before stream ends")]
|
||||
#[test_case::test_case(None, false; "no post hook dispatches before stream ends")]
|
||||
#[tokio::test]
|
||||
async fn model_request_post_hook_orders_tool_dispatch(
|
||||
hook_async: Option<bool>,
|
||||
interrupt: bool,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let (finish_stream, gate) = oneshot::channel();
|
||||
let call = if interrupt {
|
||||
ev_apply_patch_custom_tool_call(
|
||||
"call",
|
||||
"*** Begin Patch\n*** Add File: should-not-exist\n+unexpected\n*** End Patch",
|
||||
)
|
||||
} else {
|
||||
ev_function_call("call", "test_sync_tool", "{}")
|
||||
};
|
||||
let first = vec![
|
||||
StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![ev_response_created("first"), call]),
|
||||
},
|
||||
StreamingSseChunk {
|
||||
gate: Some(gate),
|
||||
body: sse_event(ev_completed_with_tokens("first", /*total_tokens*/ 42)),
|
||||
},
|
||||
];
|
||||
let second = vec![StreamingSseChunk {
|
||||
gate: None,
|
||||
body: sse(vec![ev_response_created("second"), ev_completed("second")]),
|
||||
}];
|
||||
let (server, _) = start_streaming_sse_server(vec![first, second]).await;
|
||||
let test = test_codex()
|
||||
.with_model("test-gpt-5.1-codex")
|
||||
.with_pre_build_hook(move |home| {
|
||||
let order = home.join("order").display().to_string();
|
||||
let tool = format!("echo tool >> '{order}'");
|
||||
let mut hooks =
|
||||
json!({"PreToolUse": [{"hooks": [{"type": "command", "command": tool}]}]});
|
||||
if let Some(hook_async) = hook_async {
|
||||
let started = home.join("post-started");
|
||||
let release = home.join("release-post");
|
||||
let post = format!(
|
||||
"touch '{}'; while test ! -e '{}'; do sleep 0.02; done; echo post >> '{order}'",
|
||||
started.display(),
|
||||
release.display(),
|
||||
);
|
||||
hooks["PostModelRequest"] = json!([{"hooks": [{
|
||||
"type": "command", "command": post, "async": hook_async,
|
||||
}]}]);
|
||||
}
|
||||
fs::write(home.join("hooks.json"), json!({"hooks": hooks}).to_string())
|
||||
.expect("write hook configuration");
|
||||
})
|
||||
.with_config(trust_discovered_hooks)
|
||||
.build_with_streaming_server(&server)
|
||||
.await?;
|
||||
test.codex
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "use the tool".into(),
|
||||
text_elements: vec![],
|
||||
}]))
|
||||
.await?;
|
||||
|
||||
let home = test.codex_home_path();
|
||||
let order = home.join("order");
|
||||
let sync_post = hook_async == Some(false);
|
||||
if !sync_post {
|
||||
// Dispatch must not depend on the terminal response event, which we still hold.
|
||||
fs_wait::wait_for_path_exists(&order, Duration::from_secs(10)).await?;
|
||||
}
|
||||
finish_stream.send(()).expect("release response stream");
|
||||
let release = home.join("release-post");
|
||||
if sync_post {
|
||||
fs_wait::wait_for_path_exists(home.join("post-started"), Duration::from_secs(10)).await?;
|
||||
}
|
||||
if interrupt {
|
||||
test.codex.submit(Op::Interrupt).await?;
|
||||
wait_for_event(&test.codex, |event| matches!(event,
|
||||
EventMsg::TokenCount(ev) if ev.info.as_ref().is_some_and(|info| info.total_token_usage.total_tokens == 42)
|
||||
)).await;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnAborted(_))
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
!order.exists(),
|
||||
"interrupt must not dispatch the deferred tool"
|
||||
);
|
||||
fs::write(release, "")?;
|
||||
assert!(!test.workspace_path("should-not-exist").exists());
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let requests = mount_sse_once(&server, responses::sse_completed("resumed")).await;
|
||||
let resumed = test_codex().restart(&server, &test).await?;
|
||||
let usage = resumed
|
||||
.codex
|
||||
.token_usage_info()
|
||||
.await
|
||||
.context("restored usage")?;
|
||||
assert_eq!(usage.total_token_usage.total_tokens, 42);
|
||||
resumed.submit_turn("continue after interrupt").await?;
|
||||
let (output, _) = requests
|
||||
.single_request()
|
||||
.custom_tool_call_output_content_and_success("call")
|
||||
.context("aborted custom tool output")?;
|
||||
assert_eq!(output.as_deref(), Some("aborted by user before dispatch"));
|
||||
} else {
|
||||
fs::write(release, "")?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
let events = fs::read_to_string(order)?;
|
||||
assert!(
|
||||
events.starts_with(if sync_post { "post\ntool\n" } else { "tool\n" }),
|
||||
"{events}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn memory_consolidation_skips_user_model_request_hooks() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let response = mount_sse_once(&server, responses::sse_completed("memory")).await;
|
||||
let mut test = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
write_lifecycle_hooks(home, &["PreModelRequest", "PostModelRequest"])
|
||||
.expect("write synchronous model request hooks");
|
||||
})
|
||||
.with_config(trust_discovered_hooks)
|
||||
.build(&server)
|
||||
.await?;
|
||||
let thread = test
|
||||
.thread_manager
|
||||
.start_thread(StartThreadOptions {
|
||||
session_source: Some(SessionSource::Internal(
|
||||
InternalSessionSource::MemoryConsolidation,
|
||||
)),
|
||||
..StartThreadOptions::new(test.config.clone())
|
||||
})
|
||||
.await?;
|
||||
test.codex = thread.thread;
|
||||
test.submit_turn("consolidate").await?;
|
||||
response.single_request();
|
||||
let home = test.codex_home_path();
|
||||
for event in ["PreModelRequest", "PostModelRequest"] {
|
||||
assert!(!home.join(format!("{event}.jsonl")).exists(), "{event} ran");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AbsolutePathBuf": {
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"ModelRequestStatus": {
|
||||
"enum": [
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NullableString": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"TokenUsage": {
|
||||
"properties": {
|
||||
"cache_write_input_tokens": {
|
||||
"default": 0,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"cached_input_tokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"input_tokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"output_tokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"reasoning_output_tokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"total_tokens": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cached_input_tokens",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"reasoning_output_tokens",
|
||||
"total_tokens"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"hook_event_name": {
|
||||
"enum": [
|
||||
"PreModelRequest"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hook_event_name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"error": {
|
||||
"$ref": "#/definitions/NullableString"
|
||||
},
|
||||
"hook_event_name": {
|
||||
"enum": [
|
||||
"PostModelRequest"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/ModelRequestStatus"
|
||||
},
|
||||
"token_usage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/TokenUsage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error",
|
||||
"hook_event_name",
|
||||
"status",
|
||||
"token_usage"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"cwd": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"transcript_path": {
|
||||
"$ref": "#/definitions/NullableString"
|
||||
},
|
||||
"turn_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cwd",
|
||||
"model",
|
||||
"session_id",
|
||||
"transcript_path",
|
||||
"turn_id"
|
||||
],
|
||||
"title": "model-request.command.input",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -259,7 +259,7 @@ fn append_managed_requirement_handlers(
|
||||
config_layer_stack: None,
|
||||
plugin_root: None,
|
||||
},
|
||||
managed_hooks.get().hooks.clone(),
|
||||
managed_hooks.get().hooks.as_ref().clone(),
|
||||
policy,
|
||||
);
|
||||
required_load_errors
|
||||
|
||||
@@ -67,9 +67,11 @@ pub(crate) fn select_handlers_for_matcher_inputs(
|
||||
.any(|input| matches_matcher(handler.matcher.as_ref(), Some(input)))
|
||||
}
|
||||
}
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop | HookEventName::Interrupt => {
|
||||
true
|
||||
}
|
||||
HookEventName::PreModelRequest
|
||||
| HookEventName::PostModelRequest
|
||||
| HookEventName::UserPromptSubmit
|
||||
| HookEventName::Stop
|
||||
| HookEventName::Interrupt => true,
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
@@ -267,6 +269,8 @@ pub(crate) fn scope_for_event(event_name: HookEventName) -> HookScope {
|
||||
| HookEventName::UserPromptSubmit
|
||||
| HookEventName::SubagentStop
|
||||
| HookEventName::Stop
|
||||
| HookEventName::PreModelRequest
|
||||
| HookEventName::PostModelRequest
|
||||
| HookEventName::Interrupt => HookScope::Turn,
|
||||
}
|
||||
}
|
||||
@@ -278,6 +282,8 @@ pub(crate) fn hook_event_name_label(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PostToolUse => "PostToolUse",
|
||||
HookEventName::PreCompact => "PreCompact",
|
||||
HookEventName::PostCompact => "PostCompact",
|
||||
HookEventName::PreModelRequest => "PreModelRequest",
|
||||
HookEventName::PostModelRequest => "PostModelRequest",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::SessionEnd => "SessionEnd",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
|
||||
@@ -145,6 +145,25 @@ pub(crate) struct HandlerRunResult {
|
||||
}
|
||||
|
||||
impl ConfiguredHandler {
|
||||
/// Internal memory work runs policy and executor hooks, not user or project hooks.
|
||||
pub(crate) fn runs_for_memory_consolidation(&self) -> bool {
|
||||
matches!(self.source_path, HandlerSourcePath::ExecutorScoped { .. })
|
||||
|| match self.source {
|
||||
HookSource::User
|
||||
| HookSource::Project
|
||||
| HookSource::SessionFlags
|
||||
| HookSource::Plugin => false,
|
||||
HookSource::System
|
||||
| HookSource::Mdm
|
||||
| HookSource::CloudRequirements
|
||||
| HookSource::CloudManagedConfig
|
||||
| HookSource::LegacyManagedConfigFile
|
||||
| HookSource::LegacyManagedConfigMdm
|
||||
// Required hooks can have unknown attribution; retain them fail-closed.
|
||||
| HookSource::Unknown => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn execution_mode(&self) -> HookExecutionMode {
|
||||
if matches!(self.source_path, HandlerSourcePath::ExecutorScoped { .. }) {
|
||||
return HookExecutionMode::Async;
|
||||
@@ -178,6 +197,8 @@ impl ConfiguredHandler {
|
||||
codex_protocol::protocol::HookEventName::PostToolUse => "post-tool-use",
|
||||
codex_protocol::protocol::HookEventName::PreCompact => "pre-compact",
|
||||
codex_protocol::protocol::HookEventName::PostCompact => "post-compact",
|
||||
codex_protocol::protocol::HookEventName::PreModelRequest => "pre-model-request",
|
||||
codex_protocol::protocol::HookEventName::PostModelRequest => "post-model-request",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
|
||||
codex_protocol::protocol::HookEventName::SessionEnd => "session-end",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
|
||||
|
||||
@@ -144,7 +144,7 @@ fn managed_hooks_for_current_platform(
|
||||
} else {
|
||||
None
|
||||
},
|
||||
hooks,
|
||||
hooks: Box::new(hooks),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,11 @@ pub(crate) fn matcher_pattern_for_event(
|
||||
| HookEventName::SubagentStop
|
||||
| HookEventName::PreCompact
|
||||
| HookEventName::PostCompact => matcher,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop | HookEventName::Interrupt => None,
|
||||
HookEventName::PreModelRequest
|
||||
| HookEventName::PostModelRequest
|
||||
| HookEventName::UserPromptSubmit
|
||||
| HookEventName::Stop
|
||||
| HookEventName::Interrupt => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,5 @@ pub mod session_end;
|
||||
pub mod session_start;
|
||||
pub mod stop;
|
||||
pub mod user_prompt_submit;
|
||||
|
||||
pub mod model_request;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Observation-only lifecycle hooks. Output is logged, never interpreted as control flow.
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::common;
|
||||
use crate::engine::ClaudeHooksEngine;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::HandlerRunResult;
|
||||
use crate::engine::dispatcher;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, JsonSchema)]
|
||||
#[serde(tag = "hook_event_name")]
|
||||
pub enum ModelRequestHookEvent {
|
||||
PreModelRequest,
|
||||
PostModelRequest {
|
||||
status: ModelRequestStatus,
|
||||
#[schemars(with = "crate::schema::NullableString")]
|
||||
error: Option<String>,
|
||||
#[schemars(required, schema_with = "token_usage_schema")]
|
||||
token_usage: Option<TokenUsage>,
|
||||
},
|
||||
}
|
||||
|
||||
// Hook schemas otherwise omit null from Option fields, but usage is always present on post.
|
||||
fn token_usage_schema(
|
||||
generator: &mut schemars::r#gen::SchemaGenerator,
|
||||
) -> schemars::schema::Schema {
|
||||
schemars::schema::SchemaObject {
|
||||
subschemas: Some(Box::new(schemars::schema::SubschemaValidation {
|
||||
any_of: Some(vec![
|
||||
generator.subschema_for::<TokenUsage>(),
|
||||
generator.subschema_for::<()>(),
|
||||
]),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModelRequestStatus {
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ModelRequestHookEvent {
|
||||
fn hook_event(&self) -> HookEventName {
|
||||
match self {
|
||||
Self::PreModelRequest => HookEventName::PreModelRequest,
|
||||
Self::PostModelRequest { .. } => HookEventName::PostModelRequest,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, JsonSchema)]
|
||||
#[schemars(rename = "model-request.command.input")]
|
||||
pub struct ModelRequestHookInput {
|
||||
pub session_id: String,
|
||||
pub turn_id: String,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
#[schemars(with = "crate::schema::NullableString")]
|
||||
pub transcript_path: Option<std::path::PathBuf>,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_type: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub event: ModelRequestHookEvent,
|
||||
}
|
||||
|
||||
pub(crate) fn preview(
|
||||
handlers: &[ConfiguredHandler],
|
||||
request: &ModelRequestHookInput,
|
||||
) -> Vec<HookRunSummary> {
|
||||
dispatcher::select_handlers(
|
||||
handlers,
|
||||
request.event.hook_event(),
|
||||
/*matcher_input*/ None,
|
||||
)
|
||||
.iter()
|
||||
.map(dispatcher::running_summary)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
engine: &ClaudeHooksEngine,
|
||||
request: ModelRequestHookInput,
|
||||
) -> Vec<HookCompletedEvent> {
|
||||
let matched = dispatcher::select_handlers(
|
||||
&engine.handlers,
|
||||
request.event.hook_event(),
|
||||
/*matcher_input*/ None,
|
||||
);
|
||||
if matched.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let input = match serde_json::to_string(&request) {
|
||||
Ok(input) => input,
|
||||
Err(error) => {
|
||||
return common::serialization_failure_hook_events(
|
||||
matched,
|
||||
Some(request.turn_id),
|
||||
error.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
dispatcher::execute_handlers(
|
||||
engine,
|
||||
matched,
|
||||
input,
|
||||
request.cwd.as_path(),
|
||||
Some(request.turn_id),
|
||||
parse_completed,
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|result| result.completed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_completed(
|
||||
handler: &ConfiguredHandler,
|
||||
result: HandlerRunResult,
|
||||
turn_id: Option<String>,
|
||||
) -> dispatcher::ParsedHandler<()> {
|
||||
let status = if result.error.is_none() && result.exit_code == Some(0) {
|
||||
HookRunStatus::Completed
|
||||
} else {
|
||||
HookRunStatus::Failed
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
// Observer stdout is diagnostic, including any JSON that looks like a control directive.
|
||||
if !result.stdout.trim().is_empty() {
|
||||
tracing::debug!(stdout = %result.stdout, "model request hook output");
|
||||
}
|
||||
if let Some(text) = common::trimmed_non_empty(&result.stderr) {
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text,
|
||||
});
|
||||
}
|
||||
if let Some(error) = &result.error {
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: error.clone(),
|
||||
});
|
||||
}
|
||||
dispatcher::ParsedHandler {
|
||||
completed: HookCompletedEvent {
|
||||
turn_id,
|
||||
run: dispatcher::completed_summary(handler, &result, status, entries),
|
||||
},
|
||||
data: (),
|
||||
completion_order: 0,
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use codex_protocol::protocol::HookOutputEntry;
|
||||
use codex_protocol::protocol::HookOutputEntryKind;
|
||||
use codex_protocol::protocol::HookRunStatus;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
@@ -70,24 +69,7 @@ impl StopHookTarget {
|
||||
.into_iter()
|
||||
.filter(|handler| {
|
||||
!matches!(self, Self::MemoryConsolidation)
|
||||
|| matches!(
|
||||
handler.source_path,
|
||||
HandlerSourcePath::ExecutorScoped { .. }
|
||||
)
|
||||
|| match handler.source {
|
||||
HookSource::User
|
||||
| HookSource::Project
|
||||
| HookSource::SessionFlags
|
||||
| HookSource::Plugin => false,
|
||||
HookSource::System
|
||||
| HookSource::Mdm
|
||||
| HookSource::CloudRequirements
|
||||
| HookSource::CloudManagedConfig
|
||||
| HookSource::LegacyManagedConfigFile
|
||||
| HookSource::LegacyManagedConfigMdm
|
||||
// Required hooks can have unknown attribution; retain them fail-closed.
|
||||
| HookSource::Unknown => true,
|
||||
}
|
||||
|| handler.runs_for_memory_consolidation()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ pub use engine::dispatcher::hook_execution_mode_label;
|
||||
pub use engine::dispatcher::hook_handler_type_label;
|
||||
pub use events::common::SubagentHookContext;
|
||||
/// Hook event names as they appear in hooks JSON and config files.
|
||||
pub const HOOK_EVENT_NAMES: [&str; 12] = [
|
||||
pub const HOOK_EVENT_NAMES: [&str; 14] = [
|
||||
"PreModelRequest",
|
||||
"PostModelRequest",
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
@@ -102,6 +104,8 @@ pub fn hook_event_key_label(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PostToolUse => "post_tool_use",
|
||||
HookEventName::PreCompact => "pre_compact",
|
||||
HookEventName::PostCompact => "post_compact",
|
||||
HookEventName::PreModelRequest => "pre_model_request",
|
||||
HookEventName::PostModelRequest => "post_model_request",
|
||||
HookEventName::SessionStart => "session_start",
|
||||
HookEventName::SessionEnd => "session_end",
|
||||
HookEventName::UserPromptSubmit => "user_prompt_submit",
|
||||
@@ -124,3 +128,7 @@ pub fn hook_key(
|
||||
hook_event_key_label(event_name)
|
||||
)
|
||||
}
|
||||
|
||||
pub use events::model_request::ModelRequestHookEvent;
|
||||
pub use events::model_request::ModelRequestHookInput;
|
||||
pub use events::model_request::ModelRequestStatus;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::engine::ClaudeHooksEngine;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::HookListEntry;
|
||||
use crate::engine::command_runner::CommandHookRuntime;
|
||||
use crate::events::compact::PostCompactRequest;
|
||||
@@ -32,8 +33,11 @@ use codex_config::ConfigLayerStack;
|
||||
use codex_plugin::ExecutorPluginHookSource;
|
||||
use codex_plugin::PluginHookSource;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookExecutionMode;
|
||||
use codex_protocol::shell_environment::scrub_non_inheritable_env_vars;
|
||||
use std::ffi::OsString;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
@@ -115,6 +119,13 @@ impl Hooks {
|
||||
hooks
|
||||
}
|
||||
|
||||
pub fn for_memory_consolidation(&self, executor_hooks: Vec<ExecutorPluginHookSource>) -> Self {
|
||||
let mut hooks = self.with_executor_hooks(executor_hooks);
|
||||
Arc::make_mut(&mut hooks.engine.handlers)
|
||||
.retain(ConfiguredHandler::runs_for_memory_consolidation);
|
||||
hooks
|
||||
}
|
||||
|
||||
fn from_config(
|
||||
config: HooksConfig,
|
||||
mcp_executor: Arc<dyn HookMcpExecutor>,
|
||||
@@ -299,6 +310,35 @@ impl Hooks {
|
||||
pub async fn run_interrupt(&self, request: InterruptRequest) -> InterruptOutcome {
|
||||
self.engine.run_interrupt(request).await
|
||||
}
|
||||
|
||||
/// None if unconfigured; Sync if any handler must finish before continuing.
|
||||
pub fn execution_mode_for_event(&self, event: HookEventName) -> Option<HookExecutionMode> {
|
||||
self.engine
|
||||
.handlers
|
||||
.iter()
|
||||
.filter(|handler| handler.event_name == event)
|
||||
.map(ConfiguredHandler::execution_mode)
|
||||
.min_by_key(|mode| matches!(mode, HookExecutionMode::Async))
|
||||
}
|
||||
|
||||
/// Run a hook task independently of the turn, but abort and join it at session shutdown.
|
||||
pub fn schedule_async_task(&self, task: impl Future<Output = ()> + Send + 'static) {
|
||||
self.engine.command_runtime.schedule_async_task(task);
|
||||
}
|
||||
|
||||
pub fn preview_model_request(
|
||||
&self,
|
||||
request: &crate::ModelRequestHookInput,
|
||||
) -> Vec<codex_protocol::protocol::HookRunSummary> {
|
||||
crate::events::model_request::preview(&self.engine.handlers, request)
|
||||
}
|
||||
|
||||
pub async fn run_model_request(
|
||||
&self,
|
||||
request: crate::ModelRequestHookInput,
|
||||
) -> Vec<codex_protocol::protocol::HookCompletedEvent> {
|
||||
crate::events::model_request::run(&self.engine, request).await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_hooks(config: HooksConfig) -> HookListOutcome {
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::path::PathBuf;
|
||||
use crate::events::common::SubagentHookContext;
|
||||
|
||||
const GENERATED_DIR: &str = "generated";
|
||||
const MODEL_REQUEST_INPUT_FIXTURE: &str = "model-request.command.input.schema.json";
|
||||
const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json";
|
||||
const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json";
|
||||
const PERMISSION_REQUEST_INPUT_FIXTURE: &str = "permission-request.command.input.schema.json";
|
||||
@@ -641,6 +642,11 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
|
||||
let generated_dir = schema_root.join(GENERATED_DIR);
|
||||
ensure_empty_dir(&generated_dir)?;
|
||||
|
||||
write_schema(
|
||||
&generated_dir.join(MODEL_REQUEST_INPUT_FIXTURE),
|
||||
schema_json::<crate::ModelRequestHookInput>()?,
|
||||
)?;
|
||||
|
||||
write_schema(
|
||||
&generated_dir.join(POST_TOOL_USE_INPUT_FIXTURE),
|
||||
schema_json::<PostToolUseCommandInput>()?,
|
||||
@@ -890,6 +896,7 @@ mod tests {
|
||||
use super::INTERRUPT_INPUT_FIXTURE;
|
||||
use super::INTERRUPT_OUTPUT_FIXTURE;
|
||||
use super::InterruptCommandInput;
|
||||
use super::MODEL_REQUEST_INPUT_FIXTURE;
|
||||
use super::NullableString;
|
||||
use super::PERMISSION_REQUEST_INPUT_FIXTURE;
|
||||
use super::PERMISSION_REQUEST_OUTPUT_FIXTURE;
|
||||
@@ -939,6 +946,9 @@ mod tests {
|
||||
|
||||
fn expected_fixture(name: &str) -> &'static str {
|
||||
match name {
|
||||
MODEL_REQUEST_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/model-request.command.input.schema.json")
|
||||
}
|
||||
POST_TOOL_USE_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/post-tool-use.command.input.schema.json")
|
||||
}
|
||||
@@ -1037,6 +1047,7 @@ mod tests {
|
||||
write_schema_fixtures(&schema_root).expect("write generated hook schemas");
|
||||
|
||||
for fixture in [
|
||||
MODEL_REQUEST_INPUT_FIXTURE,
|
||||
POST_TOOL_USE_INPUT_FIXTURE,
|
||||
POST_TOOL_USE_OUTPUT_FIXTURE,
|
||||
PERMISSION_REQUEST_INPUT_FIXTURE,
|
||||
@@ -1172,6 +1183,8 @@ mod tests {
|
||||
#[test]
|
||||
fn subagent_context_fields_are_optional_for_hooks_that_run_inside_subagents() {
|
||||
let schemas = [
|
||||
schema_json::<crate::ModelRequestHookInput>()
|
||||
.expect("serialize model request input schema"),
|
||||
schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
|
||||
schema_json::<PermissionRequestCommandInput>()
|
||||
.expect("serialize permission request input schema"),
|
||||
|
||||
@@ -1703,6 +1703,8 @@ pub enum HookEventName {
|
||||
SubagentStop,
|
||||
Stop,
|
||||
Interrupt,
|
||||
PreModelRequest,
|
||||
PostModelRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -676,6 +676,8 @@ fn event_label(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PostToolUse => "PostToolUse",
|
||||
HookEventName::PreCompact => "PreCompact",
|
||||
HookEventName::PostCompact => "PostCompact",
|
||||
HookEventName::PreModelRequest => "PreModelRequest",
|
||||
HookEventName::PostModelRequest => "PostModelRequest",
|
||||
HookEventName::SessionStart => "SessionStart",
|
||||
HookEventName::SessionEnd => "SessionEnd",
|
||||
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
||||
@@ -693,6 +695,8 @@ fn event_description(event_name: HookEventName) -> &'static str {
|
||||
HookEventName::PostToolUse => "After a tool executes",
|
||||
HookEventName::PreCompact => "Before context compaction",
|
||||
HookEventName::PostCompact => "After context compaction",
|
||||
HookEventName::PreModelRequest => "Before a model request",
|
||||
HookEventName::PostModelRequest => "After a model request, including failures",
|
||||
HookEventName::SessionStart => "When a new session starts",
|
||||
HookEventName::SessionEnd => "Right before a session ends",
|
||||
HookEventName::UserPromptSubmit => "When the user submits a prompt",
|
||||
@@ -891,6 +895,11 @@ mod tests {
|
||||
fn renders_event_browser() {
|
||||
let mut view = view();
|
||||
assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112));
|
||||
view.state.selected_idx = Some(view.event_rows().len() - 1);
|
||||
assert_snapshot!(
|
||||
"hooks_browser_model_requests",
|
||||
render_lines(&view, /*width*/ 112),
|
||||
);
|
||||
view.keymap.accept.clear();
|
||||
assert!(!render_lines(&view, /*width*/ 112).contains("enter details"));
|
||||
}
|
||||
|
||||
+3
-3
@@ -9,12 +9,12 @@ expression: text
|
||||
|
||||
Event Active/Installed
|
||||
↑
|
||||
SessionEnd 0/0
|
||||
UserPromptSubmit 0/0
|
||||
SubagentStart 0/0
|
||||
SubagentStop 0/0
|
||||
Stop 0/0
|
||||
› Interrupt 0/0
|
||||
Interrupt 0/0
|
||||
PreModelRequest 0/0
|
||||
› PostModelRequest 0/0
|
||||
|
||||
|
||||
enter details · esc close
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/hooks_browser_view.rs
|
||||
expression: "render_lines(&view, 112)"
|
||||
---
|
||||
|
||||
Hooks
|
||||
Lifecycle hooks from config and enabled plugins.
|
||||
|
||||
Event Installed Active Description
|
||||
↑
|
||||
SessionEnd 0 0 Right before a session ends
|
||||
UserPromptSubmit 0 0 When the user submits a prompt
|
||||
SubagentStart 0 0 When a subagent is created
|
||||
SubagentStop 0 0 Right before a subagent ends its turn
|
||||
Stop 0 0 Right before Codex ends its turn
|
||||
Interrupt 0 0 Right before an interrupted turn is aborted
|
||||
PreModelRequest 0 0 Before a model request
|
||||
› PostModelRequest 0 0 After a model request, including failures
|
||||
|
||||
|
||||
enter details · esc close
|
||||
@@ -1386,7 +1386,7 @@ approval_policy = "never"
|
||||
std::path::PathBuf::from("/enterprise/hooks")
|
||||
}),
|
||||
windows_managed_dir: Some(std::path::PathBuf::from(r"C:\enterprise\hooks")),
|
||||
hooks: HookEventsToml {
|
||||
hooks: Box::new(HookEventsToml {
|
||||
pre_tool_use: vec![MatcherGroup {
|
||||
matcher: Some("^Bash$".to_string()),
|
||||
hooks: vec![HookHandlerConfig::Command {
|
||||
@@ -1399,7 +1399,7 @@ approval_policy = "never"
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
|
||||
)),
|
||||
|
||||
@@ -1984,6 +1984,8 @@ class HookErrorInfo(BaseModel):
|
||||
|
||||
|
||||
class HookEventName(Enum):
|
||||
pre_model_request = "preModelRequest"
|
||||
post_model_request = "postModelRequest"
|
||||
pre_tool_use = "preToolUse"
|
||||
permission_request = "permissionRequest"
|
||||
post_tool_use = "postToolUse"
|
||||
@@ -2422,8 +2424,14 @@ class ManagedHooksRequirements(BaseModel):
|
||||
list[ConfiguredHookMatcherGroup], Field(alias="PermissionRequest")
|
||||
]
|
||||
post_compact: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PostCompact")]
|
||||
post_model_request: Annotated[
|
||||
list[ConfiguredHookMatcherGroup] | None, Field(alias="PostModelRequest")
|
||||
] = []
|
||||
post_tool_use: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PostToolUse")]
|
||||
pre_compact: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PreCompact")]
|
||||
pre_model_request: Annotated[
|
||||
list[ConfiguredHookMatcherGroup] | None, Field(alias="PreModelRequest")
|
||||
] = []
|
||||
pre_tool_use: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="PreToolUse")]
|
||||
session_end: Annotated[list[ConfiguredHookMatcherGroup] | None, Field(alias="SessionEnd")] = []
|
||||
session_start: Annotated[list[ConfiguredHookMatcherGroup], Field(alias="SessionStart")]
|
||||
|
||||
Reference in New Issue
Block a user