mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
16d85e2708
# What When a normal hook fires inside a thread-spawned subagent, Codex now includes these optional top-level fields in the hook input: - `agent_id`: the child thread id - `agent_type`: the subagent role Root-agent hook inputs omit these fields. `SubagentStart` and `SubagentStop` keep their existing required `agent_id` and `agent_type` fields because those events are inherently subagent-scoped. This does not change matcher behavior. Tool hooks still match on tool name, compact hooks still match on trigger, and `UserPromptSubmit` still ignores matchers. Only `SubagentStart` and `SubagentStop` match on `agent_type`.
622 lines
21 KiB
Rust
622 lines
21 KiB
Rust
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 codex_utils_absolute_path::AbsolutePathBuf;
|
|
|
|
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::PostCompactCommandInput;
|
|
use crate::schema::PreCompactCommandInput;
|
|
use crate::schema::SubagentCommandInputFields;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PreCompactRequest {
|
|
pub session_id: ThreadId,
|
|
pub turn_id: String,
|
|
pub subagent: Option<common::SubagentHookContext>,
|
|
pub cwd: AbsolutePathBuf,
|
|
pub transcript_path: Option<PathBuf>,
|
|
pub model: String,
|
|
pub trigger: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PostCompactRequest {
|
|
pub session_id: ThreadId,
|
|
pub turn_id: String,
|
|
pub subagent: Option<common::SubagentHookContext>,
|
|
pub cwd: AbsolutePathBuf,
|
|
pub transcript_path: Option<PathBuf>,
|
|
pub model: String,
|
|
pub trigger: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct StatelessHookOutcome {
|
|
pub hook_events: Vec<HookCompletedEvent>,
|
|
pub should_stop: bool,
|
|
pub stop_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct PreCompactOutcome {
|
|
pub hook_events: Vec<HookCompletedEvent>,
|
|
pub should_stop: bool,
|
|
pub stop_reason: Option<String>,
|
|
}
|
|
|
|
pub(crate) fn preview_pre(
|
|
handlers: &[ConfiguredHandler],
|
|
request: &PreCompactRequest,
|
|
) -> Vec<HookRunSummary> {
|
|
dispatcher::select_handlers(
|
|
handlers,
|
|
HookEventName::PreCompact,
|
|
Some(request.trigger.as_str()),
|
|
)
|
|
.into_iter()
|
|
.map(|handler| dispatcher::running_summary(&handler))
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) async fn run_pre(
|
|
handlers: &[ConfiguredHandler],
|
|
shell: &CommandShell,
|
|
request: PreCompactRequest,
|
|
) -> PreCompactOutcome {
|
|
let matched = dispatcher::select_handlers(
|
|
handlers,
|
|
HookEventName::PreCompact,
|
|
Some(request.trigger.as_str()),
|
|
);
|
|
if matched.is_empty() {
|
|
return PreCompactOutcome {
|
|
hook_events: Vec::new(),
|
|
should_stop: false,
|
|
stop_reason: None,
|
|
};
|
|
}
|
|
|
|
let input_json = match pre_command_input_json(&request) {
|
|
Ok(input_json) => input_json,
|
|
Err(error) => {
|
|
return PreCompactOutcome {
|
|
hook_events: common::serialization_failure_hook_events(
|
|
matched,
|
|
Some(request.turn_id),
|
|
format!("failed to serialize pre compact hook input: {error}"),
|
|
),
|
|
should_stop: false,
|
|
stop_reason: None,
|
|
};
|
|
}
|
|
};
|
|
|
|
let results = dispatcher::execute_handlers(
|
|
shell,
|
|
matched,
|
|
input_json,
|
|
request.cwd.as_path(),
|
|
Some(request.turn_id),
|
|
parse_pre_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());
|
|
PreCompactOutcome {
|
|
hook_events: results.into_iter().map(|result| result.completed).collect(),
|
|
should_stop,
|
|
stop_reason,
|
|
}
|
|
}
|
|
|
|
fn pre_command_input_json(request: &PreCompactRequest) -> Result<String, serde_json::Error> {
|
|
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
|
|
serde_json::to_string(&PreCompactCommandInput {
|
|
session_id: request.session_id.to_string(),
|
|
turn_id: request.turn_id.clone(),
|
|
agent_id: subagent.agent_id,
|
|
agent_type: subagent.agent_type,
|
|
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
|
|
cwd: request.cwd.display().to_string(),
|
|
hook_event_name: "PreCompact".to_string(),
|
|
model: request.model.clone(),
|
|
trigger: request.trigger.clone(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn preview_post(
|
|
handlers: &[ConfiguredHandler],
|
|
request: &PostCompactRequest,
|
|
) -> Vec<HookRunSummary> {
|
|
dispatcher::select_handlers(
|
|
handlers,
|
|
HookEventName::PostCompact,
|
|
Some(request.trigger.as_str()),
|
|
)
|
|
.into_iter()
|
|
.map(|handler| dispatcher::running_summary(&handler))
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) async fn run_post(
|
|
handlers: &[ConfiguredHandler],
|
|
shell: &CommandShell,
|
|
request: PostCompactRequest,
|
|
) -> StatelessHookOutcome {
|
|
let matched = dispatcher::select_handlers(
|
|
handlers,
|
|
HookEventName::PostCompact,
|
|
Some(request.trigger.as_str()),
|
|
);
|
|
if matched.is_empty() {
|
|
return StatelessHookOutcome {
|
|
hook_events: Vec::new(),
|
|
should_stop: false,
|
|
stop_reason: None,
|
|
};
|
|
}
|
|
|
|
let input_json = match post_command_input_json(&request) {
|
|
Ok(input_json) => input_json,
|
|
Err(error) => {
|
|
return StatelessHookOutcome {
|
|
hook_events: common::serialization_failure_hook_events(
|
|
matched,
|
|
Some(request.turn_id),
|
|
format!("failed to serialize post compact hook input: {error}"),
|
|
),
|
|
should_stop: false,
|
|
stop_reason: None,
|
|
};
|
|
}
|
|
};
|
|
|
|
let results = dispatcher::execute_handlers(
|
|
shell,
|
|
matched,
|
|
input_json,
|
|
request.cwd.as_path(),
|
|
Some(request.turn_id),
|
|
parse_post_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());
|
|
StatelessHookOutcome {
|
|
hook_events: results.into_iter().map(|result| result.completed).collect(),
|
|
should_stop,
|
|
stop_reason,
|
|
}
|
|
}
|
|
|
|
fn post_command_input_json(request: &PostCompactRequest) -> Result<String, serde_json::Error> {
|
|
let subagent = SubagentCommandInputFields::from(request.subagent.as_ref());
|
|
serde_json::to_string(&PostCompactCommandInput {
|
|
session_id: request.session_id.to_string(),
|
|
turn_id: request.turn_id.clone(),
|
|
agent_id: subagent.agent_id,
|
|
agent_type: subagent.agent_type,
|
|
transcript_path: crate::schema::NullableString::from_path(request.transcript_path.clone()),
|
|
cwd: request.cwd.display().to_string(),
|
|
hook_event_name: "PostCompact".to_string(),
|
|
model: request.model.clone(),
|
|
trigger: request.trigger.clone(),
|
|
})
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct CompactHandlerData {
|
|
should_stop: bool,
|
|
stop_reason: Option<String>,
|
|
}
|
|
|
|
fn parse_pre_completed(
|
|
handler: &ConfiguredHandler,
|
|
run_result: CommandRunResult,
|
|
turn_id: Option<String>,
|
|
) -> dispatcher::ParsedHandler<CompactHandlerData> {
|
|
let mut entries = Vec::new();
|
|
let mut status = HookRunStatus::Completed;
|
|
let mut should_stop = false;
|
|
let mut stop_reason = 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_pre_compact(&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();
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Stop,
|
|
text: parsed
|
|
.universal
|
|
.stop_reason
|
|
.unwrap_or_else(|| "PreCompact hook stopped execution".to_string()),
|
|
});
|
|
} else if let Some(invalid_reason) = parsed.invalid_reason {
|
|
status = HookRunStatus::Failed;
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: invalid_reason,
|
|
});
|
|
}
|
|
} else if output_parser::looks_like_json(&run_result.stdout) {
|
|
status = HookRunStatus::Failed;
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: "hook returned invalid PreCompact hook JSON output".to_string(),
|
|
});
|
|
}
|
|
}
|
|
Some(code) => {
|
|
status = HookRunStatus::Failed;
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: common::trimmed_non_empty(&run_result.stderr)
|
|
.unwrap_or_else(|| format!("hook exited with code {code}")),
|
|
});
|
|
}
|
|
None => {
|
|
status = HookRunStatus::Failed;
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: "hook process terminated without an exit code".to_string(),
|
|
});
|
|
}
|
|
},
|
|
}
|
|
|
|
dispatcher::ParsedHandler {
|
|
completed: HookCompletedEvent {
|
|
turn_id,
|
|
run: dispatcher::completed_summary(handler, &run_result, status, entries),
|
|
},
|
|
data: CompactHandlerData {
|
|
should_stop,
|
|
stop_reason,
|
|
},
|
|
completion_order: 0,
|
|
}
|
|
}
|
|
|
|
fn parse_post_completed(
|
|
handler: &ConfiguredHandler,
|
|
run_result: CommandRunResult,
|
|
turn_id: Option<String>,
|
|
) -> dispatcher::ParsedHandler<CompactHandlerData> {
|
|
parse_completed(
|
|
handler,
|
|
run_result,
|
|
turn_id,
|
|
"PostCompact",
|
|
output_parser::parse_post_compact,
|
|
)
|
|
}
|
|
|
|
fn parse_completed(
|
|
handler: &ConfiguredHandler,
|
|
run_result: CommandRunResult,
|
|
turn_id: Option<String>,
|
|
event_label: &'static str,
|
|
parse_output: fn(&str) -> Option<output_parser::StatelessHookOutput>,
|
|
) -> dispatcher::ParsedHandler<CompactHandlerData> {
|
|
let mut entries = Vec::new();
|
|
let mut status = HookRunStatus::Completed;
|
|
let mut should_stop = false;
|
|
let mut stop_reason = 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) = parse_output(&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();
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Stop,
|
|
text: parsed
|
|
.universal
|
|
.stop_reason
|
|
.unwrap_or_else(|| format!("{event_label} hook stopped execution")),
|
|
});
|
|
} else if let Some(invalid_reason) = parsed.invalid_reason {
|
|
status = HookRunStatus::Failed;
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: invalid_reason,
|
|
});
|
|
}
|
|
} else if output_parser::looks_like_json(&run_result.stdout) {
|
|
status = HookRunStatus::Failed;
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: format!("hook returned invalid {event_label} hook JSON output"),
|
|
});
|
|
}
|
|
}
|
|
Some(code) => {
|
|
status = HookRunStatus::Failed;
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: common::trimmed_non_empty(&run_result.stderr)
|
|
.unwrap_or_else(|| format!("hook exited with code {code}")),
|
|
});
|
|
}
|
|
None => {
|
|
status = HookRunStatus::Failed;
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: "hook process terminated without an exit code".to_string(),
|
|
});
|
|
}
|
|
},
|
|
}
|
|
|
|
dispatcher::ParsedHandler {
|
|
completed: HookCompletedEvent {
|
|
turn_id,
|
|
run: dispatcher::completed_summary(handler, &run_result, status, entries),
|
|
},
|
|
data: CompactHandlerData {
|
|
should_stop,
|
|
stop_reason,
|
|
},
|
|
completion_order: 0,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::protocol::HookEventName;
|
|
use codex_protocol::protocol::HookOutputEntry;
|
|
use codex_protocol::protocol::HookOutputEntryKind;
|
|
use codex_protocol::protocol::HookRunStatus;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
|
|
use super::parse_post_completed;
|
|
use super::parse_pre_completed;
|
|
use super::post_command_input_json;
|
|
use super::pre_command_input_json;
|
|
use crate::engine::ConfiguredHandler;
|
|
use crate::engine::command_runner::CommandRunResult;
|
|
|
|
#[test]
|
|
fn pre_compact_input_includes_lifecycle_metadata() {
|
|
let input_json = pre_command_input_json(&pre_request()).expect("serialize command input");
|
|
let input: serde_json::Value =
|
|
serde_json::from_str(&input_json).expect("parse command input");
|
|
|
|
assert_eq!(
|
|
input,
|
|
json!({
|
|
"session_id": pre_request().session_id.to_string(),
|
|
"turn_id": "turn-1",
|
|
"transcript_path": null,
|
|
"cwd": test_path_buf("/tmp").display().to_string(),
|
|
"hook_event_name": "PreCompact",
|
|
"model": "gpt-test",
|
|
"trigger": "manual",
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn post_compact_input_includes_lifecycle_metadata() {
|
|
let input_json = post_command_input_json(&post_request()).expect("serialize command input");
|
|
let input: serde_json::Value =
|
|
serde_json::from_str(&input_json).expect("parse command input");
|
|
|
|
assert_eq!(
|
|
input,
|
|
json!({
|
|
"session_id": post_request().session_id.to_string(),
|
|
"turn_id": "turn-1",
|
|
"transcript_path": null,
|
|
"cwd": test_path_buf("/tmp").display().to_string(),
|
|
"hook_event_name": "PostCompact",
|
|
"model": "gpt-test",
|
|
"trigger": "manual",
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn block_decision_is_not_supported_for_pre_compact() {
|
|
let parsed = parse_pre_completed(
|
|
&handler(HookEventName::PreCompact),
|
|
run_result(
|
|
Some(0),
|
|
r#"{"decision":"block","reason":"policy blocked compaction"}"#,
|
|
"",
|
|
),
|
|
Some("turn-1".to_string()),
|
|
);
|
|
|
|
assert_eq!(parsed.completed.run.status, HookRunStatus::Failed);
|
|
assert_eq!(
|
|
parsed.completed.run.entries,
|
|
vec![HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: "hook returned invalid PreCompact hook JSON output".to_string(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn continue_false_stops_before_compaction() {
|
|
let parsed = parse_pre_completed(
|
|
&handler(HookEventName::PreCompact),
|
|
run_result(Some(0), r#"{"continue":false,"stopReason":"nope"}"#, ""),
|
|
Some("turn-1".to_string()),
|
|
);
|
|
|
|
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
|
|
assert_eq!(parsed.data.should_stop, true);
|
|
assert_eq!(parsed.data.stop_reason, Some("nope".to_string()));
|
|
assert_eq!(
|
|
parsed.completed.run.entries,
|
|
vec![HookOutputEntry {
|
|
kind: HookOutputEntryKind::Stop,
|
|
text: "nope".to_string(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn post_compact_continue_false_stops_after_compaction() {
|
|
let parsed = parse_post_completed(
|
|
&handler(HookEventName::PostCompact),
|
|
run_result(
|
|
Some(0),
|
|
r#"{"continue":false,"stopReason":"pause after compact"}"#,
|
|
"",
|
|
),
|
|
Some("turn-1".to_string()),
|
|
);
|
|
|
|
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
|
|
assert_eq!(parsed.data.should_stop, true);
|
|
assert_eq!(
|
|
parsed.data.stop_reason,
|
|
Some("pause after compact".to_string())
|
|
);
|
|
assert_eq!(
|
|
parsed.completed.run.entries,
|
|
vec![HookOutputEntry {
|
|
kind: HookOutputEntryKind::Stop,
|
|
text: "pause after compact".to_string(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pre_compact_ignores_plain_stdout() {
|
|
let parsed = parse_pre_completed(
|
|
&handler(HookEventName::PreCompact),
|
|
run_result(Some(0), "checking compact policy\n", ""),
|
|
Some("turn-1".to_string()),
|
|
);
|
|
|
|
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
|
|
assert_eq!(parsed.completed.run.entries, Vec::new());
|
|
}
|
|
|
|
#[test]
|
|
fn post_compact_ignores_plain_stdout() {
|
|
let parsed = parse_post_completed(
|
|
&handler(HookEventName::PostCompact),
|
|
run_result(Some(0), "logged compact summary\n", ""),
|
|
Some("turn-1".to_string()),
|
|
);
|
|
|
|
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
|
|
assert_eq!(parsed.completed.run.entries, Vec::new());
|
|
}
|
|
|
|
fn pre_request() -> super::PreCompactRequest {
|
|
super::PreCompactRequest {
|
|
session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000001")
|
|
.expect("valid thread id"),
|
|
turn_id: "turn-1".to_string(),
|
|
subagent: None,
|
|
cwd: test_path_buf("/tmp").abs(),
|
|
transcript_path: None,
|
|
model: "gpt-test".to_string(),
|
|
trigger: "manual".to_string(),
|
|
}
|
|
}
|
|
|
|
fn post_request() -> super::PostCompactRequest {
|
|
super::PostCompactRequest {
|
|
session_id: ThreadId::from_string("00000000-0000-4000-8000-000000000002")
|
|
.expect("valid thread id"),
|
|
turn_id: "turn-1".to_string(),
|
|
subagent: None,
|
|
cwd: test_path_buf("/tmp").abs(),
|
|
transcript_path: None,
|
|
model: "gpt-test".to_string(),
|
|
trigger: "manual".to_string(),
|
|
}
|
|
}
|
|
|
|
fn handler(event_name: HookEventName) -> ConfiguredHandler {
|
|
ConfiguredHandler {
|
|
event_name,
|
|
matcher: None,
|
|
command: "python3 compact_hook.py".to_string(),
|
|
timeout_sec: 5,
|
|
status_message: Some("running compact hook".to_string()),
|
|
source_path: test_path_buf("/tmp/hooks.json").abs(),
|
|
source: codex_protocol::protocol::HookSource::User,
|
|
display_order: 0,
|
|
env: std::collections::HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn run_result(exit_code: Option<i32>, stdout: &str, stderr: &str) -> CommandRunResult {
|
|
CommandRunResult {
|
|
started_at: 1_700_000_000,
|
|
completed_at: 1_700_000_001,
|
|
duration_ms: 12,
|
|
exit_code,
|
|
stdout: stdout.to_string(),
|
|
stderr: stderr.to_string(),
|
|
error: None,
|
|
}
|
|
}
|
|
}
|