mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
af49d38373
# Why Compaction replaces the live conversation history, so hooks that use `SessionStart` to re-inject durable model context need a way to run again after that rewrite. Related - #19905 adds dedicated compact lifecycle hooks # What - add `compact` as a supported `SessionStart` source and matcher value - change pending `SessionStart` state from a single slot to a small FIFO queue so `resume` / `startup` / `clear` can be preserved alongside a later `compact` - drain all queued `SessionStart` sources before the next model request, preserving their original order # Testing The new integration coverage verifies both the basic `compact` matcher path and the stacked `resume` -> `compact` case where both hooks contribute `additionalContext` to the next model turn.
541 lines
18 KiB
Rust
541 lines
18 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::NullableString;
|
|
use crate::schema::SessionStartCommandInput;
|
|
use crate::schema::SubagentStartCommandInput;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum SessionStartSource {
|
|
Startup,
|
|
Resume,
|
|
Clear,
|
|
Compact,
|
|
}
|
|
|
|
impl SessionStartSource {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Startup => "startup",
|
|
Self::Resume => "resume",
|
|
Self::Clear => "clear",
|
|
Self::Compact => "compact",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SessionStartRequest {
|
|
pub session_id: ThreadId,
|
|
pub cwd: AbsolutePathBuf,
|
|
pub transcript_path: Option<PathBuf>,
|
|
pub model: String,
|
|
pub permission_mode: String,
|
|
pub target: StartHookTarget,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum StartHookTarget {
|
|
SessionStart {
|
|
source: SessionStartSource,
|
|
},
|
|
SubagentStart {
|
|
turn_id: String,
|
|
agent_id: String,
|
|
agent_type: String,
|
|
},
|
|
}
|
|
|
|
impl StartHookTarget {
|
|
fn event_name(&self) -> HookEventName {
|
|
match self {
|
|
Self::SessionStart { .. } => HookEventName::SessionStart,
|
|
Self::SubagentStart { .. } => HookEventName::SubagentStart,
|
|
}
|
|
}
|
|
|
|
fn matcher_input(&self) -> &str {
|
|
match self {
|
|
Self::SessionStart { source } => source.as_str(),
|
|
Self::SubagentStart { agent_type, .. } => agent_type.as_str(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct SessionStartOutcome {
|
|
pub hook_events: Vec<HookCompletedEvent>,
|
|
pub should_stop: bool,
|
|
pub stop_reason: Option<String>,
|
|
pub additional_contexts: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct SessionStartHandlerData {
|
|
should_stop: bool,
|
|
stop_reason: Option<String>,
|
|
additional_contexts_for_model: Vec<String>,
|
|
}
|
|
|
|
pub(crate) fn preview(
|
|
handlers: &[ConfiguredHandler],
|
|
request: &SessionStartRequest,
|
|
) -> Vec<HookRunSummary> {
|
|
dispatcher::select_handlers(
|
|
handlers,
|
|
request.target.event_name(),
|
|
Some(request.target.matcher_input()),
|
|
)
|
|
.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,
|
|
request.target.event_name(),
|
|
Some(request.target.matcher_input()),
|
|
);
|
|
if matched.is_empty() {
|
|
return SessionStartOutcome {
|
|
hook_events: Vec::new(),
|
|
should_stop: false,
|
|
stop_reason: None,
|
|
additional_contexts: Vec::new(),
|
|
};
|
|
}
|
|
|
|
let (input_json, turn_id) = match request.target {
|
|
StartHookTarget::SessionStart { source } => {
|
|
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(),
|
|
source.as_str().to_string(),
|
|
)) {
|
|
Ok(input_json) => input_json,
|
|
Err(error) => {
|
|
return serialization_failure_outcome(
|
|
common::serialization_failure_hook_events(
|
|
matched,
|
|
turn_id,
|
|
format!("failed to serialize session start hook input: {error}"),
|
|
),
|
|
);
|
|
}
|
|
};
|
|
(input_json, turn_id)
|
|
}
|
|
StartHookTarget::SubagentStart {
|
|
turn_id: subagent_turn_id,
|
|
agent_id,
|
|
agent_type,
|
|
} => {
|
|
let input = SubagentStartCommandInput {
|
|
session_id: request.session_id.to_string(),
|
|
turn_id: subagent_turn_id.clone(),
|
|
transcript_path: NullableString::from_path(request.transcript_path.clone()),
|
|
cwd: request.cwd.display().to_string(),
|
|
hook_event_name: "SubagentStart".to_string(),
|
|
model: request.model.clone(),
|
|
permission_mode: request.permission_mode.clone(),
|
|
agent_id,
|
|
agent_type,
|
|
};
|
|
let input_json = match serde_json::to_string(&input) {
|
|
Ok(input_json) => input_json,
|
|
Err(error) => {
|
|
return serialization_failure_outcome(
|
|
common::serialization_failure_hook_events(
|
|
matched,
|
|
Some(subagent_turn_id),
|
|
format!("failed to serialize subagent start hook input: {error}"),
|
|
),
|
|
);
|
|
}
|
|
};
|
|
(input_json, Some(subagent_turn_id))
|
|
}
|
|
};
|
|
|
|
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 = 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_contexts,
|
|
}
|
|
}
|
|
|
|
/// Interprets completed `SessionStart` and `SubagentStart` hook runs.
|
|
///
|
|
/// The two events have different input payloads but share most output
|
|
/// handling: hook JSON can emit warnings/context, invalid JSON-looking stdout
|
|
/// fails, and plain stdout becomes model context. Only `SessionStart` honors
|
|
/// `continue:false`; `SubagentStart` stays context-injection-only.
|
|
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_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) = match handler.event_name {
|
|
HookEventName::SessionStart => {
|
|
output_parser::parse_session_start(&run_result.stdout)
|
|
}
|
|
HookEventName::SubagentStart => {
|
|
output_parser::parse_subagent_start(&run_result.stdout)
|
|
}
|
|
event_name => {
|
|
panic!("expected start hook event, got {event_name:?}")
|
|
}
|
|
} {
|
|
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 {
|
|
common::append_additional_context(
|
|
&mut entries,
|
|
&mut additional_contexts_for_model,
|
|
additional_context,
|
|
);
|
|
}
|
|
let _ = parsed.universal.suppress_output;
|
|
if handler.event_name == HookEventName::SessionStart
|
|
&& !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 output_parser::looks_like_json(&run_result.stdout) {
|
|
status = HookRunStatus::Failed;
|
|
entries.push(HookOutputEntry {
|
|
kind: HookOutputEntryKind::Error,
|
|
text: match handler.event_name {
|
|
HookEventName::SessionStart => {
|
|
"hook returned invalid session start JSON output"
|
|
}
|
|
HookEventName::SubagentStart => {
|
|
"hook returned invalid subagent start JSON output"
|
|
}
|
|
event_name => {
|
|
panic!("expected start hook event, got {event_name:?}")
|
|
}
|
|
}
|
|
.to_string(),
|
|
});
|
|
} else {
|
|
let additional_context = trimmed_stdout.to_string();
|
|
common::append_additional_context(
|
|
&mut entries,
|
|
&mut additional_contexts_for_model,
|
|
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_contexts_for_model,
|
|
},
|
|
completion_order: 0,
|
|
}
|
|
}
|
|
|
|
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> SessionStartOutcome {
|
|
SessionStartOutcome {
|
|
hook_events,
|
|
should_stop: false,
|
|
stop_reason: None,
|
|
additional_contexts: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
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 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", ""),
|
|
/*turn_id*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
parsed.data,
|
|
SessionStartHandlerData {
|
|
should_stop: false,
|
|
stop_reason: None,
|
|
additional_contexts_for_model: vec!["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_preserves_context_for_later_turns() {
|
|
let parsed = parse_completed(
|
|
&handler(),
|
|
run_result(
|
|
Some(0),
|
|
r#"{"continue":false,"stopReason":"pause","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"do not inject"}}"#,
|
|
"",
|
|
),
|
|
/*turn_id*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
parsed.data,
|
|
SessionStartHandlerData {
|
|
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 invalid_json_like_stdout_fails_instead_of_becoming_model_context() {
|
|
let parsed = parse_completed(
|
|
&handler(),
|
|
run_result(
|
|
Some(0),
|
|
r#"{"hookSpecificOutput":{"hookEventName":"SessionStart""#,
|
|
"",
|
|
),
|
|
/*turn_id*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
parsed.data,
|
|
SessionStartHandlerData {
|
|
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: "hook returned invalid session start JSON output".to_string(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_start_plain_stdout_becomes_model_context() {
|
|
let parsed = parse_completed(
|
|
&handler_for(HookEventName::SubagentStart),
|
|
run_result(Some(0), "hello from subagent hook\n", ""),
|
|
/*turn_id*/ Some("turn-1".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
parsed.data,
|
|
SessionStartHandlerData {
|
|
should_stop: false,
|
|
stop_reason: None,
|
|
additional_contexts_for_model: vec!["hello from subagent hook".to_string()],
|
|
}
|
|
);
|
|
assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1"));
|
|
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
|
|
assert_eq!(
|
|
parsed.completed.run.entries,
|
|
vec![HookOutputEntry {
|
|
kind: HookOutputEntryKind::Context,
|
|
text: "hello from subagent hook".to_string(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subagent_start_continue_false_is_ignored() {
|
|
let parsed = parse_completed(
|
|
&handler_for(HookEventName::SubagentStart),
|
|
run_result(
|
|
Some(0),
|
|
r#"{"continue":false,"stopReason":"skip child","hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child context"}}"#,
|
|
"",
|
|
),
|
|
/*turn_id*/ Some("turn-1".to_string()),
|
|
);
|
|
|
|
assert_eq!(
|
|
parsed.data,
|
|
SessionStartHandlerData {
|
|
should_stop: false,
|
|
stop_reason: None,
|
|
additional_contexts_for_model: vec!["child context".to_string()],
|
|
}
|
|
);
|
|
assert_eq!(parsed.completed.turn_id.as_deref(), Some("turn-1"));
|
|
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
|
|
assert_eq!(
|
|
parsed.completed.run.entries,
|
|
vec![HookOutputEntry {
|
|
kind: HookOutputEntryKind::Context,
|
|
text: "child context".to_string(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
fn handler() -> ConfiguredHandler {
|
|
handler_for(HookEventName::SessionStart)
|
|
}
|
|
|
|
fn handler_for(event_name: HookEventName) -> ConfiguredHandler {
|
|
ConfiguredHandler {
|
|
event_name,
|
|
matcher: None,
|
|
command: "echo hook".to_string(),
|
|
timeout_sec: 600,
|
|
status_message: None,
|
|
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,
|
|
completed_at: 2,
|
|
duration_ms: 1,
|
|
exit_code,
|
|
stdout: stdout.to_string(),
|
|
stderr: stderr.to_string(),
|
|
error: None,
|
|
}
|
|
}
|
|
}
|