mirror of
https://github.com/openai/codex.git
synced 2026-05-04 13:21:54 +03:00
start of hooks engine (#13276)
(Experimental) This PR adds a first MVP for hooks, with SessionStart and Stop The core design is: - hooks live in a dedicated engine under codex-rs/hooks - each hook type has its own event-specific file - hook execution is synchronous and blocks normal turn progression while running - matching hooks run in parallel, then their results are aggregated into a normalized HookRunSummary On the AppServer side, hooks are exposed as operational metadata rather than transcript-native items: - new live notifications: hook/started, hook/completed - persisted/replayed hook results live on Turn.hookRuns - we intentionally did not add hook-specific ThreadItem variants Hooks messages are not persisted, they remain ephemeral. The context changes they add are (they get appended to the user's prompt)
This commit is contained in:
2
codex-rs/hooks/src/events/mod.rs
Normal file
2
codex-rs/hooks/src/events/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod session_start;
|
||||
pub mod stop;
|
||||
393
codex-rs/hooks/src/events/session_start.rs
Normal file
393
codex-rs/hooks/src/events/session_start.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
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 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::SessionStartCommandInput;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum SessionStartSource {
|
||||
Startup,
|
||||
Resume,
|
||||
}
|
||||
|
||||
impl SessionStartSource {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Startup => "startup",
|
||||
Self::Resume => "resume",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionStartRequest {
|
||||
pub session_id: ThreadId,
|
||||
pub cwd: PathBuf,
|
||||
pub transcript_path: Option<PathBuf>,
|
||||
pub model: String,
|
||||
pub permission_mode: String,
|
||||
pub source: SessionStartSource,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SessionStartOutcome {
|
||||
pub hook_events: Vec<HookCompletedEvent>,
|
||||
pub should_stop: bool,
|
||||
pub stop_reason: Option<String>,
|
||||
pub additional_context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct SessionStartHandlerData {
|
||||
should_stop: bool,
|
||||
stop_reason: Option<String>,
|
||||
additional_context_for_model: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn preview(
|
||||
handlers: &[ConfiguredHandler],
|
||||
request: &SessionStartRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::SessionStart,
|
||||
Some(request.source.as_str()),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|handler| dispatcher::running_summary(&handler))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
request: SessionStartRequest,
|
||||
turn_id: Option<String>,
|
||||
) -> SessionStartOutcome {
|
||||
let matched = dispatcher::select_handlers(
|
||||
handlers,
|
||||
HookEventName::SessionStart,
|
||||
Some(request.source.as_str()),
|
||||
);
|
||||
if matched.is_empty() {
|
||||
return SessionStartOutcome {
|
||||
hook_events: Vec::new(),
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
additional_context: None,
|
||||
};
|
||||
}
|
||||
|
||||
let input_json = match serde_json::to_string(&SessionStartCommandInput::new(
|
||||
request.session_id.to_string(),
|
||||
request.transcript_path.clone(),
|
||||
request.cwd.display().to_string(),
|
||||
request.model.clone(),
|
||||
request.permission_mode.clone(),
|
||||
request.source.as_str().to_string(),
|
||||
)) {
|
||||
Ok(input_json) => input_json,
|
||||
Err(error) => {
|
||||
return serialization_failure_outcome(
|
||||
matched,
|
||||
turn_id,
|
||||
format!("failed to serialize session start hook input: {error}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let results = dispatcher::execute_handlers(
|
||||
shell,
|
||||
matched,
|
||||
input_json,
|
||||
request.cwd.as_path(),
|
||||
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 = results
|
||||
.iter()
|
||||
.filter_map(|result| result.data.additional_context_for_model.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
SessionStartOutcome {
|
||||
hook_events: results.into_iter().map(|result| result.completed).collect(),
|
||||
should_stop,
|
||||
stop_reason,
|
||||
additional_context: join_text_chunks(additional_contexts),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_completed(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: CommandRunResult,
|
||||
turn_id: Option<String>,
|
||||
) -> dispatcher::ParsedHandler<SessionStartHandlerData> {
|
||||
let mut entries = Vec::new();
|
||||
let mut status = HookRunStatus::Completed;
|
||||
let mut should_stop = false;
|
||||
let mut stop_reason = None;
|
||||
let mut additional_context_for_model = None;
|
||||
|
||||
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_session_start(&run_result.stdout)
|
||||
{
|
||||
if let Some(system_message) = parsed.universal.system_message {
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: system_message,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Preserve plain-text context support without treating malformed JSON as context.
|
||||
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned invalid session start JSON output".to_string(),
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
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: SessionStartHandlerData {
|
||||
should_stop,
|
||||
stop_reason,
|
||||
additional_context_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();
|
||||
|
||||
SessionStartOutcome {
|
||||
hook_events,
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
additional_context: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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::SessionStartHandlerData;
|
||||
use super::parse_completed;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
|
||||
#[test]
|
||||
fn plain_stdout_becomes_model_context() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), "hello from hook\n", ""),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
SessionStartHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
additional_context_for_model: Some("hello from hook".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Context,
|
||||
text: "hello from hook".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_false_keeps_context_out_of_model_input() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"continue":false,"stopReason":"pause","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"do not inject"}}"#,
|
||||
"",
|
||||
),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
SessionStartHandlerData {
|
||||
should_stop: true,
|
||||
stop_reason: Some("pause".to_string()),
|
||||
additional_context_for_model: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_like_stdout_fails_instead_of_becoming_model_context() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"hookSpecificOutput":{"hookEventName":"SessionStart""#,
|
||||
"",
|
||||
),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
SessionStartHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
additional_context_for_model: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned invalid session start JSON output".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::SessionStart,
|
||||
matcher: None,
|
||||
command: "echo hook".to_string(),
|
||||
timeout_sec: 600,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
495
codex-rs/hooks/src/events/stop.rs
Normal file
495
codex-rs/hooks/src/events/stop.rs
Normal file
@@ -0,0 +1,495 @@
|
||||
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 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::StopCommandInput;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StopRequest {
|
||||
pub session_id: ThreadId,
|
||||
pub turn_id: String,
|
||||
pub cwd: PathBuf,
|
||||
pub transcript_path: Option<PathBuf>,
|
||||
pub model: String,
|
||||
pub permission_mode: String,
|
||||
pub stop_hook_active: bool,
|
||||
pub last_assistant_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StopOutcome {
|
||||
pub hook_events: Vec<HookCompletedEvent>,
|
||||
pub should_stop: bool,
|
||||
pub stop_reason: Option<String>,
|
||||
pub should_block: bool,
|
||||
pub block_reason: Option<String>,
|
||||
pub block_message_for_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct StopHandlerData {
|
||||
should_stop: bool,
|
||||
stop_reason: Option<String>,
|
||||
should_block: bool,
|
||||
block_reason: Option<String>,
|
||||
block_message_for_model: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn preview(
|
||||
handlers: &[ConfiguredHandler],
|
||||
_request: &StopRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
dispatcher::select_handlers(handlers, HookEventName::Stop, None)
|
||||
.into_iter()
|
||||
.map(|handler| dispatcher::running_summary(&handler))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn run(
|
||||
handlers: &[ConfiguredHandler],
|
||||
shell: &CommandShell,
|
||||
request: StopRequest,
|
||||
) -> StopOutcome {
|
||||
let matched = dispatcher::select_handlers(handlers, HookEventName::Stop, None);
|
||||
if matched.is_empty() {
|
||||
return StopOutcome {
|
||||
hook_events: Vec::new(),
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
block_message_for_model: None,
|
||||
};
|
||||
}
|
||||
|
||||
let input_json = match serde_json::to_string(&StopCommandInput::new(
|
||||
request.session_id.to_string(),
|
||||
request.transcript_path.clone(),
|
||||
request.cwd.display().to_string(),
|
||||
request.model.clone(),
|
||||
request.permission_mode.clone(),
|
||||
request.stop_hook_active,
|
||||
request.last_assistant_message.clone(),
|
||||
)) {
|
||||
Ok(input_json) => input_json,
|
||||
Err(error) => {
|
||||
return serialization_failure_outcome(
|
||||
matched,
|
||||
Some(request.turn_id),
|
||||
format!("failed to serialize stop 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 should_block = !should_stop && results.iter().any(|result| result.data.should_block);
|
||||
let block_reason = if should_block {
|
||||
results
|
||||
.iter()
|
||||
.find_map(|result| result.data.block_reason.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let block_message_for_model = if should_block {
|
||||
results
|
||||
.iter()
|
||||
.find_map(|result| result.data.block_message_for_model.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
StopOutcome {
|
||||
hook_events: results.into_iter().map(|result| result.completed).collect(),
|
||||
should_stop,
|
||||
stop_reason,
|
||||
should_block,
|
||||
block_reason,
|
||||
block_message_for_model,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_completed(
|
||||
handler: &ConfiguredHandler,
|
||||
run_result: CommandRunResult,
|
||||
turn_id: Option<String>,
|
||||
) -> dispatcher::ParsedHandler<StopHandlerData> {
|
||||
let mut entries = Vec::new();
|
||||
let mut status = HookRunStatus::Completed;
|
||||
let mut should_stop = false;
|
||||
let mut stop_reason = None;
|
||||
let mut should_block = false;
|
||||
let mut block_reason = None;
|
||||
let mut block_message_for_model = None;
|
||||
|
||||
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_stop(&run_result.stdout) {
|
||||
if let Some(system_message) = parsed.universal.system_message {
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Warning,
|
||||
text: system_message,
|
||||
});
|
||||
}
|
||||
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 parsed.should_block {
|
||||
if let Some(reason) = parsed.reason.as_deref().and_then(trimmed_non_empty) {
|
||||
status = HookRunStatus::Blocked;
|
||||
should_block = true;
|
||||
block_reason = Some(reason.clone());
|
||||
block_message_for_model = Some(reason.clone());
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: reason,
|
||||
});
|
||||
} else {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned decision \"block\" without a non-empty reason"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned invalid stop hook JSON output".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(2) => {
|
||||
if let Some(reason) = trimmed_non_empty(&run_result.stderr) {
|
||||
status = HookRunStatus::Blocked;
|
||||
should_block = true;
|
||||
block_reason = Some(reason.clone());
|
||||
block_message_for_model = Some(reason.clone());
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Feedback,
|
||||
text: reason,
|
||||
});
|
||||
} else {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook exited with code 2 without stderr feedback".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: StopHandlerData {
|
||||
should_stop,
|
||||
stop_reason,
|
||||
should_block,
|
||||
block_reason,
|
||||
block_message_for_model,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
StopOutcome {
|
||||
hook_events,
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
block_message_for_model: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[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::StopHandlerData;
|
||||
use super::parse_completed;
|
||||
use crate::engine::ConfiguredHandler;
|
||||
use crate::engine::command_runner::CommandRunResult;
|
||||
|
||||
#[test]
|
||||
fn continue_false_overrides_block_decision() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(
|
||||
Some(0),
|
||||
r#"{"continue":false,"stopReason":"done","decision":"block","reason":"keep going"}"#,
|
||||
"",
|
||||
),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
StopHandlerData {
|
||||
should_stop: true,
|
||||
stop_reason: Some("done".to_string()),
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
block_message_for_model: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_two_uses_stderr_feedback_only() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(2), "ignored stdout", "retry with tests"),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
StopHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
should_block: true,
|
||||
block_reason: Some("retry with tests".to_string()),
|
||||
block_message_for_model: Some("retry with tests".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_decision_without_reason_fails_instead_of_blocking() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), r#"{"decision":"block"}"#, ""),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
StopHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
block_message_for_model: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned decision \"block\" without a non-empty reason".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_decision_with_blank_reason_fails_instead_of_blocking() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), "{\"decision\":\"block\",\"reason\":\" \"}", ""),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
StopHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
block_message_for_model: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned decision \"block\" without a non-empty reason".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_two_without_stderr_feedback_fails_instead_of_blocking() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(2), "ignored stdout", " "),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
StopHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
block_message_for_model: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook exited with code 2 without stderr feedback".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_stdout_fails_instead_of_silently_nooping() {
|
||||
let parsed = parse_completed(
|
||||
&handler(),
|
||||
run_result(Some(0), "not json", ""),
|
||||
Some("turn-1".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parsed.data,
|
||||
StopHandlerData {
|
||||
should_stop: false,
|
||||
stop_reason: None,
|
||||
should_block: false,
|
||||
block_reason: None,
|
||||
block_message_for_model: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
||||
assert_eq!(
|
||||
parsed.completed.run.entries,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
text: "hook returned invalid stop hook JSON output".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
fn handler() -> ConfiguredHandler {
|
||||
ConfiguredHandler {
|
||||
event_name: HookEventName::Stop,
|
||||
matcher: None,
|
||||
command: "echo hook".to_string(),
|
||||
timeout_sec: 600,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user