[hooks] userpromptsubmit - hook before user's prompt is executed (#14626)

- this allows blocking the user's prompts from executing, and also
prevents them from entering history
- handles the edge case where you can both prevent the user's prompt AND
add n amount of additionalContexts
- refactors some old code into common.rs where hooks overlap
functionality
- refactors additionalContext being previously added to user messages,
instead we use developer messages for them
- handles queued messages correctly

Sample hook for testing - if you write "[block-user-submit]" this hook
will stop the thread:

example run
```
› sup


• Running UserPromptSubmit hook: reading the observatory notes

UserPromptSubmit hook (completed)
  warning: wizard-tower UserPromptSubmit demo inspected: sup
  hook context: Wizard Tower UserPromptSubmit demo fired. For this reply only, include the exact
phrase 'observatory lanterns lit' exactly once near the end.

• Just riding the cosmic wave and ready to help, my friend. What are we building today? observatory
  lanterns lit


› and [block-user-submit]


• Running UserPromptSubmit hook: reading the observatory notes

UserPromptSubmit hook (stopped)
  warning: wizard-tower UserPromptSubmit demo blocked the prompt on purpose.
  stop: Wizard Tower demo block: remove [block-user-submit] to continue.
```

.codex/config.toml
```
[features]
codex_hooks = true
```

.codex/hooks.json
```
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 .codex/hooks/user_prompt_submit_demo.py",
            "timeoutSec": 10,
            "statusMessage": "reading the observatory notes"
          }
        ]
      }
    ]
  }
}
```

