mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
Activate prompt hooks
This commit is contained in:
@@ -543,7 +543,7 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand
|
||||
r#async,
|
||||
status_message,
|
||||
},
|
||||
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
|
||||
CoreHookHandlerConfig::Prompt { .. } => ConfiguredHookHandler::Prompt {},
|
||||
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,17 @@ pub enum HookHandlerConfig {
|
||||
status_message: Option<String>,
|
||||
},
|
||||
#[serde(rename = "prompt")]
|
||||
Prompt {},
|
||||
Prompt {
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
#[serde(default, rename = "timeout", alias = "timeoutSec")]
|
||||
timeout_sec: Option<u64>,
|
||||
#[serde(default, rename = "statusMessage")]
|
||||
status_message: Option<String>,
|
||||
#[serde(default, rename = "continueOnBlock")]
|
||||
continue_on_block: bool,
|
||||
},
|
||||
#[serde(rename = "agent")]
|
||||
Agent {},
|
||||
}
|
||||
|
||||
@@ -1057,6 +1057,27 @@
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"continueOnBlock": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"model": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"statusMessage": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"timeout": {
|
||||
"default": null,
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"prompt"
|
||||
@@ -1065,6 +1086,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"prompt",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
|
||||
@@ -238,6 +238,7 @@ pub struct ModelClient {
|
||||
pub struct ModelClientSession {
|
||||
client: ModelClient,
|
||||
websocket_session: WebsocketSession,
|
||||
cache_websocket_session_on_drop: bool,
|
||||
/// Turn state for sticky routing.
|
||||
///
|
||||
/// This is an `OnceLock` that stores the turn state value received from the server
|
||||
@@ -382,6 +383,18 @@ impl ModelClient {
|
||||
ModelClientSession {
|
||||
client: self.clone(),
|
||||
websocket_session: self.take_cached_websocket_session(),
|
||||
cache_websocket_session_on_drop: true,
|
||||
turn_state: Arc::new(OnceLock::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a fresh streaming session that does not read or write the session's cached
|
||||
/// WebSocket state.
|
||||
pub(crate) fn new_isolated_session(&self) -> ModelClientSession {
|
||||
ModelClientSession {
|
||||
client: self.clone(),
|
||||
websocket_session: WebsocketSession::default(),
|
||||
cache_websocket_session_on_drop: false,
|
||||
turn_state: Arc::new(OnceLock::new()),
|
||||
}
|
||||
}
|
||||
@@ -951,9 +964,11 @@ impl ModelClient {
|
||||
|
||||
impl Drop for ModelClientSession {
|
||||
fn drop(&mut self) {
|
||||
let websocket_session = std::mem::take(&mut self.websocket_session);
|
||||
self.client
|
||||
.store_cached_websocket_session(websocket_session);
|
||||
if self.cache_websocket_session_on_drop {
|
||||
let websocket_session = std::mem::take(&mut self.websocket_session);
|
||||
self.client
|
||||
.store_cached_websocket_session(websocket_session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::AuthRequestTelemetryContext;
|
||||
use super::ModelClient;
|
||||
use super::PendingUnauthorizedRetry;
|
||||
use super::UnauthorizedRecoveryExecution;
|
||||
use super::WebsocketSession;
|
||||
use super::X_CODEX_INSTALLATION_ID_HEADER;
|
||||
use super::X_CODEX_PARENT_THREAD_ID_HEADER;
|
||||
use super::X_CODEX_TURN_METADATA_HEADER;
|
||||
@@ -116,6 +117,24 @@ fn test_model_info() -> ModelInfo {
|
||||
.expect("deserialize test model info")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_session_does_not_touch_cached_websocket_state() {
|
||||
let model_client = test_model_client(SessionSource::Cli);
|
||||
let cached_websocket_session = WebsocketSession::default();
|
||||
cached_websocket_session.set_connection_reused(/*connection_reused*/ true);
|
||||
model_client.store_cached_websocket_session(cached_websocket_session);
|
||||
|
||||
let isolated_session = model_client.new_isolated_session();
|
||||
assert!(!isolated_session.websocket_session.connection_reused());
|
||||
drop(isolated_session);
|
||||
|
||||
assert!(
|
||||
model_client
|
||||
.take_cached_websocket_session()
|
||||
.connection_reused()
|
||||
);
|
||||
}
|
||||
|
||||
fn test_session_telemetry() -> SessionTelemetry {
|
||||
SessionTelemetry::new(
|
||||
ThreadId::new(),
|
||||
|
||||
160
codex-rs/core/src/hook_prompt.rs
Normal file
160
codex-rs/core/src/hook_prompt.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_hooks::PromptHookRequest;
|
||||
use codex_hooks::PromptHookRunner;
|
||||
use codex_models_manager::ModelsManagerConfig;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_rollout_trace::InferenceTraceContext;
|
||||
use futures::StreamExt;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::client::ModelClient;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::config::Config;
|
||||
use crate::stream_events_utils::raw_assistant_output_text_from_item;
|
||||
|
||||
const PROMPT_HOOK_BASE_INSTRUCTIONS: &str = r#"You evaluate a Codex prompt hook.
|
||||
|
||||
The user message contains:
|
||||
1. The hook author's instructions.
|
||||
2. The hook input JSON.
|
||||
|
||||
Decide whether the hook input satisfies the hook author's instructions.
|
||||
|
||||
Return only JSON:
|
||||
{"ok": true}
|
||||
or
|
||||
{"ok": false, "reason": "concise actionable reason"}
|
||||
|
||||
Use ok:false only when the hook criteria fail. Do not answer the user's task. Do not include Markdown or extra text."#;
|
||||
|
||||
pub(crate) fn build_prompt_hook_runner(
|
||||
model_client: ModelClient,
|
||||
models_manager: SharedModelsManager,
|
||||
config: &Config,
|
||||
session_telemetry: SessionTelemetry,
|
||||
service_tier: Option<String>,
|
||||
) -> PromptHookRunner {
|
||||
let models_manager_config = config.to_models_manager_config();
|
||||
PromptHookRunner::new(move |request| {
|
||||
let model_client = model_client.clone();
|
||||
let models_manager = Arc::clone(&models_manager);
|
||||
let models_manager_config = models_manager_config.clone();
|
||||
let session_telemetry = session_telemetry.clone();
|
||||
let service_tier = service_tier.clone();
|
||||
async move {
|
||||
run_prompt_hook(
|
||||
model_client,
|
||||
models_manager,
|
||||
models_manager_config,
|
||||
session_telemetry,
|
||||
service_tier,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the hook as an isolated model request: resolve the configured model,
|
||||
/// send the rendered hook prompt as the only user message, disable all
|
||||
/// tools/personality/reasoning summaries, and constrain the final answer to the
|
||||
/// small `{ ok, reason }` contract that `codex-hooks` maps back into existing
|
||||
/// hook output semantics.
|
||||
async fn run_prompt_hook(
|
||||
model_client: ModelClient,
|
||||
models_manager: SharedModelsManager,
|
||||
models_manager_config: ModelsManagerConfig,
|
||||
session_telemetry: SessionTelemetry,
|
||||
service_tier: Option<String>,
|
||||
request: PromptHookRequest,
|
||||
) -> anyhow::Result<String> {
|
||||
let model_info = models_manager
|
||||
.get_model_info(request.model.as_str(), &models_manager_config)
|
||||
.await;
|
||||
let prompt = Prompt {
|
||||
input: vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: request.prompt,
|
||||
}],
|
||||
phase: None,
|
||||
}],
|
||||
tools: Vec::new(),
|
||||
parallel_tool_calls: false,
|
||||
base_instructions: BaseInstructions {
|
||||
text: PROMPT_HOOK_BASE_INSTRUCTIONS.to_string(),
|
||||
},
|
||||
personality: None,
|
||||
output_schema: Some(prompt_hook_output_schema()),
|
||||
output_schema_strict: false,
|
||||
};
|
||||
|
||||
let disabled_trace = InferenceTraceContext::disabled();
|
||||
let mut client_session = model_client.new_isolated_session();
|
||||
let mut stream = client_session
|
||||
.stream(
|
||||
&prompt,
|
||||
&model_info,
|
||||
&session_telemetry,
|
||||
/*effort*/ None,
|
||||
ReasoningSummaryConfig::None,
|
||||
service_tier,
|
||||
/*turn_metadata_header*/ None,
|
||||
&disabled_trace,
|
||||
)
|
||||
.await?;
|
||||
let mut delta_text = String::new();
|
||||
let mut item_texts = Vec::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
match event? {
|
||||
ResponseEvent::OutputItemDone(item) => {
|
||||
if let Some(text) = raw_assistant_output_text_from_item(&item) {
|
||||
item_texts.push(text);
|
||||
}
|
||||
}
|
||||
ResponseEvent::OutputTextDelta(delta) => delta_text.push_str(delta.as_str()),
|
||||
ResponseEvent::Completed { .. } => break,
|
||||
ResponseEvent::Created
|
||||
| ResponseEvent::OutputItemAdded(_)
|
||||
| ResponseEvent::ServerModel(_)
|
||||
| ResponseEvent::ModelVerifications(_)
|
||||
| ResponseEvent::ServerReasoningIncluded(_)
|
||||
| ResponseEvent::ToolCallInputDelta { .. }
|
||||
| ResponseEvent::ReasoningSummaryDelta { .. }
|
||||
| ResponseEvent::ReasoningContentDelta { .. }
|
||||
| ResponseEvent::ReasoningSummaryPartAdded { .. }
|
||||
| ResponseEvent::RateLimits(_)
|
||||
| ResponseEvent::ModelsEtag(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
if item_texts.is_empty() {
|
||||
Ok(delta_text)
|
||||
} else {
|
||||
Ok(item_texts.join(""))
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_hook_output_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["ok"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
@@ -41,6 +41,7 @@ mod goals;
|
||||
pub use goals::ExternalGoalPreviousStatus;
|
||||
pub use goals::ExternalGoalSet;
|
||||
mod guardian;
|
||||
mod hook_prompt;
|
||||
mod hook_runtime;
|
||||
mod installation_id;
|
||||
pub(crate) mod landlock;
|
||||
|
||||
@@ -58,6 +58,7 @@ use codex_features::Feature;
|
||||
use codex_features::unstable_features_warning_event;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_hooks::HooksConfig;
|
||||
use codex_hooks::PromptHookRunner;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
@@ -1482,10 +1483,11 @@ impl Session {
|
||||
// layers such as request/session overrides that were present when this session
|
||||
// was created.
|
||||
let notify_config_contributors = !self.services.extensions.config_contributors().is_empty();
|
||||
let (previous_config, new_config, config) = {
|
||||
let (previous_config, new_config, config, service_tier) = {
|
||||
let mut state = self.state.lock().await;
|
||||
let previous_config = notify_config_contributors
|
||||
.then(|| Self::build_effective_session_config(&state.session_configuration));
|
||||
let service_tier = state.session_configuration.service_tier.clone();
|
||||
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
@@ -1496,15 +1498,23 @@ impl Session {
|
||||
state.session_configuration.original_config_do_not_use = Arc::clone(&config);
|
||||
let new_config = notify_config_contributors
|
||||
.then(|| Self::build_effective_session_config(&state.session_configuration));
|
||||
(previous_config, new_config, config)
|
||||
(previous_config, new_config, config, service_tier)
|
||||
};
|
||||
self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref());
|
||||
self.services.skills_manager.clear_cache();
|
||||
self.services.plugins_manager.clear_cache();
|
||||
let prompt_hook_runner = crate::hook_prompt::build_prompt_hook_runner(
|
||||
self.services.model_client.clone(),
|
||||
Arc::clone(&self.services.models_manager),
|
||||
config.as_ref(),
|
||||
self.services.session_telemetry.clone(),
|
||||
service_tier,
|
||||
);
|
||||
let hooks = build_hooks_for_config(
|
||||
config.as_ref(),
|
||||
self.services.plugins_manager.as_ref(),
|
||||
self.services.user_shell.as_ref(),
|
||||
Some(prompt_hook_runner),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -3382,6 +3392,7 @@ async fn build_hooks_for_config(
|
||||
config: &Config,
|
||||
plugins_manager: &PluginsManager,
|
||||
user_shell: &crate::shell::Shell,
|
||||
prompt_hook_runner: Option<PromptHookRunner>,
|
||||
) -> Hooks {
|
||||
let mut hook_shell_argv = user_shell.derive_exec_args("", /*use_login_shell*/ false);
|
||||
let hook_shell_program = hook_shell_argv.remove(0);
|
||||
@@ -3399,7 +3410,7 @@ async fn build_hooks_for_config(
|
||||
plugin_hook_load_warnings,
|
||||
shell_program: Some(hook_shell_program),
|
||||
shell_args: hook_shell_argv,
|
||||
prompt_hook_runner: None,
|
||||
prompt_hook_runner,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -942,17 +942,6 @@ impl Session {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let hooks =
|
||||
build_hooks_for_config(&config, plugins_manager.as_ref(), &default_shell).await;
|
||||
for warning in hooks.startup_warnings() {
|
||||
post_session_configured_events.push(Event {
|
||||
id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
msg: EventMsg::Warning(WarningEvent {
|
||||
message: warning.clone(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let analytics_events_client = analytics_events_client.unwrap_or_else(|| {
|
||||
AnalyticsEventsClient::new(
|
||||
Arc::clone(&auth_manager),
|
||||
@@ -966,6 +955,49 @@ impl Session {
|
||||
SessionId::from(thread_id)
|
||||
};
|
||||
let agent_control = agent_control.with_session_id(session_id);
|
||||
let model_client = ModelClient::new(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
session_id,
|
||||
thread_id,
|
||||
installation_id.clone(),
|
||||
session_configuration.provider.clone(),
|
||||
session_configuration.session_source.clone(),
|
||||
session_configuration.parent_thread_id,
|
||||
config.model_verbosity,
|
||||
config.features.enabled(Feature::EnableRequestCompression),
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Self::build_model_client_beta_features_header(config.as_ref()),
|
||||
attestation_provider.clone(),
|
||||
)
|
||||
.with_prompt_cache_key_override(
|
||||
crate::guardian::prompt_cache_key_override_for_review_session(
|
||||
&session_configuration.session_source,
|
||||
session_configuration.parent_thread_id,
|
||||
),
|
||||
);
|
||||
model_client.set_window_generation(window_generation);
|
||||
let prompt_hook_runner = crate::hook_prompt::build_prompt_hook_runner(
|
||||
model_client.clone(),
|
||||
Arc::clone(&models_manager),
|
||||
config.as_ref(),
|
||||
session_telemetry.clone(),
|
||||
session_configuration.service_tier.clone(),
|
||||
);
|
||||
let hooks = build_hooks_for_config(
|
||||
&config,
|
||||
plugins_manager.as_ref(),
|
||||
&default_shell,
|
||||
Some(prompt_hook_runner),
|
||||
)
|
||||
.await;
|
||||
for warning in hooks.startup_warnings() {
|
||||
post_session_configured_events.push(Event {
|
||||
id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
msg: EventMsg::Warning(WarningEvent {
|
||||
message: warning.clone(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
let session_extension_data =
|
||||
codex_extension_api::ExtensionData::new(session_id.to_string());
|
||||
let thread_extension_data =
|
||||
@@ -1031,32 +1063,10 @@ impl Session {
|
||||
live_thread: live_thread_init.as_ref().cloned(),
|
||||
thread_store: Arc::clone(&thread_store),
|
||||
attestation_provider: attestation_provider.clone(),
|
||||
model_client: ModelClient::new(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
session_id,
|
||||
thread_id,
|
||||
installation_id.clone(),
|
||||
session_configuration.provider.clone(),
|
||||
session_configuration.session_source.clone(),
|
||||
session_configuration.parent_thread_id,
|
||||
config.model_verbosity,
|
||||
config.features.enabled(Feature::EnableRequestCompression),
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Self::build_model_client_beta_features_header(config.as_ref()),
|
||||
attestation_provider,
|
||||
)
|
||||
.with_prompt_cache_key_override(
|
||||
crate::guardian::prompt_cache_key_override_for_review_session(
|
||||
&session_configuration.session_source,
|
||||
session_configuration.parent_thread_id,
|
||||
),
|
||||
),
|
||||
model_client,
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
|
||||
environment_manager,
|
||||
};
|
||||
services
|
||||
.model_client
|
||||
.set_window_generation(window_generation);
|
||||
let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) =
|
||||
watch::channel(false);
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
@@ -242,6 +243,23 @@ if payload.get("prompt") == {blocked_prompt_json}:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_user_prompt_submit_prompt_hook(home: &Path) -> Result<()> {
|
||||
let hooks = serde_json::json!({
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [{
|
||||
"hooks": [{
|
||||
"type": "prompt",
|
||||
"prompt": "Reject prompts that mention secrets: $ARGUMENTS",
|
||||
"statusMessage": "checking prompt",
|
||||
}]
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_session_start_and_user_prompt_submit_order_hooks(home: &Path) -> Result<()> {
|
||||
let session_start_script_path = home.join("session_start_order_hook.py");
|
||||
let user_prompt_submit_script_path = home.join("user_prompt_submit_order_hook.py");
|
||||
@@ -1713,6 +1731,68 @@ async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_prompt_submit_prompt_hook_runs_isolated_request() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let response = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("prompt-hook-resp"),
|
||||
ev_assistant_message(
|
||||
"prompt-hook-msg",
|
||||
r#"{"ok":false,"reason":"do not mention secrets"}"#,
|
||||
),
|
||||
ev_completed("prompt-hook-resp"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_model("gpt-5.4")
|
||||
.with_pre_build_hook(|home| {
|
||||
if let Err(error) = write_user_prompt_submit_prompt_hook(home) {
|
||||
panic!("failed to write user prompt submit prompt hook fixture: {error}");
|
||||
}
|
||||
})
|
||||
.with_config(trust_discovered_hooks);
|
||||
let test = builder.build(&server).await?;
|
||||
let prompt = "secret launch codes";
|
||||
|
||||
test.submit_turn(prompt).await?;
|
||||
|
||||
let request = response.single_request();
|
||||
let body = request.body_json();
|
||||
assert_eq!(body["model"], json!("gpt-5.4"));
|
||||
assert_eq!(body["tools"], json!([]));
|
||||
assert!(
|
||||
request
|
||||
.instructions_text()
|
||||
.starts_with("You evaluate a Codex prompt hook."),
|
||||
"prompt hook request should use isolated hook instructions",
|
||||
);
|
||||
let user_inputs = request.message_input_texts("user");
|
||||
assert_eq!(
|
||||
user_inputs.len(),
|
||||
1,
|
||||
"prompt hook request should contain exactly one user message",
|
||||
);
|
||||
assert_eq!(
|
||||
request.message_input_texts("developer"),
|
||||
Vec::<String>::new(),
|
||||
"prompt hook request should not inherit developer context",
|
||||
);
|
||||
let hook_input = user_inputs.first().context("prompt hook user input")?;
|
||||
assert!(hook_input.contains("Reject prompts that mention secrets:"));
|
||||
assert!(
|
||||
hook_input.contains(prompt),
|
||||
"prompt hook request should include the submitted prompt",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -62,7 +62,13 @@ mod tests {
|
||||
pre_tool_use: vec![MatcherGroup {
|
||||
matcher: None,
|
||||
hooks: vec![
|
||||
HookHandlerConfig::Prompt {},
|
||||
HookHandlerConfig::Prompt {
|
||||
prompt: "check $ARGUMENTS".to_string(),
|
||||
model: None,
|
||||
timeout_sec: None,
|
||||
status_message: None,
|
||||
continue_on_block: false,
|
||||
},
|
||||
HookHandlerConfig::Command {
|
||||
command: "echo hi".to_string(),
|
||||
command_windows: None,
|
||||
|
||||
@@ -24,9 +24,12 @@ use serde::Serialize;
|
||||
use super::ConfiguredHandler;
|
||||
use super::ConfiguredHandlerKind;
|
||||
use super::HookListEntry;
|
||||
use super::prompt_runner::PromptHookBehavior;
|
||||
use super::prompt_runner::prompt_hook_behavior;
|
||||
use crate::config_rules::hook_states_from_stack;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
use crate::events::common::validate_matcher_pattern;
|
||||
use crate::schema::hook_event_wire_name;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookSource;
|
||||
@@ -506,6 +509,9 @@ fn append_matcher_groups(
|
||||
handler_type: HookHandlerType::Command,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
command: Some(command.clone()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
continue_on_block: None,
|
||||
timeout_sec,
|
||||
status_message: status_message.clone(),
|
||||
source_path: source.path.clone(),
|
||||
@@ -540,10 +546,92 @@ fn append_matcher_groups(
|
||||
}
|
||||
*display_order += 1;
|
||||
}
|
||||
HookHandlerConfig::Prompt {} => warnings.push(format!(
|
||||
"skipping prompt hook in {}: prompt hooks are not supported yet",
|
||||
source.path.display()
|
||||
)),
|
||||
HookHandlerConfig::Prompt {
|
||||
prompt,
|
||||
model,
|
||||
timeout_sec,
|
||||
status_message,
|
||||
continue_on_block,
|
||||
} => {
|
||||
if matches!(
|
||||
prompt_hook_behavior(event_name),
|
||||
PromptHookBehavior::Unsupported
|
||||
) {
|
||||
warnings.push(format!(
|
||||
"skipping prompt hook in {}: prompt hooks are not supported for {}",
|
||||
source.path.display(),
|
||||
hook_event_wire_name(event_name)
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if prompt.trim().is_empty() {
|
||||
warnings.push(format!(
|
||||
"skipping empty hook prompt in {}",
|
||||
source.path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let timeout_sec = timeout_sec.unwrap_or(30).max(1);
|
||||
let normalized_handler = HookHandlerConfig::Prompt {
|
||||
prompt: prompt.clone(),
|
||||
model: model.clone(),
|
||||
timeout_sec: Some(timeout_sec),
|
||||
status_message: status_message.clone(),
|
||||
continue_on_block,
|
||||
};
|
||||
let current_hash = hook_hash(event_name, matcher, &group, normalized_handler);
|
||||
let key =
|
||||
crate::hook_key(&source.key_source, event_name, group_index, handler_index);
|
||||
let state = source.hook_states.get(&key);
|
||||
let enabled = hook_enabled(source.is_managed, state);
|
||||
let trusted_hash = hook_trusted_hash(source.is_managed, state);
|
||||
let trust_status =
|
||||
hook_trust_status(source.is_managed, ¤t_hash, trusted_hash);
|
||||
hook_entries.push(HookListEntry {
|
||||
key,
|
||||
event_name,
|
||||
handler_type: HookHandlerType::Prompt,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
command: None,
|
||||
prompt: Some(prompt.clone()),
|
||||
model: model.clone(),
|
||||
continue_on_block: Some(continue_on_block),
|
||||
timeout_sec,
|
||||
status_message: status_message.clone(),
|
||||
source_path: source.path.clone(),
|
||||
source: source.source,
|
||||
plugin_id: source.plugin_id.clone(),
|
||||
display_order: *display_order,
|
||||
enabled,
|
||||
is_managed: source.is_managed,
|
||||
current_hash,
|
||||
trust_status,
|
||||
});
|
||||
if enabled
|
||||
&& (source.bypass_hook_trust
|
||||
|| matches!(
|
||||
trust_status,
|
||||
HookTrustStatus::Managed | HookTrustStatus::Trusted
|
||||
))
|
||||
{
|
||||
handlers.push(ConfiguredHandler {
|
||||
event_name,
|
||||
matcher: matcher.map(ToOwned::to_owned),
|
||||
kind: ConfiguredHandlerKind::Prompt {
|
||||
prompt,
|
||||
model,
|
||||
timeout_sec,
|
||||
continue_on_block,
|
||||
},
|
||||
status_message,
|
||||
source_path: source.path.clone(),
|
||||
source: source.source,
|
||||
display_order: *display_order,
|
||||
env: source.env.clone(),
|
||||
});
|
||||
}
|
||||
*display_order += 1;
|
||||
}
|
||||
HookHandlerConfig::Agent {} => warnings.push(format!(
|
||||
"skipping agent hook in {}: agent hooks are not supported yet",
|
||||
source.path.display()
|
||||
@@ -666,6 +754,7 @@ mod tests {
|
||||
use codex_config::HookStateToml;
|
||||
use codex_config::MatcherGroup;
|
||||
use codex_config::TomlValue;
|
||||
use codex_protocol::protocol::HookHandlerType;
|
||||
use codex_protocol::protocol::HookTrustStatus;
|
||||
|
||||
fn source_path() -> AbsolutePathBuf {
|
||||
@@ -757,6 +846,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_group(matcher: Option<&str>) -> MatcherGroup {
|
||||
MatcherGroup {
|
||||
matcher: matcher.map(str::to_string),
|
||||
hooks: vec![HookHandlerConfig::Prompt {
|
||||
prompt: "Check this hook input: $ARGUMENTS".to_string(),
|
||||
model: Some("gpt-5-mini".to_string()),
|
||||
timeout_sec: None,
|
||||
status_message: Some("Checking prompt hook".to_string()),
|
||||
continue_on_block: true,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prompt_submit_ignores_invalid_matcher_during_discovery() {
|
||||
let mut handlers = Vec::new();
|
||||
@@ -944,6 +1046,76 @@ mod tests {
|
||||
assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_request_prompt_hook_is_discovered_as_supported() {
|
||||
let mut handlers = Vec::new();
|
||||
let mut hook_entries = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let hook_states = std::collections::HashMap::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut hook_entries,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&hook_handler_source(&source_path, &hook_states),
|
||||
HookEventName::PermissionRequest,
|
||||
vec![prompt_group(Some(".*"))],
|
||||
);
|
||||
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
assert_eq!(handlers.len(), 1);
|
||||
assert_eq!(hook_entries.len(), 1);
|
||||
assert_eq!(hook_entries[0].handler_type, HookHandlerType::Prompt);
|
||||
assert_eq!(hook_entries[0].command, None);
|
||||
assert_eq!(
|
||||
hook_entries[0].prompt.as_deref(),
|
||||
Some("Check this hook input: $ARGUMENTS")
|
||||
);
|
||||
assert_eq!(hook_entries[0].timeout_sec, 30);
|
||||
assert_eq!(
|
||||
handlers[0].kind,
|
||||
ConfiguredHandlerKind::Prompt {
|
||||
prompt: "Check this hook input: $ARGUMENTS".to_string(),
|
||||
model: Some("gpt-5-mini".to_string()),
|
||||
timeout_sec: 30,
|
||||
continue_on_block: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_prompt_hook_is_skipped_during_discovery() {
|
||||
let mut handlers = Vec::new();
|
||||
let mut hook_entries = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut display_order = 0;
|
||||
let source_path = source_path();
|
||||
let hook_states = std::collections::HashMap::new();
|
||||
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut hook_entries,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
&hook_handler_source(&source_path, &hook_states),
|
||||
HookEventName::SessionStart,
|
||||
vec![prompt_group(/*matcher*/ None)],
|
||||
);
|
||||
|
||||
assert_eq!(handlers, Vec::<ConfiguredHandler>::new());
|
||||
assert_eq!(hook_entries.len(), 0);
|
||||
assert_eq!(
|
||||
warnings,
|
||||
vec![format!(
|
||||
"skipping prompt hook in {}: prompt hooks are not supported for SessionStart",
|
||||
source_path.display()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_hook_discovery_ignores_malformed_state_entries() {
|
||||
let layer = ConfigLayerEntry::new(
|
||||
|
||||
@@ -60,10 +60,6 @@ pub(crate) enum ConfiguredHandlerKind {
|
||||
command: String,
|
||||
timeout_sec: u64,
|
||||
},
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "constructed by prompt-hook discovery in the follow-up"
|
||||
)]
|
||||
Prompt {
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
@@ -127,6 +123,9 @@ pub struct HookListEntry {
|
||||
pub handler_type: HookHandlerType,
|
||||
pub matcher: Option<String>,
|
||||
pub command: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub continue_on_block: Option<bool>,
|
||||
pub timeout_sec: u64,
|
||||
pub status_message: Option<String>,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
|
||||
Reference in New Issue
Block a user