mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
226241f035
commit
6fef421654
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
mod common;
|
||||
pub mod session_start;
|
||||
pub mod stop;
|
||||
pub mod user_prompt_submit;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
] {
|
||||
|
||||
@@ -1341,6 +1341,7 @@ pub enum EventMsg {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HookEventName {
|
||||
SessionStart,
|
||||
UserPromptSubmit,
|
||||
Stop,
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user