.codex/hooks/user_prompt_submit_demo.py
```
#!/usr/bin/env python3

import json
import sys
from pathlib import Path


def prompt_from_payload(payload: dict) -> str:
    prompt = payload.get("prompt")
    if isinstance(prompt, str) and prompt.strip():
        return prompt.strip()

    event = payload.get("event")
    if isinstance(event, dict):
        user_prompt = event.get("user_prompt")
        if isinstance(user_prompt, str):
            return user_prompt.strip()

    return ""


def main() -> int:
    payload = json.load(sys.stdin)
    prompt = prompt_from_payload(payload)
    cwd = Path(payload.get("cwd", ".")).name or "wizard-tower"

    if "[block-user-submit]" in prompt:
        print(
            json.dumps(
                {
                    "systemMessage": (
                        f"{cwd} UserPromptSubmit demo blocked the prompt on purpose."
                    ),
                    "decision": "block",
                    "reason": (
                        "Wizard Tower demo block: remove [block-user-submit] to continue."
                    ),
                }
            )
        )
        return 0

    prompt_preview = prompt or "(empty prompt)"
    if len(prompt_preview) > 80:
        prompt_preview = f"{prompt_preview[:77]}..."

    print(
        json.dumps(
            {
                "systemMessage": (
                    f"{cwd} UserPromptSubmit demo inspected: {prompt_preview}"
                ),
                "hookSpecificOutput": {
                    "hookEventName": "UserPromptSubmit",
                    "additionalContext": (
                        "Wizard Tower UserPromptSubmit demo fired. "
                        "For this reply only, include the exact phrase "
                        "'observatory lanterns lit' exactly once near the end."
                    ),
                },
            }
        )
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```
This commit is contained in:
Andrei Eternal
2026-03-17 22:09:22 -07:00
committed by GitHub
Unverified
parent 226241f035
commit 6fef421654
36 changed files with 1845 additions and 248 deletions
@@ -1136,6 +1136,7 @@
"HookEventName": {
"enum": [
"sessionStart",
"userPromptSubmit",
"stop"
],
"type": "string"
@@ -7882,6 +7882,7 @@
"HookEventName": {
"enum": [
"sessionStart",
"userPromptSubmit",
"stop"
],
"type": "string"
@@ -4626,6 +4626,7 @@
"HookEventName": {
"enum": [
"sessionStart",
"userPromptSubmit",
"stop"
],
"type": "string"
@@ -4,6 +4,7 @@
"HookEventName": {
"enum": [
"sessionStart",
"userPromptSubmit",
"stop"
],
"type": "string"
@@ -4,6 +4,7 @@
"HookEventName": {
"enum": [
"sessionStart",
"userPromptSubmit",
"stop"
],
"type": "string"
@@ -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 = "sessionStart" | "stop";
export type HookEventName = "sessionStart" | "userPromptSubmit" | "stop";
@@ -343,7 +343,7 @@ v2_enum_from_core!(
v2_enum_from_core!(
pub enum HookEventName from CoreHookEventName {
SessionStart, Stop
SessionStart, UserPromptSubmit, Stop
}
);
+84 -74
View File
@@ -205,6 +205,12 @@ use crate::file_watcher::FileWatcher;
use crate::file_watcher::FileWatcherEvent;
use crate::git_info::get_git_repo_root;
use crate::guardian::GuardianReviewSessionManager;
use crate::hook_runtime::PendingInputHookDisposition;
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_pending_session_start_hooks;
use crate::hook_runtime::run_user_prompt_submit_hooks;
use crate::instructions::UserInstructions;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::McpManager;
@@ -3850,6 +3856,18 @@ impl Session {
}
}
pub async fn prepend_pending_input(&self, input: Vec<ResponseInputItem>) -> Result<(), ()> {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.prepend_pending_input(input);
Ok(())
}
None => Err(()),
}
}
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
@@ -3974,6 +3992,11 @@ impl Session {
recorder.map(|recorder| recorder.rollout_path().to_path_buf())
}
pub(crate) async fn hook_transcript_path(&self) -> Option<PathBuf> {
self.ensure_rollout_materialized().await;
self.current_rollout_path().await
}
pub(crate) async fn take_pending_session_start_source(
&self,
) -> Option<codex_hooks::SessionStartSource> {
@@ -5486,6 +5509,26 @@ pub(crate) async fn run_turn(
invocation_type: Some(InvocationType::Explicit),
})
.collect::<Vec<_>>();
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
let response_item: ResponseItem = initial_input_for_turn.clone().into();
let mut last_agent_message: Option<String> = None;
if run_pending_session_start_hooks(&sess, &turn_context).await {
return last_agent_message;
}
let user_prompt_submit_outcome =
run_user_prompt_submit_hooks(&sess, &turn_context, UserMessageItem::new(&input).message())
.await;
if user_prompt_submit_outcome.should_stop {
record_additional_contexts(
&sess,
&turn_context,
user_prompt_submit_outcome.additional_contexts,
)
.await;
return last_agent_message;
}
let additional_contexts = user_prompt_submit_outcome.additional_contexts;
sess.services
.analytics_events_client
.track_app_mentioned(tracking.clone(), mentioned_app_invocations);
@@ -5496,11 +5539,9 @@ pub(crate) async fn run_turn(
}
sess.merge_connector_selection(explicitly_enabled_connectors.clone())
.await;
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input.clone());
let response_item: ResponseItem = initial_input_for_turn.clone().into();
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), &input, response_item)
.await;
record_additional_contexts(&sess, &turn_context, additional_contexts).await;
// Track the previous-turn baseline from the regular user-turn path only so
// standalone tasks (compact/shell/review/undo) cannot suppress future
// model/realtime injections.
@@ -5521,7 +5562,6 @@ pub(crate) async fn run_turn(
sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token())
.await;
let mut last_agent_message: Option<String> = None;
let mut stop_hook_active = false;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
@@ -5534,85 +5574,55 @@ pub(crate) async fn run_turn(
prewarmed_client_session.unwrap_or_else(|| sess.services.model_client.new_session());
loop {
if let Some(session_start_source) = sess.take_pending_session_start_source().await {
let session_start_permission_mode = match turn_context.approval_policy.value() {
AskForApproval::Never => "bypassPermissions",
AskForApproval::UnlessTrusted
| AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::Granular(_) => "default",
}
.to_string();
let session_start_request = codex_hooks::SessionStartRequest {
session_id: sess.conversation_id,
cwd: turn_context.cwd.clone(),
transcript_path: sess.current_rollout_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: session_start_permission_mode,
source: session_start_source,
};
for run in sess.hooks().preview_session_start(&session_start_request) {
sess.send_event(
&turn_context,
EventMsg::HookStarted(crate::protocol::HookStartedEvent {
turn_id: Some(turn_context.sub_id.clone()),
run,
}),
)
.await;
}
let session_start_outcome = sess
.hooks()
.run_session_start(session_start_request, Some(turn_context.sub_id.clone()))
.await;
for completed in session_start_outcome.hook_events {
sess.send_event(&turn_context, EventMsg::HookCompleted(completed))
.await;
}
if session_start_outcome.should_stop {
break;
}
if let Some(additional_context) = session_start_outcome.additional_context {
let developer_message: ResponseItem =
DeveloperInstructions::new(additional_context).into();
sess.record_conversation_items(
&turn_context,
std::slice::from_ref(&developer_message),
)
.await;
}
if run_pending_session_start_hooks(&sess, &turn_context).await {
break;
}
// Note that pending_input would be something like a message the user
// submitted through the UI while the model was running. Though the UI
// may support this, the model might not.
let pending_response_items = sess
.get_pending_input()
.await
.into_iter()
.map(ResponseItem::from)
.collect::<Vec<ResponseItem>>();
let pending_input = sess.get_pending_input().await;
if !pending_response_items.is_empty() {
for response_item in pending_response_items {
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
// todo(aibrahim): move pending input to be UserInput only to keep TextElements. context: https://github.com/openai/codex/pull/10656#discussion_r2765522480
sess.record_user_prompt_and_emit_turn_item(
turn_context.as_ref(),
&user_message.content,
response_item,
)
.await;
} else {
sess.record_conversation_items(
&turn_context,
std::slice::from_ref(&response_item),
)
.await;
let mut blocked_pending_input = false;
let mut blocked_pending_input_contexts = Vec::new();
let mut requeued_pending_input = false;
let mut accepted_pending_input = Vec::new();
if !pending_input.is_empty() {
let mut pending_input_iter = pending_input.into_iter();
while let Some(pending_input_item) = pending_input_iter.next() {
match inspect_pending_input(&sess, &turn_context, pending_input_item).await {
PendingInputHookDisposition::Accepted(pending_input) => {
accepted_pending_input.push(*pending_input);
}
PendingInputHookDisposition::Blocked {
additional_contexts,
} => {
let remaining_pending_input = pending_input_iter.collect::<Vec<_>>();
if !remaining_pending_input.is_empty() {
let _ = sess.prepend_pending_input(remaining_pending_input).await;
requeued_pending_input = true;
}
blocked_pending_input_contexts = additional_contexts;
blocked_pending_input = true;
break;
}
}
}
}
let has_accepted_pending_input = !accepted_pending_input.is_empty();
for pending_input in accepted_pending_input {
record_pending_input(&sess, &turn_context, pending_input).await;
}
record_additional_contexts(&sess, &turn_context, blocked_pending_input_contexts).await;
if blocked_pending_input && !has_accepted_pending_input {
if requeued_pending_input {
continue;
}
break;
}
// Construct the input that we will send to the model.
let sampling_request_input: Vec<ResponseItem> = {
sess.clone_history()
@@ -5693,7 +5703,7 @@ pub(crate) async fn run_turn(
session_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
cwd: turn_context.cwd.clone(),
transcript_path: sess.current_rollout_path().await,
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: stop_hook_permission_mode,
stop_hook_active,
+56
View File
@@ -4385,6 +4385,62 @@ async fn steer_input_returns_active_turn_id() {
assert!(sess.has_pending_input().await);
}
#[tokio::test]
async fn prepend_pending_input_keeps_older_tail_ahead_of_newer_input() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
sess.spawn_task(
Arc::clone(&tc),
input,
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let blocked = ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "blocked queued prompt".to_string(),
}],
};
let later = ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "later queued prompt".to_string(),
}],
};
let newer = ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "newer queued prompt".to_string(),
}],
};
sess.inject_response_items(vec![blocked.clone(), later.clone()])
.await
.expect("inject initial pending input into active turn");
let drained = sess.get_pending_input().await;
assert_eq!(drained, vec![blocked, later.clone()]);
sess.inject_response_items(vec![newer.clone()])
.await
.expect("inject newer pending input into active turn");
let mut drained_iter = drained.into_iter();
let _blocked = drained_iter.next().expect("blocked prompt should exist");
sess.prepend_pending_input(drained_iter.collect())
.await
.expect("requeue later pending input at the front of the queue");
assert_eq!(sess.get_pending_input().await, vec![later, newer]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
+318
View File
@@ -0,0 +1,318 @@
use std::future::Future;
use std::sync::Arc;
use codex_hooks::SessionStartOutcome;
use codex_hooks::UserPromptSubmitOutcome;
use codex_hooks::UserPromptSubmitRequest;
use codex_protocol::items::TurnItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::user_input::UserInput;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::event_mapping::parse_turn_item;
pub(crate) struct HookRuntimeOutcome {
pub should_stop: bool,
pub additional_contexts: Vec<String>,
}
pub(crate) enum PendingInputHookDisposition {
Accepted(Box<PendingInputRecord>),
Blocked { additional_contexts: Vec<String> },
}
pub(crate) enum PendingInputRecord {
UserMessage {
content: Vec<UserInput>,
response_item: ResponseItem,
additional_contexts: Vec<String>,
},
ConversationItem {
response_item: ResponseItem,
},
}
struct ContextInjectingHookOutcome {
hook_events: Vec<HookCompletedEvent>,
outcome: HookRuntimeOutcome,
}
impl From<SessionStartOutcome> for ContextInjectingHookOutcome {
fn from(value: SessionStartOutcome) -> Self {
let SessionStartOutcome {
hook_events,
should_stop,
stop_reason: _,
additional_contexts,
} = value;
Self {
hook_events,
outcome: HookRuntimeOutcome {
should_stop,
additional_contexts,
},
}
}
}
impl From<UserPromptSubmitOutcome> for ContextInjectingHookOutcome {
fn from(value: UserPromptSubmitOutcome) -> Self {
let UserPromptSubmitOutcome {
hook_events,
should_stop,
stop_reason: _,
additional_contexts,
} = value;
Self {
hook_events,
outcome: HookRuntimeOutcome {
should_stop,
additional_contexts,
},
}
}
}
pub(crate) async fn run_pending_session_start_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
) -> bool {
let Some(session_start_source) = sess.take_pending_session_start_source().await else {
return false;
};
let request = codex_hooks::SessionStartRequest {
session_id: sess.conversation_id,
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
source: session_start_source,
};
let preview_runs = sess.hooks().preview_session_start(&request);
run_context_injecting_hook(
sess,
turn_context,
preview_runs,
sess.hooks()
.run_session_start(request, Some(turn_context.sub_id.clone())),
)
.await
.record_additional_contexts(sess, turn_context)
.await
}
pub(crate) async fn run_user_prompt_submit_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
prompt: String,
) -> HookRuntimeOutcome {
let request = UserPromptSubmitRequest {
session_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
prompt,
};
let preview_runs = sess.hooks().preview_user_prompt_submit(&request);
run_context_injecting_hook(
sess,
turn_context,
preview_runs,
sess.hooks().run_user_prompt_submit(request),
)
.await
}
pub(crate) async fn inspect_pending_input(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
pending_input_item: ResponseInputItem,
) -> PendingInputHookDisposition {
let response_item = ResponseItem::from(pending_input_item);
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
let user_prompt_submit_outcome =
run_user_prompt_submit_hooks(sess, turn_context, user_message.message()).await;
if user_prompt_submit_outcome.should_stop {
PendingInputHookDisposition::Blocked {
additional_contexts: user_prompt_submit_outcome.additional_contexts,
}
} else {
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::UserMessage {
content: user_message.content,
response_item,
additional_contexts: user_prompt_submit_outcome.additional_contexts,
}))
}
} else {
PendingInputHookDisposition::Accepted(Box::new(PendingInputRecord::ConversationItem {
response_item,
}))
}
}
pub(crate) async fn record_pending_input(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
pending_input: PendingInputRecord,
) {
match pending_input {
PendingInputRecord::UserMessage {
content,
response_item,
additional_contexts,
} => {
sess.record_user_prompt_and_emit_turn_item(
turn_context.as_ref(),
content.as_slice(),
response_item,
)
.await;
record_additional_contexts(sess, turn_context, additional_contexts).await;
}
PendingInputRecord::ConversationItem { response_item } => {
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
}
}
}
async fn run_context_injecting_hook<Fut, Outcome>(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
preview_runs: Vec<HookRunSummary>,
outcome_future: Fut,
) -> HookRuntimeOutcome
where
Fut: Future<Output = Outcome>,
Outcome: Into<ContextInjectingHookOutcome>,
{
emit_hook_started_events(sess, turn_context, preview_runs).await;
let outcome = outcome_future.await.into();
emit_hook_completed_events(sess, turn_context, outcome.hook_events).await;
outcome.outcome
}
impl HookRuntimeOutcome {
async fn record_additional_contexts(
self,
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
) -> bool {
record_additional_contexts(sess, turn_context, self.additional_contexts).await;
self.should_stop
}
}
pub(crate) async fn record_additional_contexts(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
additional_contexts: Vec<String>,
) {
let developer_messages = additional_context_messages(additional_contexts);
if developer_messages.is_empty() {
return;
}
sess.record_conversation_items(turn_context, developer_messages.as_slice())
.await;
}
fn additional_context_messages(additional_contexts: Vec<String>) -> Vec<ResponseItem> {
additional_contexts
.into_iter()
.map(|additional_context| DeveloperInstructions::new(additional_context).into())
.collect()
}
async fn emit_hook_started_events(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
preview_runs: Vec<HookRunSummary>,
) {
for run in preview_runs {
sess.send_event(
turn_context,
EventMsg::HookStarted(crate::protocol::HookStartedEvent {
turn_id: Some(turn_context.sub_id.clone()),
run,
}),
)
.await;
}
}
async fn emit_hook_completed_events(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
completed_events: Vec<HookCompletedEvent>,
) {
for completed in completed_events {
sess.send_event(turn_context, EventMsg::HookCompleted(completed))
.await;
}
}
fn hook_permission_mode(turn_context: &TurnContext) -> String {
match turn_context.approval_policy.value() {
AskForApproval::Never => "bypassPermissions",
AskForApproval::UnlessTrusted
| AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::Granular(_) => "default",
}
.to_string()
}
#[cfg(test)]
mod tests {
use codex_protocol::models::ContentItem;
use pretty_assertions::assert_eq;
use super::additional_context_messages;
#[test]
fn additional_context_messages_stay_separate_and_ordered() {
let messages = additional_context_messages(vec![
"first tide note".to_string(),
"second tide note".to_string(),
]);
assert_eq!(messages.len(), 2);
assert_eq!(
messages
.iter()
.map(|message| match message {
codex_protocol::models::ResponseItem::Message { role, content, .. } => {
let text = content
.iter()
.map(|item| match item {
ContentItem::InputText { text } => text.as_str(),
ContentItem::InputImage { .. } | ContentItem::OutputText { .. } => {
panic!("expected input text content, got {item:?}")
}
})
.collect::<String>();
(role.as_str(), text)
}
other => panic!("expected developer message, got {other:?}"),
})
.collect::<Vec<_>>(),
vec![
("developer", "first tide note".to_string()),
("developer", "second tide note".to_string()),
],
);
}
}
+1
View File
@@ -44,6 +44,7 @@ mod file_watcher;
mod flags;
pub mod git_info;
mod guardian;
mod hook_runtime;
pub mod instructions;
pub mod landlock;
pub mod mcp;
+9
View File
@@ -179,6 +179,15 @@ impl TurnState {
self.pending_input.push(input);
}
pub(crate) fn prepend_pending_input(&mut self, mut input: Vec<ResponseInputItem>) {
if input.is_empty() {
return;
}
input.append(&mut self.pending_input);
self.pending_input = input;
}
pub(crate) fn take_pending_input(&mut self) -> Vec<ResponseInputItem> {
if self.pending_input.is_empty() {
Vec::with_capacity(0)
+14 -23
View File
@@ -23,7 +23,10 @@ use crate::AuthManager;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG;
use crate::event_mapping::parse_turn_item;
use crate::hook_runtime::PendingInputHookDisposition;
use crate::hook_runtime::inspect_pending_input;
use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::record_pending_input;
use crate::models_manager::manager::ModelsManager;
use crate::protocol::EventMsg;
use crate::protocol::TokenUsage;
@@ -38,7 +41,6 @@ use codex_otel::metrics::names::TURN_E2E_DURATION_METRIC;
use codex_otel::metrics::names::TURN_NETWORK_PROXY_METRIC;
use codex_otel::metrics::names::TURN_TOKEN_USAGE_METRIC;
use codex_otel::metrics::names::TURN_TOOL_CALL_METRIC;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
@@ -261,27 +263,16 @@ impl Session {
}
drop(active);
if !pending_input.is_empty() {
let pending_response_items = pending_input
.into_iter()
.map(ResponseItem::from)
.collect::<Vec<_>>();
for response_item in pending_response_items {
if let Some(TurnItem::UserMessage(user_message)) = parse_turn_item(&response_item) {
// Keep leftover user input on the same persistence + lifecycle path as the
// normal pre-sampling drain. This helper records the response item once, then
// emits ItemStarted/UserMessage and ItemCompleted/UserMessage for clients.
self.record_user_prompt_and_emit_turn_item(
turn_context.as_ref(),
&user_message.content,
response_item,
)
.await;
} else {
self.record_conversation_items(
turn_context.as_ref(),
std::slice::from_ref(&response_item),
)
.await;
for pending_input_item in pending_input {
match inspect_pending_input(self, &turn_context, pending_input_item).await {
PendingInputHookDisposition::Accepted(pending_input) => {
record_pending_input(self, &turn_context, *pending_input).await;
}
PendingInputHookDisposition::Blocked {
additional_contexts,
} => {
record_additional_contexts(self, &turn_context, additional_contexts).await;
}
}
}
}
+358
View File
@@ -6,21 +6,34 @@ use anyhow::Result;
use codex_core::features::Feature;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_message_item_added;
use core_test_support::responses::ev_output_text_delta;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
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 std::time::Duration;
use tokio::sync::oneshot;
use tokio::time::sleep;
const FIRST_CONTINUATION_PROMPT: &str = "Retry with exactly the phrase meow meow meow.";
const SECOND_CONTINUATION_PROMPT: &str = "Now tighten it to just: meow.";
const BLOCKED_PROMPT_CONTEXT: &str = "Remember the blocked lighthouse note.";
fn write_stop_hook(home: &Path, block_prompts: &[&str]) -> Result<()> {
let script_path = home.join("stop_hook.py");
@@ -69,6 +82,87 @@ else:
Ok(())
}
fn write_user_prompt_submit_hook(
home: &Path,
blocked_prompt: &str,
additional_context: &str,
) -> Result<()> {
let script_path = home.join("user_prompt_submit_hook.py");
let blocked_prompt_json =
serde_json::to_string(blocked_prompt).context("serialize blocked prompt for test")?;
let additional_context_json = serde_json::to_string(additional_context)
.context("serialize user prompt submit additional context for test")?;
let script = format!(
r#"import json
import sys
payload = json.load(sys.stdin)
if payload.get("prompt") == {blocked_prompt_json}:
print(json.dumps({{
"decision": "block",
"reason": "blocked by hook",
"hookSpecificOutput": {{
"hookEventName": "UserPromptSubmit",
"additionalContext": {additional_context_json}
}}
}}))
"#,
);
let hooks = serde_json::json!({
"hooks": {
"UserPromptSubmit": [{
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "running user prompt submit hook",
}]
}]
}
});
fs::write(&script_path, script).context("write user prompt submit hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> {
let script_path = home.join("session_start_hook.py");
let log_path = home.join("session_start_hook_log.jsonl");
let script = format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
transcript_path = payload.get("transcript_path")
record = {{
"transcript_path": transcript_path,
"exists": Path(transcript_path).exists() if transcript_path else False,
}}
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record) + "\n")
"#,
log_path = log_path.display(),
);
let hooks = serde_json::json!({
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": format!("python3 {}", script_path.display()),
"statusMessage": "running session start hook",
}]
}]
}
});
fs::write(&script_path, script).context("write session start hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn rollout_developer_texts(text: &str) -> Result<Vec<String>> {
let mut texts = Vec::new();
for line in text.lines() {
@@ -99,6 +193,49 @@ fn read_stop_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
.collect()
}
fn read_session_start_hook_inputs(home: &Path) -> Result<Vec<serde_json::Value>> {
fs::read_to_string(home.join("session_start_hook_log.jsonl"))
.context("read session start hook log")?
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).context("parse session start hook log line"))
.collect()
}
fn ev_message_item_done(id: &str, text: &str) -> Value {
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": id,
"content": [{"type": "output_text", "text": text}]
}
})
}
fn sse_event(event: Value) -> String {
sse(vec![event])
}
fn request_message_input_texts(body: &[u8], role: &str) -> Vec<String> {
let body: Value = match serde_json::from_slice(body) {
Ok(body) => body,
Err(error) => panic!("parse request body: {error}"),
};
body.get("input")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
.filter_map(|item| item.get("content").and_then(Value::as_array))
.flatten()
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -193,6 +330,51 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let _response = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "hello from the reef"),
ev_completed("resp-1"),
]),
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_session_start_hook_recording_transcript(home) {
panic!("failed to write session start hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
test.submit_turn("hello").await?;
let hook_inputs = read_session_start_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
hook_inputs[0]
.get("transcript_path")
.and_then(Value::as_str)
.map(str::is_empty),
Some(false)
);
assert_eq!(hook_inputs[0].get("exists"), Some(&Value::Bool(true)));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -269,3 +451,179 @@ async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "second prompt handled"),
ev_completed("resp-1"),
]),
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_user_prompt_submit_hook(home, "blocked first prompt", BLOCKED_PROMPT_CONTEXT)
{
panic!("failed to write user prompt submit hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
test.submit_turn("blocked first prompt").await?;
test.submit_turn("second prompt").await?;
let request = response.single_request();
assert!(
request
.message_input_texts("developer")
.contains(&BLOCKED_PROMPT_CONTEXT.to_string()),
"second request should include developer context persisted from the blocked prompt",
);
assert!(
request
.message_input_texts("user")
.iter()
.all(|text| !text.contains("blocked first prompt")),
"blocked prompt should not be sent to the model",
);
assert!(
request
.message_input_texts("user")
.iter()
.any(|text| text.contains("second prompt")),
"second request should include the accepted prompt",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Result<()> {
skip_if_no_network!(Ok(()));
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
let first_chunks = vec![
StreamingSseChunk {
gate: None,
body: sse_event(ev_response_created("resp-1")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_message_item_added("msg-1", "")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_output_text_delta("first ")),
},
StreamingSseChunk {
gate: None,
body: sse_event(ev_message_item_done("msg-1", "first response")),
},
StreamingSseChunk {
gate: Some(gate_completed_rx),
body: sse_event(ev_completed("resp-1")),
},
];
let second_chunks = vec![StreamingSseChunk {
gate: None,
body: sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-2", "accepted queued prompt handled"),
ev_completed("resp-2"),
]),
}];
let (server, _completions) =
start_streaming_sse_server(vec![first_chunks, second_chunks]).await;
let mut builder = test_codex()
.with_model("gpt-5.1")
.with_pre_build_hook(|home| {
if let Err(error) =
write_user_prompt_submit_hook(home, "blocked queued prompt", BLOCKED_PROMPT_CONTEXT)
{
panic!("failed to write user prompt submit hook test fixture: {error}");
}
})
.with_config(|config| {
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
});
let test = builder.build_with_streaming_server(&server).await?;
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "initial prompt".to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::AgentMessageContentDelta(_))
})
.await;
for text in ["accepted queued prompt", "blocked queued prompt"] {
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
})
.await?;
}
sleep(Duration::from_millis(100)).await;
let _ = gate_completed_tx.send(());
let requests = tokio::time::timeout(Duration::from_secs(30), async {
loop {
let requests = server.requests().await;
if requests.len() >= 2 {
break requests;
}
sleep(Duration::from_millis(50)).await;
}
})
.await
.expect("second request should arrive")
.into_iter()
.collect::<Vec<_>>();
sleep(Duration::from_millis(100)).await;
assert_eq!(requests.len(), 2);
let second_user_texts = request_message_input_texts(&requests[1], "user");
assert!(
second_user_texts.contains(&"accepted queued prompt".to_string()),
"second request should include the accepted queued prompt",
);
assert!(
!second_user_texts.contains(&"blocked queued prompt".to_string()),
"second request should not include the blocked queued prompt",
);
server.shutdown().await;
Ok(())
}
@@ -989,6 +989,7 @@ impl EventProcessorWithHumanOutput {
fn hook_event_name(event_name: HookEventName) -> &'static str {
match event_name {
HookEventName::SessionStart => "SessionStart",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::Stop => "Stop",
}
}
@@ -5,6 +5,7 @@
"HookEventNameWire": {
"enum": [
"SessionStart",
"UserPromptSubmit",
"Stop"
],
"type": "string"
@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"StopDecisionWire": {
"BlockDecisionWire": {
"enum": [
"block"
],
@@ -17,7 +17,7 @@
"decision": {
"allOf": [
{
"$ref": "#/definitions/StopDecisionWire"
"$ref": "#/definitions/BlockDecisionWire"
}
],
"default": null
@@ -0,0 +1,54 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"NullableString": {
"type": [
"string",
"null"
]
}
},
"properties": {
"cwd": {
"type": "string"
},
"hook_event_name": {
"const": "UserPromptSubmit",
"type": "string"
},
"model": {
"type": "string"
},
"permission_mode": {
"enum": [
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions"
],
"type": "string"
},
"prompt": {
"type": "string"
},
"session_id": {
"type": "string"
},
"transcript_path": {
"$ref": "#/definitions/NullableString"
}
},
"required": [
"cwd",
"hook_event_name",
"model",
"permission_mode",
"prompt",
"session_id",
"transcript_path"
],
"title": "user-prompt-submit.command.input",
"type": "object"
}
@@ -0,0 +1,76 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"definitions": {
"BlockDecisionWire": {
"enum": [
"block"
],
"type": "string"
},
"HookEventNameWire": {
"enum": [
"SessionStart",
"UserPromptSubmit",
"Stop"
],
"type": "string"
},
"UserPromptSubmitHookSpecificOutputWire": {
"additionalProperties": false,
"properties": {
"additionalContext": {
"default": null,
"type": "string"
},
"hookEventName": {
"$ref": "#/definitions/HookEventNameWire"
}
},
"required": [
"hookEventName"
],
"type": "object"
}
},
"properties": {
"continue": {
"default": true,
"type": "boolean"
},
"decision": {
"allOf": [
{
"$ref": "#/definitions/BlockDecisionWire"
}
],
"default": null
},
"hookSpecificOutput": {
"allOf": [
{
"$ref": "#/definitions/UserPromptSubmitHookSpecificOutputWire"
}
],
"default": null
},
"reason": {
"default": null,
"type": "string"
},
"stopReason": {
"default": null,
"type": "string"
},
"suppressOutput": {
"default": false,
"type": "boolean"
},
"systemMessage": {
"default": null,
"type": "string"
}
},
"title": "user-prompt-submit.command.output",
"type": "object"
}
+2
View File
@@ -10,6 +10,8 @@ pub(crate) struct HooksFile {
pub(crate) struct HookEvents {
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[serde(rename = "Stop", default)]
pub stop: Vec<MatcherGroup>,
}
+84 -2
View File
@@ -76,7 +76,25 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::SessionStart,
group.matcher.as_deref(),
effective_matcher(
codex_protocol::protocol::HookEventName::SessionStart,
group.matcher.as_deref(),
),
group.hooks,
);
}
for group in parsed.hooks.user_prompt_submit {
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::UserPromptSubmit,
effective_matcher(
codex_protocol::protocol::HookEventName::UserPromptSubmit,
group.matcher.as_deref(),
),
group.hooks,
);
}
@@ -88,7 +106,10 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::Stop,
/*matcher*/ None,
effective_matcher(
codex_protocol::protocol::HookEventName::Stop,
group.matcher.as_deref(),
),
group.hooks,
);
}
@@ -97,6 +118,17 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
DiscoveryResult { handlers, warnings }
}
fn effective_matcher(
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
) -> Option<&str> {
match event_name {
codex_protocol::protocol::HookEventName::SessionStart => matcher,
codex_protocol::protocol::HookEventName::UserPromptSubmit
| codex_protocol::protocol::HookEventName::Stop => None,
}
}
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
@@ -161,3 +193,53 @@ fn append_group_handlers(
}
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use pretty_assertions::assert_eq;
use super::ConfiguredHandler;
use super::HookHandlerConfig;
use super::append_group_handlers;
use super::effective_matcher;
#[test]
fn user_prompt_submit_ignores_invalid_matcher_during_discovery() {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
Path::new("/tmp/hooks.json"),
HookEventName::UserPromptSubmit,
effective_matcher(HookEventName::UserPromptSubmit, Some("[")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
matcher: None,
command: "echo hello".to_string(),
timeout_sec: 600,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}]
);
}
}
+25 -6
View File
@@ -24,20 +24,20 @@ pub(crate) struct ParsedHandler<T> {
pub(crate) fn select_handlers(
handlers: &[ConfiguredHandler],
event_name: HookEventName,
session_start_source: Option<&str>,
matcher_input: Option<&str>,
) -> Vec<ConfiguredHandler> {
handlers
.iter()
.filter(|handler| handler.event_name == event_name)
.filter(|handler| match event_name {
HookEventName::SessionStart => match (&handler.matcher, session_start_source) {
(Some(matcher), Some(source)) => regex::Regex::new(matcher)
.map(|regex| regex.is_match(source))
HookEventName::SessionStart => match (&handler.matcher, matcher_input) {
(Some(matcher), Some(input)) => regex::Regex::new(matcher)
.map(|regex| regex.is_match(input))
.unwrap_or(false),
(None, _) => true,
_ => false,
},
HookEventName::Stop => true,
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
})
.cloned()
.collect()
@@ -109,7 +109,7 @@ pub(crate) fn completed_summary(
fn scope_for_event(event_name: HookEventName) -> HookScope {
match event_name {
HookEventName::SessionStart => HookScope::Thread,
HookEventName::Stop => HookScope::Turn,
HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn,
}
}
@@ -172,6 +172,25 @@ mod tests {
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn user_prompt_submit_ignores_matcher() {
let handlers = vec![
make_handler(
HookEventName::UserPromptSubmit,
Some("^hello"),
"echo first",
0,
),
make_handler(HookEventName::UserPromptSubmit, Some("["), "echo second", 1),
];
let selected = select_handlers(&handlers, HookEventName::UserPromptSubmit, None);
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].display_order, 0);
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn select_handlers_preserves_declaration_order() {
let handlers = vec![
+18 -1
View File
@@ -14,6 +14,8 @@ use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
#[derive(Debug, Clone)]
pub(crate) struct CommandShell {
@@ -21,7 +23,7 @@ pub(crate) struct CommandShell {
pub args: Vec<String>,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConfiguredHandler {
pub event_name: codex_protocol::protocol::HookEventName,
pub matcher: Option<String>,
@@ -45,6 +47,7 @@ impl ConfiguredHandler {
fn event_name_label(&self) -> &'static str {
match self.event_name {
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
codex_protocol::protocol::HookEventName::Stop => "stop",
}
}
@@ -99,6 +102,20 @@ impl ClaudeHooksEngine {
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
}
pub(crate) fn preview_user_prompt_submit(
&self,
request: &UserPromptSubmitRequest,
) -> Vec<HookRunSummary> {
crate::events::user_prompt_submit::preview(&self.handlers, request)
}
pub(crate) async fn run_user_prompt_submit(
&self,
request: UserPromptSubmitRequest,
) -> UserPromptSubmitOutcome {
crate::events::user_prompt_submit::run(&self.handlers, &self.shell, request).await
}
pub(crate) fn preview_stop(&self, request: &StopRequest) -> Vec<HookRunSummary> {
crate::events::stop::preview(&self.handlers, request)
}
+41 -7
View File
@@ -12,6 +12,15 @@ pub(crate) struct SessionStartOutput {
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct UserPromptSubmitOutput {
pub universal: UniversalOutput,
pub should_block: bool,
pub reason: Option<String>,
pub invalid_block_reason: Option<String>,
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct StopOutput {
pub universal: UniversalOutput,
@@ -20,10 +29,11 @@ pub(crate) struct StopOutput {
pub invalid_block_reason: Option<String>,
}
use crate::schema::BlockDecisionWire;
use crate::schema::HookUniversalOutputWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::StopDecisionWire;
use crate::schema::UserPromptSubmitCommandOutputWire;
pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
let wire: SessionStartCommandOutputWire = parse_json(stdout)?;
@@ -36,15 +46,39 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
})
}
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(StopDecisionWire::Block));
pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitOutput> {
let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
let invalid_block_reason = if should_block
&& match wire.reason.as_deref() {
Some(reason) => reason.trim().is_empty(),
None => true,
} {
Some(invalid_block_message())
Some(invalid_block_message("UserPromptSubmit"))
} else {
None
};
let additional_context = wire
.hook_specific_output
.and_then(|output| output.additional_context);
Some(UserPromptSubmitOutput {
universal: UniversalOutput::from(wire.universal),
should_block: should_block && invalid_block_reason.is_none(),
reason: wire.reason,
invalid_block_reason,
additional_context,
})
}
pub(crate) fn parse_stop(stdout: &str) -> Option<StopOutput> {
let wire: StopCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
let invalid_block_reason = if should_block
&& match wire.reason.as_deref() {
Some(reason) => reason.trim().is_empty(),
None => true,
} {
Some(invalid_block_message("Stop"))
} else {
None
};
@@ -82,6 +116,6 @@ where
serde_json::from_value(value).ok()
}
fn invalid_block_message() -> String {
"Stop hook returned decision:block without a non-empty reason".to_string()
fn invalid_block_message(event_name: &str) -> String {
format!("{event_name} hook returned decision:block without a non-empty reason")
}
@@ -6,6 +6,8 @@ use serde_json::Value;
pub(crate) struct GeneratedHookSchemas {
pub session_start_command_input: Value,
pub session_start_command_output: Value,
pub user_prompt_submit_command_input: Value,
pub user_prompt_submit_command_output: Value,
pub stop_command_input: Value,
pub stop_command_output: Value,
}
@@ -21,6 +23,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
"session-start.command.output",
include_str!("../../schema/generated/session-start.command.output.schema.json"),
),
user_prompt_submit_command_input: parse_json_schema(
"user-prompt-submit.command.input",
include_str!("../../schema/generated/user-prompt-submit.command.input.schema.json"),
),
user_prompt_submit_command_output: parse_json_schema(
"user-prompt-submit.command.output",
include_str!("../../schema/generated/user-prompt-submit.command.output.schema.json"),
),
stop_command_input: parse_json_schema(
"stop.command.input",
include_str!("../../schema/generated/stop.command.input.schema.json"),
@@ -48,6 +58,8 @@ mod tests {
assert_eq!(schemas.session_start_command_input["type"], "object");
assert_eq!(schemas.session_start_command_output["type"], "object");
assert_eq!(schemas.user_prompt_submit_command_input["type"], "object");
assert_eq!(schemas.user_prompt_submit_command_output["type"], "object");
assert_eq!(schemas.stop_command_input["type"], "object");
assert_eq!(schemas.stop_command_output["type"], "object");
}
+69
View File
@@ -0,0 +1,69 @@
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use crate::engine::ConfiguredHandler;
use crate::engine::dispatcher;
pub(crate) fn join_text_chunks(chunks: Vec<String>) -> Option<String> {
if chunks.is_empty() {
None
} else {
Some(chunks.join("\n\n"))
}
}
pub(crate) fn trimmed_non_empty(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub(crate) fn append_additional_context(
entries: &mut Vec<HookOutputEntry>,
additional_contexts_for_model: &mut Vec<String>,
additional_context: String,
) {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: additional_context.clone(),
});
additional_contexts_for_model.push(additional_context);
}
pub(crate) fn flatten_additional_contexts<'a>(
additional_contexts: impl IntoIterator<Item = &'a [String]>,
) -> Vec<String> {
additional_contexts
.into_iter()
.flat_map(|chunk| chunk.iter().cloned())
.collect()
}
pub(crate) fn serialization_failure_hook_events(
handlers: Vec<ConfiguredHandler>,
turn_id: Option<String>,
error_message: String,
) -> Vec<HookCompletedEvent> {
handlers
.into_iter()
.map(|handler| {
let mut run = dispatcher::running_summary(&handler);
run.status = HookRunStatus::Failed;
run.completed_at = Some(run.started_at);
run.duration_ms = Some(0);
run.entries = vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error_message.clone(),
}];
HookCompletedEvent {
turn_id: turn_id.clone(),
run,
}
})
.collect()
}
+2
View File
@@ -1,2 +1,4 @@
mod common;
pub mod session_start;
pub mod stop;
pub mod user_prompt_submit;
+43 -60
View File
@@ -8,6 +8,7 @@ use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
@@ -45,14 +46,14 @@ pub struct SessionStartOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub additional_context: Option<String>,
pub additional_contexts: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct SessionStartHandlerData {
should_stop: bool,
stop_reason: Option<String>,
additional_context_for_model: Option<String>,
additional_contexts_for_model: Vec<String>,
}
pub(crate) fn preview(
@@ -85,7 +86,7 @@ pub(crate) async fn run(
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
additional_context: None,
additional_contexts: Vec::new(),
};
}
@@ -99,11 +100,11 @@ pub(crate) async fn run(
)) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(
return serialization_failure_outcome(common::serialization_failure_hook_events(
matched,
turn_id,
format!("failed to serialize session start hook input: {error}"),
);
));
}
};
@@ -121,16 +122,17 @@ pub(crate) async fn run(
let stop_reason = results
.iter()
.find_map(|result| result.data.stop_reason.clone());
let additional_contexts = results
.iter()
.filter_map(|result| result.data.additional_context_for_model.clone())
.collect::<Vec<_>>();
let additional_contexts = common::flatten_additional_contexts(
results
.iter()
.map(|result| result.data.additional_contexts_for_model.as_slice()),
);
SessionStartOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
should_stop,
stop_reason,
additional_context: join_text_chunks(additional_contexts),
additional_contexts,
}
}
@@ -143,7 +145,7 @@ fn parse_completed(
let mut status = HookRunStatus::Completed;
let mut should_stop = false;
let mut stop_reason = None;
let mut additional_context_for_model = None;
let mut additional_contexts_for_model = Vec::new();
match run_result.error.as_deref() {
Some(error) => {
@@ -166,13 +168,11 @@ fn parse_completed(
});
}
if let Some(additional_context) = parsed.additional_context {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: additional_context.clone(),
});
if parsed.universal.continue_processing {
additional_context_for_model = Some(additional_context);
}
common::append_additional_context(
&mut entries,
&mut additional_contexts_for_model,
additional_context,
);
}
let _ = parsed.universal.suppress_output;
if !parsed.universal.continue_processing {
@@ -195,11 +195,11 @@ fn parse_completed(
});
} else {
let additional_context = trimmed_stdout.to_string();
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: additional_context.clone(),
});
additional_context_for_model = Some(additional_context);
common::append_additional_context(
&mut entries,
&mut additional_contexts_for_model,
additional_context,
);
}
}
Some(exit_code) => {
@@ -229,47 +229,17 @@ fn parse_completed(
data: SessionStartHandlerData {
should_stop,
stop_reason,
additional_context_for_model,
additional_contexts_for_model,
},
}
}
fn join_text_chunks(chunks: Vec<String>) -> Option<String> {
if chunks.is_empty() {
None
} else {
Some(chunks.join("\n\n"))
}
}
fn serialization_failure_outcome(
handlers: Vec<ConfiguredHandler>,
turn_id: Option<String>,
error_message: String,
) -> SessionStartOutcome {
let hook_events = handlers
.into_iter()
.map(|handler| {
let mut run = dispatcher::running_summary(&handler);
run.status = HookRunStatus::Failed;
run.completed_at = Some(run.started_at);
run.duration_ms = Some(0);
run.entries = vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error_message.clone(),
}];
HookCompletedEvent {
turn_id: turn_id.clone(),
run,
}
})
.collect();
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> SessionStartOutcome {
SessionStartOutcome {
hook_events,
should_stop: false,
stop_reason: None,
additional_context: None,
additional_contexts: Vec::new(),
}
}
@@ -301,7 +271,7 @@ mod tests {
SessionStartHandlerData {
should_stop: false,
stop_reason: None,
additional_context_for_model: Some("hello from hook".to_string()),
additional_contexts_for_model: vec!["hello from hook".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
@@ -315,7 +285,7 @@ mod tests {
}
#[test]
fn continue_false_keeps_context_out_of_model_input() {
fn continue_false_preserves_context_for_later_turns() {
let parsed = parse_completed(
&handler(),
run_result(
@@ -331,10 +301,23 @@ mod tests {
SessionStartHandlerData {
should_stop: true,
stop_reason: Some("pause".to_string()),
additional_context_for_model: None,
additional_contexts_for_model: vec!["do not inject".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
assert_eq!(
parsed.completed.run.entries,
vec![
HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "do not inject".to_string(),
},
HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: "pause".to_string(),
},
]
);
}
#[test]
@@ -354,7 +337,7 @@ mod tests {
SessionStartHandlerData {
should_stop: false,
stop_reason: None,
additional_context_for_model: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
+26 -69
View File
@@ -8,6 +8,7 @@ use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use codex_protocol::protocol::HookRunSummary;
use super::common;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
@@ -50,14 +51,10 @@ pub(crate) fn preview(
handlers: &[ConfiguredHandler],
_request: &StopRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(
handlers,
HookEventName::Stop,
/*session_start_source*/ None,
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
dispatcher::select_handlers(handlers, HookEventName::Stop, /*matcher_input*/ None)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
@@ -65,11 +62,8 @@ pub(crate) async fn run(
shell: &CommandShell,
request: StopRequest,
) -> StopOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::Stop,
/*session_start_source*/ None,
);
let matched =
dispatcher::select_handlers(handlers, HookEventName::Stop, /*matcher_input*/ None);
if matched.is_empty() {
return StopOutcome {
hook_events: Vec::new(),
@@ -92,11 +86,11 @@ pub(crate) async fn run(
)) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(
return serialization_failure_outcome(common::serialization_failure_hook_events(
matched,
Some(request.turn_id),
format!("failed to serialize stop hook input: {error}"),
);
));
}
};
@@ -172,7 +166,9 @@ fn parse_completed(
text: invalid_block_reason,
});
} else if parsed.should_block {
if let Some(reason) = parsed.reason.as_deref().and_then(trimmed_non_empty) {
if let Some(reason) =
parsed.reason.as_deref().and_then(common::trimmed_non_empty)
{
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
@@ -200,7 +196,7 @@ fn parse_completed(
}
}
Some(2) => {
if let Some(reason) = trimmed_non_empty(&run_result.stderr) {
if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) {
status = HookRunStatus::Blocked;
should_block = true;
block_reason = Some(reason.clone());
@@ -261,16 +257,22 @@ fn aggregate_results<'a>(
let stop_reason = results.iter().find_map(|result| result.stop_reason.clone());
let should_block = !should_stop && results.iter().any(|result| result.should_block);
let block_reason = if should_block {
join_block_text(results.iter().copied(), |result| {
result.block_reason.as_deref()
})
common::join_text_chunks(
results
.iter()
.filter_map(|result| result.block_reason.clone())
.collect(),
)
} else {
None
};
let continuation_prompt = if should_block {
join_block_text(results.iter().copied(), |result| {
result.continuation_prompt.as_deref()
})
common::join_text_chunks(
results
.iter()
.filter_map(|result| result.continuation_prompt.clone())
.collect(),
)
} else {
None
};
@@ -284,52 +286,7 @@ fn aggregate_results<'a>(
}
}
fn join_block_text<'a>(
results: impl IntoIterator<Item = &'a StopHandlerData>,
select: impl Fn(&'a StopHandlerData) -> Option<&'a str>,
) -> Option<String> {
let parts = results
.into_iter()
.filter_map(select)
.map(str::to_owned)
.collect::<Vec<_>>();
if parts.is_empty() {
return None;
}
Some(parts.join("\n\n"))
}
fn trimmed_non_empty(text: &str) -> Option<String> {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
None
}
fn serialization_failure_outcome(
handlers: Vec<ConfiguredHandler>,
turn_id: Option<String>,
error_message: String,
) -> StopOutcome {
let hook_events = handlers
.into_iter()
.map(|handler| {
let mut run = dispatcher::running_summary(&handler);
run.status = HookRunStatus::Failed;
run.completed_at = Some(run.started_at);
run.duration_ms = Some(0);
run.entries = vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error_message.clone(),
}];
HookCompletedEvent {
turn_id: turn_id.clone(),
run,
}
})
.collect();
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> StopOutcome {
StopOutcome {
hook_events,
should_stop: false,
@@ -0,0 +1,433 @@
use std::path::PathBuf;
use codex_protocol::ThreadId;
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 super::common;
use crate::engine::CommandShell;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
use crate::engine::dispatcher;
use crate::engine::output_parser;
use crate::schema::UserPromptSubmitCommandInput;
#[derive(Debug, Clone)]
pub struct UserPromptSubmitRequest {
pub session_id: ThreadId,
pub turn_id: String,
pub cwd: PathBuf,
pub transcript_path: Option<PathBuf>,
pub model: String,
pub permission_mode: String,
pub prompt: String,
}
#[derive(Debug)]
pub struct UserPromptSubmitOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub additional_contexts: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
struct UserPromptSubmitHandlerData {
should_stop: bool,
stop_reason: Option<String>,
additional_contexts_for_model: Vec<String>,
}
pub(crate) fn preview(
handlers: &[ConfiguredHandler],
_request: &UserPromptSubmitRequest,
) -> Vec<HookRunSummary> {
dispatcher::select_handlers(
handlers,
HookEventName::UserPromptSubmit,
/*matcher_input*/ None,
)
.into_iter()
.map(|handler| dispatcher::running_summary(&handler))
.collect()
}
pub(crate) async fn run(
handlers: &[ConfiguredHandler],
shell: &CommandShell,
request: UserPromptSubmitRequest,
) -> UserPromptSubmitOutcome {
let matched = dispatcher::select_handlers(
handlers,
HookEventName::UserPromptSubmit,
/*matcher_input*/ None,
);
if matched.is_empty() {
return UserPromptSubmitOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
additional_contexts: Vec::new(),
};
}
let input_json = match serde_json::to_string(&UserPromptSubmitCommandInput::new(
request.session_id.to_string(),
request.transcript_path.clone(),
request.cwd.display().to_string(),
request.model.clone(),
request.permission_mode.clone(),
request.prompt.clone(),
)) {
Ok(input_json) => input_json,
Err(error) => {
return serialization_failure_outcome(common::serialization_failure_hook_events(
matched,
Some(request.turn_id),
format!("failed to serialize user prompt submit hook input: {error}"),
));
}
};
let results = dispatcher::execute_handlers(
shell,
matched,
input_json,
request.cwd.as_path(),
Some(request.turn_id),
parse_completed,
)
.await;
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
.find_map(|result| result.data.stop_reason.clone());
let additional_contexts = common::flatten_additional_contexts(
results
.iter()
.map(|result| result.data.additional_contexts_for_model.as_slice()),
);
UserPromptSubmitOutcome {
hook_events: results.into_iter().map(|result| result.completed).collect(),
should_stop,
stop_reason,
additional_contexts,
}
}
fn parse_completed(
handler: &ConfiguredHandler,
run_result: CommandRunResult,
turn_id: Option<String>,
) -> dispatcher::ParsedHandler<UserPromptSubmitHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut should_stop = false;
let mut stop_reason = None;
let mut additional_contexts_for_model = Vec::new();
match run_result.error.as_deref() {
Some(error) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error.to_string(),
});
}
None => match run_result.exit_code {
Some(0) => {
let trimmed_stdout = run_result.stdout.trim();
if trimmed_stdout.is_empty() {
} else if let Some(parsed) =
output_parser::parse_user_prompt_submit(&run_result.stdout)
{
if let Some(system_message) = parsed.universal.system_message {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: system_message,
});
}
if parsed.invalid_block_reason.is_none()
&& let Some(additional_context) = parsed.additional_context
{
common::append_additional_context(
&mut entries,
&mut additional_contexts_for_model,
additional_context,
);
}
let _ = parsed.universal.suppress_output;
if !parsed.universal.continue_processing {
status = HookRunStatus::Stopped;
should_stop = true;
stop_reason = parsed.universal.stop_reason.clone();
if let Some(stop_reason_text) = parsed.universal.stop_reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: stop_reason_text,
});
}
} else if let Some(invalid_block_reason) = parsed.invalid_block_reason {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: invalid_block_reason,
});
} else if parsed.should_block {
status = HookRunStatus::Blocked;
should_stop = true;
stop_reason = parsed.reason.clone();
if let Some(reason) = parsed.reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
}
}
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook returned invalid user prompt submit JSON output".to_string(),
});
} else {
let additional_context = trimmed_stdout.to_string();
common::append_additional_context(
&mut entries,
&mut additional_contexts_for_model,
additional_context,
);
}
}
Some(2) => {
if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) {
status = HookRunStatus::Blocked;
should_stop = true;
stop_reason = Some(reason.clone());
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason,
});
} else {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "UserPromptSubmit hook exited with code 2 but did not write a blocking reason to stderr".to_string(),
});
}
}
Some(exit_code) => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: format!("hook exited with code {exit_code}"),
});
}
None => {
status = HookRunStatus::Failed;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "hook exited without a status code".to_string(),
});
}
},
}
let completed = HookCompletedEvent {
turn_id,
run: dispatcher::completed_summary(handler, &run_result, status, entries),
};
dispatcher::ParsedHandler {
completed,
data: UserPromptSubmitHandlerData {
should_stop,
stop_reason,
additional_contexts_for_model,
},
}
}
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> UserPromptSubmitOutcome {
UserPromptSubmitOutcome {
hook_events,
should_stop: false,
stop_reason: None,
additional_contexts: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use pretty_assertions::assert_eq;
use super::UserPromptSubmitHandlerData;
use super::parse_completed;
use crate::engine::ConfiguredHandler;
use crate::engine::command_runner::CommandRunResult;
#[test]
fn continue_false_preserves_context_for_later_turns() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"continue":false,"stopReason":"pause","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"do not inject"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
UserPromptSubmitHandlerData {
should_stop: true,
stop_reason: Some("pause".to_string()),
additional_contexts_for_model: vec!["do not inject".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
assert_eq!(
parsed.completed.run.entries,
vec![
HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "do not inject".to_string(),
},
HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: "pause".to_string(),
},
]
);
}
#[test]
fn claude_block_decision_blocks_processing() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"decision":"block","reason":"slow down","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"do not inject"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
UserPromptSubmitHandlerData {
should_stop: true,
stop_reason: Some("slow down".to_string()),
additional_contexts_for_model: vec!["do not inject".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
assert_eq!(
parsed.completed.run.entries,
vec![
HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "do not inject".to_string(),
},
HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: "slow down".to_string(),
},
]
);
}
#[test]
fn claude_block_decision_requires_reason() {
let parsed = parse_completed(
&handler(),
run_result(
Some(0),
r#"{"decision":"block","hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"do not inject"}}"#,
"",
),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
UserPromptSubmitHandlerData {
should_stop: false,
stop_reason: None,
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: "UserPromptSubmit hook returned decision:block without a non-empty reason"
.to_string(),
}]
);
}
#[test]
fn exit_code_two_blocks_processing() {
let parsed = parse_completed(
&handler(),
run_result(Some(2), "", "blocked by policy\n"),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
UserPromptSubmitHandlerData {
should_stop: true,
stop_reason: Some("blocked by policy".to_string()),
additional_contexts_for_model: Vec::new(),
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: "blocked by policy".to_string(),
}]
);
}
fn handler() -> ConfiguredHandler {
ConfiguredHandler {
event_name: HookEventName::UserPromptSubmit,
matcher: None,
command: "echo hook".to_string(),
timeout_sec: 5,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}
}
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
CommandRunResult {
started_at: 1,
completed_at: 2,
duration_ms: 1,
exit_code,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
error: None,
}
}
}
+2
View File
@@ -10,6 +10,8 @@ pub use events::session_start::SessionStartRequest;
pub use events::session_start::SessionStartSource;
pub use events::stop::StopOutcome;
pub use events::stop::StopRequest;
pub use events::user_prompt_submit::UserPromptSubmitOutcome;
pub use events::user_prompt_submit::UserPromptSubmitRequest;
pub use legacy_notify::legacy_notify_json;
pub use legacy_notify::notify_hook;
pub use registry::Hooks;
+16
View File
@@ -7,6 +7,8 @@ use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
use crate::events::stop::StopRequest;
use crate::events::user_prompt_submit::UserPromptSubmitOutcome;
use crate::events::user_prompt_submit::UserPromptSubmitRequest;
use crate::types::Hook;
use crate::types::HookEvent;
use crate::types::HookPayload;
@@ -98,6 +100,20 @@ impl Hooks {
self.engine.run_session_start(request, turn_id).await
}
pub fn preview_user_prompt_submit(
&self,
request: &UserPromptSubmitRequest,
) -> Vec<codex_protocol::protocol::HookRunSummary> {
self.engine.preview_user_prompt_submit(request)
}
pub async fn run_user_prompt_submit(
&self,
request: UserPromptSubmitRequest,
) -> UserPromptSubmitOutcome {
self.engine.run_user_prompt_submit(request).await
}
pub fn preview_stop(
&self,
request: &StopRequest,
+88 -2
View File
@@ -15,6 +15,8 @@ use std::path::PathBuf;
const GENERATED_DIR: &str = "generated";
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json";
const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json";
const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json";
const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json";
@@ -63,6 +65,8 @@ pub(crate) struct HookUniversalOutputWire {
pub(crate) enum HookEventNameWire {
#[serde(rename = "SessionStart")]
SessionStart,
#[serde(rename = "UserPromptSubmit")]
UserPromptSubmit,
#[serde(rename = "Stop")]
Stop,
}
@@ -87,6 +91,30 @@ pub(crate) struct SessionStartHookSpecificOutputWire {
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "user-prompt-submit.command.output")]
pub(crate) struct UserPromptSubmitCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<BlockDecisionWire>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub hook_specific_output: Option<UserPromptSubmitHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct UserPromptSubmitHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -95,7 +123,7 @@ pub(crate) struct StopCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<StopDecisionWire>,
pub decision: Option<BlockDecisionWire>,
/// Claude requires `reason` when `decision` is `block`; we enforce that
/// semantic rule during output parsing rather than in the JSON schema.
#[serde(default)]
@@ -103,7 +131,7 @@ pub(crate) struct StopCommandOutputWire {
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum StopDecisionWire {
pub(crate) enum BlockDecisionWire {
#[serde(rename = "block")]
Block,
}
@@ -145,6 +173,42 @@ impl SessionStartCommandInput {
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "user-prompt-submit.command.input")]
pub(crate) struct UserPromptSubmitCommandInput {
pub session_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "user_prompt_submit_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
pub prompt: String,
}
impl UserPromptSubmitCommandInput {
pub(crate) fn new(
session_id: impl Into<String>,
transcript_path: Option<PathBuf>,
cwd: impl Into<String>,
model: impl Into<String>,
permission_mode: impl Into<String>,
prompt: impl Into<String>,
) -> Self {
Self {
session_id: session_id.into(),
transcript_path: NullableString::from_path(transcript_path),
cwd: cwd.into(),
hook_event_name: "UserPromptSubmit".to_string(),
model: model.into(),
permission_mode: permission_mode.into(),
prompt: prompt.into(),
}
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "stop.command.input")]
@@ -196,6 +260,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
&generated_dir.join(SESSION_START_OUTPUT_FIXTURE),
schema_json::<SessionStartCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(USER_PROMPT_SUBMIT_INPUT_FIXTURE),
schema_json::<UserPromptSubmitCommandInput>()?,
)?;
write_schema(
&generated_dir.join(USER_PROMPT_SUBMIT_OUTPUT_FIXTURE),
schema_json::<UserPromptSubmitCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(STOP_INPUT_FIXTURE),
schema_json::<StopCommandInput>()?,
@@ -263,6 +335,10 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("SessionStart")
}
fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("UserPromptSubmit")
}
fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Stop")
}
@@ -314,6 +390,8 @@ mod tests {
use super::SESSION_START_OUTPUT_FIXTURE;
use super::STOP_INPUT_FIXTURE;
use super::STOP_OUTPUT_FIXTURE;
use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE;
use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE;
use super::write_schema_fixtures;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -326,6 +404,12 @@ mod tests {
SESSION_START_OUTPUT_FIXTURE => {
include_str!("../schema/generated/session-start.command.output.schema.json")
}
USER_PROMPT_SUBMIT_INPUT_FIXTURE => {
include_str!("../schema/generated/user-prompt-submit.command.input.schema.json")
}
USER_PROMPT_SUBMIT_OUTPUT_FIXTURE => {
include_str!("../schema/generated/user-prompt-submit.command.output.schema.json")
}
STOP_INPUT_FIXTURE => {
include_str!("../schema/generated/stop.command.input.schema.json")
}
@@ -349,6 +433,8 @@ mod tests {
for fixture in [
SESSION_START_INPUT_FIXTURE,
SESSION_START_OUTPUT_FIXTURE,
USER_PROMPT_SUBMIT_INPUT_FIXTURE,
USER_PROMPT_SUBMIT_OUTPUT_FIXTURE,
STOP_INPUT_FIXTURE,
STOP_OUTPUT_FIXTURE,
] {
+1
View File
@@ -1341,6 +1341,7 @@ pub enum EventMsg {
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
SessionStart,
UserPromptSubmit,
Stop,
}
+1
View File
@@ -9481,6 +9481,7 @@ fn extract_first_bold(s: &str) -> Option<String> {
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_protocol::protocol::HookEventName::Stop => "Stop",
}
}
@@ -9338,6 +9338,7 @@ fn extract_first_bold(s: &str) -> Option<String> {
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_protocol::protocol::HookEventName::Stop => "Stop",
}
}