mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
Add compact lifecycle hooks (started by vincentkoc - external contrib) (#19905)
Based on work from Vincent K - https://github.com/openai/codex/pull/19060 <img width="1836" height="642" alt="CleanShot 2026-04-29 at 20 47 40@2x" src="https://github.com/user-attachments/assets/b647bb89-65fe-40c8-80b0-7a6b7c984634" /> ## Why Compaction rewrites the conversation context that future model turns receive, but hooks currently have no deterministic lifecycle point around that rewrite. This adds compact lifecycle hooks so users can audit manual and automatic compaction, surface hook messages in the UI, and run post-compaction follow-up without overloading tool or prompt hooks. ## What Changed - Added `PreCompact` and `PostCompact` hook events across hook config, discovery, dispatch, generated schemas, app-server notifications, analytics, and TUI hook rendering. - Added trigger matching for compact hooks with the documented `manual` and `auto` matcher values. - Wired `PreCompact` before both local and remote compaction, and `PostCompact` after successful local or remote compaction. - Kept compact hook command input to lifecycle metadata: session id, Codex turn id, transcript path, cwd, hook event name, model, and trigger. - Made compact stdout handling consistent with other hooks: plain stdout is ignored as debug output, while malformed JSON-looking stdout is reported as failed hook output. - Added integration coverage for compact hook dispatch, trigger matching, post-compact execution, and the audited behavior that `decision:"block"` does not block compaction. ## Out of Scope - Hook-specific compaction blocking is not implemented; `decision:"block"` and exit-code-2 blocking semantics are intentionally unsupported for `PreCompact`. - Custom compaction instructions are not exposed to compact hooks in this PR. - Compact summaries, summary character counts, and summary previews are not exposed to compact hooks in this PR. ## Verification - `cargo test -p codex-hooks` - `cargo test -p codex-core manual_pre_compact_block_decision_does_not_block_compaction` - `cargo test -p codex-app-server hooks_list` - `cargo test -p codex-core config_schema_matches_fixture` - `cargo test -p codex-tui hooks_browser` ## Docs The developer documentation for Codex hooks should be updated alongside this feature to document `PreCompact` and `PostCompact`, the `manual`/`auto` matcher values, and the compact hook payload fields. --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop"
|
||||
|
||||
52
codex-rs/hooks/schema/generated/post-compact.command.input.schema.json
generated
Normal file
52
codex-rs/hooks/schema/generated/post-compact.command.input.schema.json
generated
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"NullableString": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"hook_event_name": {
|
||||
"const": "PostCompact",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"transcript_path": {
|
||||
"$ref": "#/definitions/NullableString"
|
||||
},
|
||||
"trigger": {
|
||||
"enum": [
|
||||
"manual",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cwd",
|
||||
"hook_event_name",
|
||||
"model",
|
||||
"session_id",
|
||||
"transcript_path",
|
||||
"trigger",
|
||||
"turn_id"
|
||||
],
|
||||
"title": "post-compact.command.input",
|
||||
"type": "object"
|
||||
}
|
||||
24
codex-rs/hooks/schema/generated/post-compact.command.output.schema.json
generated
Normal file
24
codex-rs/hooks/schema/generated/post-compact.command.output.schema.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"continue": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"stopReason": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"suppressOutput": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"systemMessage": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "post-compact.command.output",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop"
|
||||
|
||||
52
codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json
generated
Normal file
52
codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json
generated
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"NullableString": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"hook_event_name": {
|
||||
"const": "PreCompact",
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"transcript_path": {
|
||||
"$ref": "#/definitions/NullableString"
|
||||
},
|
||||
"trigger": {
|
||||
"enum": [
|
||||
"manual",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"turn_id": {
|
||||
"description": "Codex extension: expose the active turn id to internal turn-scoped hooks.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"cwd",
|
||||
"hook_event_name",
|
||||
"model",
|
||||
"session_id",
|
||||
"transcript_path",
|
||||
"trigger",
|
||||
"turn_id"
|
||||
],
|
||||
"title": "pre-compact.command.input",
|
||||
"type": "object"
|
||||
}
|
||||
24
codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json
generated
Normal file
24
codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"continue": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"stopReason": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"suppressOutput": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"systemMessage": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "pre-compact.command.output",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop"
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop"
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop"
|
||||
|
||||
@@ -511,6 +511,8 @@ fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) ->
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use",
|
||||
codex_protocol::protocol::HookEventName::PermissionRequest => "permission_request",
|
||||
codex_protocol::protocol::HookEventName::PostToolUse => "post_tool_use",
|
||||
codex_protocol::protocol::HookEventName::PreCompact => "pre_compact",
|
||||
codex_protocol::protocol::HookEventName::PostCompact => "post_compact",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session_start",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user_prompt_submit",
|
||||
codex_protocol::protocol::HookEventName::Stop => "stop",
|
||||
|
||||
@@ -46,7 +46,9 @@ pub(crate) fn select_handlers_for_matcher_inputs(
|
||||
HookEventName::PreToolUse
|
||||
| HookEventName::PermissionRequest
|
||||
| HookEventName::PostToolUse
|
||||
| HookEventName::SessionStart => {
|
||||
| HookEventName::SessionStart
|
||||
| HookEventName::PreCompact
|
||||
| HookEventName::PostCompact => {
|
||||
if matcher_inputs.is_empty() {
|
||||
matches_matcher(handler.matcher.as_deref(), /*input*/ None)
|
||||
} else {
|
||||
@@ -132,6 +134,8 @@ fn scope_for_event(event_name: HookEventName) -> HookScope {
|
||||
HookEventName::PreToolUse
|
||||
| HookEventName::PermissionRequest
|
||||
| HookEventName::PostToolUse
|
||||
| HookEventName::PreCompact
|
||||
| HookEventName::PostCompact
|
||||
| HookEventName::UserPromptSubmit
|
||||
| HookEventName::Stop => HookScope::Turn,
|
||||
}
|
||||
@@ -215,6 +219,29 @@ mod tests {
|
||||
assert_eq!(selected[1].display_order, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_hooks_match_trigger() {
|
||||
let handlers = vec![
|
||||
make_handler(
|
||||
HookEventName::PreCompact,
|
||||
Some("manual"),
|
||||
"echo manual",
|
||||
/*display_order*/ 0,
|
||||
),
|
||||
make_handler(
|
||||
HookEventName::PreCompact,
|
||||
Some("auto"),
|
||||
"echo auto",
|
||||
/*display_order*/ 1,
|
||||
),
|
||||
];
|
||||
|
||||
let selected = select_handlers(&handlers, HookEventName::PreCompact, Some("manual"));
|
||||
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].display_order, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_tool_use_matches_tool_name() {
|
||||
let handlers = vec![
|
||||
|
||||
@@ -6,6 +6,10 @@ pub(crate) mod schema_loader;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::events::compact::PostCompactRequest;
|
||||
use crate::events::compact::PreCompactOutcome;
|
||||
use crate::events::compact::PreCompactRequest;
|
||||
use crate::events::compact::StatelessHookOutcome;
|
||||
use crate::events::permission_request::PermissionRequestOutcome;
|
||||
use crate::events::permission_request::PermissionRequestRequest;
|
||||
use crate::events::post_tool_use::PostToolUseOutcome;
|
||||
@@ -63,6 +67,8 @@ impl ConfiguredHandler {
|
||||
codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use",
|
||||
codex_protocol::protocol::HookEventName::PermissionRequest => "permission-request",
|
||||
codex_protocol::protocol::HookEventName::PostToolUse => "post-tool-use",
|
||||
codex_protocol::protocol::HookEventName::PreCompact => "pre-compact",
|
||||
codex_protocol::protocol::HookEventName::PostCompact => "post-compact",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
|
||||
codex_protocol::protocol::HookEventName::Stop => "stop",
|
||||
@@ -198,6 +204,25 @@ impl ClaudeHooksEngine {
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) fn preview_pre_compact(&self, request: &PreCompactRequest) -> Vec<HookRunSummary> {
|
||||
crate::events::compact::preview_pre(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_pre_compact(&self, request: PreCompactRequest) -> PreCompactOutcome {
|
||||
crate::events::compact::run_pre(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
|
||||
pub(crate) fn preview_post_compact(&self, request: &PostCompactRequest) -> Vec<HookRunSummary> {
|
||||
crate::events::compact::preview_post(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_post_compact(
|
||||
&self,
|
||||
request: PostCompactRequest,
|
||||
) -> StatelessHookOutcome {
|
||||
crate::events::compact::run_post(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
|
||||
pub(crate) fn preview_user_prompt_submit(
|
||||
&self,
|
||||
request: &UserPromptSubmitRequest,
|
||||
|
||||
@@ -60,12 +60,26 @@ pub(crate) struct StopOutput {
|
||||
pub invalid_block_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PreCompactOutput {
|
||||
pub universal: UniversalOutput,
|
||||
pub invalid_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct StatelessHookOutput {
|
||||
pub universal: UniversalOutput,
|
||||
pub invalid_reason: Option<String>,
|
||||
}
|
||||
|
||||
use crate::schema::BlockDecisionWire;
|
||||
use crate::schema::HookUniversalOutputWire;
|
||||
use crate::schema::PermissionRequestBehaviorWire;
|
||||
use crate::schema::PermissionRequestCommandOutputWire;
|
||||
use crate::schema::PermissionRequestDecisionWire;
|
||||
use crate::schema::PostCompactCommandOutputWire;
|
||||
use crate::schema::PostToolUseCommandOutputWire;
|
||||
use crate::schema::PreCompactCommandOutputWire;
|
||||
use crate::schema::PreToolUseCommandOutputWire;
|
||||
use crate::schema::PreToolUseDecisionWire;
|
||||
use crate::schema::PreToolUsePermissionDecisionWire;
|
||||
@@ -191,6 +205,24 @@ pub(crate) fn parse_post_tool_use(stdout: &str) -> Option<PostToolUseOutput> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_pre_compact(stdout: &str) -> Option<PreCompactOutput> {
|
||||
let wire: PreCompactCommandOutputWire = parse_json(stdout)?;
|
||||
let universal = UniversalOutput::from(wire.universal);
|
||||
Some(PreCompactOutput {
|
||||
universal,
|
||||
invalid_reason: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_post_compact(stdout: &str) -> Option<StatelessHookOutput> {
|
||||
let wire: PostCompactCommandOutputWire = parse_json(stdout)?;
|
||||
let universal = UniversalOutput::from(wire.universal);
|
||||
Some(StatelessHookOutput {
|
||||
universal,
|
||||
invalid_reason: None,
|
||||
})
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -261,6 +293,11 @@ where
|
||||
serde_json::from_value(value).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn looks_like_json(stdout: &str) -> bool {
|
||||
let trimmed = stdout.trim_start();
|
||||
trimmed.starts_with('{') || trimmed.starts_with('[')
|
||||
}
|
||||
|
||||
fn invalid_block_message(event_name: &str) -> String {
|
||||
format!("{event_name} hook returned decision:block without a non-empty reason")
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@ pub(crate) struct GeneratedHookSchemas {
|
||||
pub post_tool_use_command_output: Value,
|
||||
pub permission_request_command_input: Value,
|
||||
pub permission_request_command_output: Value,
|
||||
pub post_compact_command_input: Value,
|
||||
pub post_compact_command_output: Value,
|
||||
pub pre_tool_use_command_input: Value,
|
||||
pub pre_tool_use_command_output: Value,
|
||||
pub pre_compact_command_input: Value,
|
||||
pub pre_compact_command_output: Value,
|
||||
pub session_start_command_input: Value,
|
||||
pub session_start_command_output: Value,
|
||||
pub user_prompt_submit_command_input: Value,
|
||||
@@ -37,6 +41,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
|
||||
"permission-request.command.output",
|
||||
include_str!("../../schema/generated/permission-request.command.output.schema.json"),
|
||||
),
|
||||
post_compact_command_input: parse_json_schema(
|
||||
"post-compact.command.input",
|
||||
include_str!("../../schema/generated/post-compact.command.input.schema.json"),
|
||||
),
|
||||
post_compact_command_output: parse_json_schema(
|
||||
"post-compact.command.output",
|
||||
include_str!("../../schema/generated/post-compact.command.output.schema.json"),
|
||||
),
|
||||
pre_tool_use_command_input: parse_json_schema(
|
||||
"pre-tool-use.command.input",
|
||||
include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"),
|
||||
@@ -45,6 +57,14 @@ pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
|
||||
"pre-tool-use.command.output",
|
||||
include_str!("../../schema/generated/pre-tool-use.command.output.schema.json"),
|
||||
),
|
||||
pre_compact_command_input: parse_json_schema(
|
||||
"pre-compact.command.input",
|
||||
include_str!("../../schema/generated/pre-compact.command.input.schema.json"),
|
||||
),
|
||||
pre_compact_command_output: parse_json_schema(
|
||||
"pre-compact.command.output",
|
||||
include_str!("../../schema/generated/pre-compact.command.output.schema.json"),
|
||||
),
|
||||
session_start_command_input: parse_json_schema(
|
||||
"session-start.command.input",
|
||||
include_str!("../../schema/generated/session-start.command.input.schema.json"),
|
||||
@@ -90,8 +110,12 @@ mod tests {
|
||||
assert_eq!(schemas.post_tool_use_command_output["type"], "object");
|
||||
assert_eq!(schemas.permission_request_command_input["type"], "object");
|
||||
assert_eq!(schemas.permission_request_command_output["type"], "object");
|
||||
assert_eq!(schemas.post_compact_command_input["type"], "object");
|
||||
assert_eq!(schemas.post_compact_command_output["type"], "object");
|
||||
assert_eq!(schemas.pre_tool_use_command_input["type"], "object");
|
||||
assert_eq!(schemas.pre_tool_use_command_output["type"], "object");
|
||||
assert_eq!(schemas.pre_compact_command_input["type"], "object");
|
||||
assert_eq!(schemas.pre_compact_command_output["type"], "object");
|
||||
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");
|
||||
|
||||
@@ -103,7 +103,9 @@ pub(crate) fn matcher_pattern_for_event(
|
||||
HookEventName::PreToolUse
|
||||
| HookEventName::PermissionRequest
|
||||
| HookEventName::PostToolUse
|
||||
| HookEventName::SessionStart => matcher,
|
||||
| HookEventName::SessionStart
|
||||
| HookEventName::PreCompact
|
||||
| HookEventName::PostCompact => matcher,
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
|
||||
}
|
||||
}
|
||||
@@ -267,5 +269,13 @@ mod tests {
|
||||
matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")),
|
||||
Some("startup|resume")
|
||||
);
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::PreCompact, Some("^auto$")),
|
||||
Some("^auto$")
|
||||
);
|
||||
assert_eq!(
|
||||
matcher_pattern_for_event(HookEventName::PostCompact, Some("manual|auto")),
|
||||
Some("manual|auto")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
608
codex-rs/hooks/src/events/compact.rs
Normal file
608
codex-rs/hooks/src/events/compact.rs
Normal file
@@ -0,0 +1,608 @@
|
||||
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;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreCompactRequest {
|
||||
pub session_id: ThreadId,
|
||||
pub turn_id: String,
|
||||
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 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> {
|
||||
serde_json::to_string(&PreCompactCommandInput {
|
||||
session_id: request.session_id.to_string(),
|
||||
turn_id: request.turn_id.clone(),
|
||||
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> {
|
||||
serde_json::to_string(&PostCompactCommandInput {
|
||||
session_id: request.session_id.to_string(),
|
||||
turn_id: request.turn_id.clone(),
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[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(),
|
||||
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(),
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod common;
|
||||
pub mod compact;
|
||||
pub mod permission_request;
|
||||
pub mod post_tool_use;
|
||||
pub mod pre_tool_use;
|
||||
|
||||
@@ -232,7 +232,7 @@ fn parse_completed(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
|
||||
} else if output_parser::looks_like_json(&run_result.stdout) {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
|
||||
@@ -245,7 +245,7 @@ fn parse_completed(
|
||||
feedback_messages_for_model.push(reason);
|
||||
}
|
||||
}
|
||||
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
|
||||
} else if output_parser::looks_like_json(&run_result.stdout) {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
|
||||
@@ -205,7 +205,7 @@ fn parse_completed(
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
|
||||
} else if output_parser::looks_like_json(&run_result.stdout) {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
|
||||
@@ -190,7 +190,7 @@ fn parse_completed(
|
||||
}
|
||||
}
|
||||
// Preserve plain-text context support without treating malformed JSON as context.
|
||||
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
|
||||
} else if output_parser::looks_like_json(&run_result.stdout) {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
|
||||
@@ -194,7 +194,7 @@ fn parse_completed(
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if trimmed_stdout.starts_with('{') || trimmed_stdout.starts_with('[') {
|
||||
} else if output_parser::looks_like_json(&run_result.stdout) {
|
||||
status = HookRunStatus::Failed;
|
||||
entries.push(HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Error,
|
||||
|
||||
@@ -9,10 +9,12 @@ mod types;
|
||||
|
||||
pub use engine::HookListEntry;
|
||||
/// Hook event names as they appear in hooks JSON and config files.
|
||||
pub const HOOK_EVENT_NAMES: [&str; 6] = [
|
||||
pub const HOOK_EVENT_NAMES: [&str; 8] = [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
@@ -21,14 +23,21 @@ pub const HOOK_EVENT_NAMES: [&str; 6] = [
|
||||
/// Hook event names whose matcher fields are meaningful during dispatch.
|
||||
///
|
||||
/// Other events can appear in hooks JSON, but Codex ignores their matcher
|
||||
/// fields because those events do not dispatch against a tool or session-start
|
||||
/// source.
|
||||
pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 4] = [
|
||||
/// fields because those events do not dispatch against a tool, compaction
|
||||
/// trigger, or session-start source.
|
||||
pub const HOOK_EVENT_NAMES_WITH_MATCHERS: [&str; 6] = [
|
||||
"PreToolUse",
|
||||
"PermissionRequest",
|
||||
"PostToolUse",
|
||||
"PreCompact",
|
||||
"PostCompact",
|
||||
"SessionStart",
|
||||
];
|
||||
|
||||
pub use events::compact::PostCompactRequest;
|
||||
pub use events::compact::PreCompactOutcome;
|
||||
pub use events::compact::PreCompactRequest;
|
||||
pub use events::compact::StatelessHookOutcome;
|
||||
pub use events::permission_request::PermissionRequestDecision;
|
||||
pub use events::permission_request::PermissionRequestOutcome;
|
||||
pub use events::permission_request::PermissionRequestRequest;
|
||||
|
||||
@@ -5,6 +5,10 @@ use tokio::process::Command;
|
||||
use crate::engine::ClaudeHooksEngine;
|
||||
use crate::engine::CommandShell;
|
||||
use crate::engine::HookListEntry;
|
||||
use crate::events::compact::PostCompactRequest;
|
||||
use crate::events::compact::PreCompactOutcome;
|
||||
use crate::events::compact::PreCompactRequest;
|
||||
use crate::events::compact::StatelessHookOutcome;
|
||||
use crate::events::permission_request::PermissionRequestOutcome;
|
||||
use crate::events::permission_request::PermissionRequestRequest;
|
||||
use crate::events::post_tool_use::PostToolUseOutcome;
|
||||
@@ -154,6 +158,28 @@ impl Hooks {
|
||||
self.engine.run_post_tool_use(request).await
|
||||
}
|
||||
|
||||
pub fn preview_pre_compact(
|
||||
&self,
|
||||
request: &PreCompactRequest,
|
||||
) -> Vec<codex_protocol::protocol::HookRunSummary> {
|
||||
self.engine.preview_pre_compact(request)
|
||||
}
|
||||
|
||||
pub async fn run_pre_compact(&self, request: PreCompactRequest) -> PreCompactOutcome {
|
||||
self.engine.run_pre_compact(request).await
|
||||
}
|
||||
|
||||
pub fn preview_post_compact(
|
||||
&self,
|
||||
request: &PostCompactRequest,
|
||||
) -> Vec<codex_protocol::protocol::HookRunSummary> {
|
||||
self.engine.preview_post_compact(request)
|
||||
}
|
||||
|
||||
pub async fn run_post_compact(&self, request: PostCompactRequest) -> StatelessHookOutcome {
|
||||
self.engine.run_post_compact(request).await
|
||||
}
|
||||
|
||||
pub fn preview_user_prompt_submit(
|
||||
&self,
|
||||
request: &UserPromptSubmitRequest,
|
||||
|
||||
@@ -17,8 +17,12 @@ const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.js
|
||||
const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json";
|
||||
const PERMISSION_REQUEST_INPUT_FIXTURE: &str = "permission-request.command.input.schema.json";
|
||||
const PERMISSION_REQUEST_OUTPUT_FIXTURE: &str = "permission-request.command.output.schema.json";
|
||||
const POST_COMPACT_INPUT_FIXTURE: &str = "post-compact.command.input.schema.json";
|
||||
const POST_COMPACT_OUTPUT_FIXTURE: &str = "post-compact.command.output.schema.json";
|
||||
const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json";
|
||||
const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json";
|
||||
const PRE_COMPACT_INPUT_FIXTURE: &str = "pre-compact.command.input.schema.json";
|
||||
const PRE_COMPACT_OUTPUT_FIXTURE: &str = "pre-compact.command.output.schema.json";
|
||||
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";
|
||||
@@ -75,6 +79,10 @@ pub(crate) enum HookEventNameWire {
|
||||
PermissionRequest,
|
||||
#[serde(rename = "PostToolUse")]
|
||||
PostToolUse,
|
||||
#[serde(rename = "PreCompact")]
|
||||
PreCompact,
|
||||
#[serde(rename = "PostCompact")]
|
||||
PostCompact,
|
||||
#[serde(rename = "SessionStart")]
|
||||
SessionStart,
|
||||
#[serde(rename = "UserPromptSubmit")]
|
||||
@@ -124,6 +132,24 @@ pub(crate) struct PermissionRequestCommandOutputWire {
|
||||
pub hook_specific_output: Option<PermissionRequestHookSpecificOutputWire>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "pre-compact.command.output")]
|
||||
pub(crate) struct PreCompactCommandOutputWire {
|
||||
#[serde(flatten)]
|
||||
pub universal: HookUniversalOutputWire,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "post-compact.command.output")]
|
||||
pub(crate) struct PostCompactCommandOutputWire {
|
||||
#[serde(flatten)]
|
||||
pub universal: HookUniversalOutputWire,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -267,6 +293,38 @@ pub(crate) struct PostToolUseCommandInput {
|
||||
pub tool_use_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "pre-compact.command.input")]
|
||||
pub(crate) struct PreCompactCommandInput {
|
||||
pub session_id: String,
|
||||
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
|
||||
pub turn_id: String,
|
||||
pub transcript_path: NullableString,
|
||||
pub cwd: String,
|
||||
#[schemars(schema_with = "pre_compact_hook_event_name_schema")]
|
||||
pub hook_event_name: String,
|
||||
pub model: String,
|
||||
#[schemars(schema_with = "compaction_trigger_schema")]
|
||||
pub trigger: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(rename = "post-compact.command.input")]
|
||||
pub(crate) struct PostCompactCommandInput {
|
||||
pub session_id: String,
|
||||
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
|
||||
pub turn_id: String,
|
||||
pub transcript_path: NullableString,
|
||||
pub cwd: String,
|
||||
#[schemars(schema_with = "post_compact_hook_event_name_schema")]
|
||||
pub hook_event_name: String,
|
||||
pub model: String,
|
||||
#[schemars(schema_with = "compaction_trigger_schema")]
|
||||
pub trigger: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -424,6 +482,22 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
|
||||
&generated_dir.join(PERMISSION_REQUEST_OUTPUT_FIXTURE),
|
||||
schema_json::<PermissionRequestCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(POST_COMPACT_INPUT_FIXTURE),
|
||||
schema_json::<PostCompactCommandInput>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(POST_COMPACT_OUTPUT_FIXTURE),
|
||||
schema_json::<PostCompactCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(PRE_COMPACT_INPUT_FIXTURE),
|
||||
schema_json::<PreCompactCommandInput>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(PRE_COMPACT_OUTPUT_FIXTURE),
|
||||
schema_json::<PreCompactCommandOutputWire>()?,
|
||||
)?;
|
||||
write_schema(
|
||||
&generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
|
||||
schema_json::<PreToolUseCommandInput>()?,
|
||||
@@ -519,6 +593,14 @@ fn post_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("PostToolUse")
|
||||
}
|
||||
|
||||
fn pre_compact_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("PreCompact")
|
||||
}
|
||||
|
||||
fn post_compact_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("PostCompact")
|
||||
}
|
||||
|
||||
fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_const_schema("PreToolUse")
|
||||
}
|
||||
@@ -549,6 +631,10 @@ fn session_start_source_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_enum_schema(&["startup", "resume", "clear"])
|
||||
}
|
||||
|
||||
fn compaction_trigger_schema(_gen: &mut SchemaGenerator) -> Schema {
|
||||
string_enum_schema(&["manual", "auto"])
|
||||
}
|
||||
|
||||
fn string_const_schema(value: &str) -> Schema {
|
||||
let mut schema = SchemaObject {
|
||||
instance_type: Some(InstanceType::String.into()),
|
||||
@@ -580,12 +666,18 @@ fn default_continue() -> bool {
|
||||
mod tests {
|
||||
use super::PERMISSION_REQUEST_INPUT_FIXTURE;
|
||||
use super::PERMISSION_REQUEST_OUTPUT_FIXTURE;
|
||||
use super::POST_COMPACT_INPUT_FIXTURE;
|
||||
use super::POST_COMPACT_OUTPUT_FIXTURE;
|
||||
use super::POST_TOOL_USE_INPUT_FIXTURE;
|
||||
use super::POST_TOOL_USE_OUTPUT_FIXTURE;
|
||||
use super::PRE_COMPACT_INPUT_FIXTURE;
|
||||
use super::PRE_COMPACT_OUTPUT_FIXTURE;
|
||||
use super::PRE_TOOL_USE_INPUT_FIXTURE;
|
||||
use super::PRE_TOOL_USE_OUTPUT_FIXTURE;
|
||||
use super::PermissionRequestCommandInput;
|
||||
use super::PostCompactCommandInput;
|
||||
use super::PostToolUseCommandInput;
|
||||
use super::PreCompactCommandInput;
|
||||
use super::PreToolUseCommandInput;
|
||||
use super::SESSION_START_INPUT_FIXTURE;
|
||||
use super::SESSION_START_OUTPUT_FIXTURE;
|
||||
@@ -615,6 +707,18 @@ mod tests {
|
||||
PERMISSION_REQUEST_OUTPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/permission-request.command.output.schema.json")
|
||||
}
|
||||
POST_COMPACT_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/post-compact.command.input.schema.json")
|
||||
}
|
||||
POST_COMPACT_OUTPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/post-compact.command.output.schema.json")
|
||||
}
|
||||
PRE_COMPACT_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/pre-compact.command.input.schema.json")
|
||||
}
|
||||
PRE_COMPACT_OUTPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/pre-compact.command.output.schema.json")
|
||||
}
|
||||
PRE_TOOL_USE_INPUT_FIXTURE => {
|
||||
include_str!("../schema/generated/pre-tool-use.command.input.schema.json")
|
||||
}
|
||||
@@ -658,6 +762,10 @@ mod tests {
|
||||
POST_TOOL_USE_OUTPUT_FIXTURE,
|
||||
PERMISSION_REQUEST_INPUT_FIXTURE,
|
||||
PERMISSION_REQUEST_OUTPUT_FIXTURE,
|
||||
POST_COMPACT_INPUT_FIXTURE,
|
||||
POST_COMPACT_OUTPUT_FIXTURE,
|
||||
PRE_COMPACT_INPUT_FIXTURE,
|
||||
PRE_COMPACT_OUTPUT_FIXTURE,
|
||||
PRE_TOOL_USE_INPUT_FIXTURE,
|
||||
PRE_TOOL_USE_OUTPUT_FIXTURE,
|
||||
SESSION_START_INPUT_FIXTURE,
|
||||
@@ -688,6 +796,14 @@ mod tests {
|
||||
.expect("serialize post tool use input schema"),
|
||||
)
|
||||
.expect("parse post tool use input schema");
|
||||
let pre_compact: Value = serde_json::from_slice(
|
||||
&schema_json::<PreCompactCommandInput>().expect("serialize pre compact input schema"),
|
||||
)
|
||||
.expect("parse pre compact input schema");
|
||||
let post_compact: Value = serde_json::from_slice(
|
||||
&schema_json::<PostCompactCommandInput>().expect("serialize post compact input schema"),
|
||||
)
|
||||
.expect("parse post compact input schema");
|
||||
let permission_request: Value = serde_json::from_slice(
|
||||
&schema_json::<PermissionRequestCommandInput>()
|
||||
.expect("serialize permission request input schema"),
|
||||
@@ -707,6 +823,8 @@ mod tests {
|
||||
&pre_tool_use,
|
||||
&permission_request,
|
||||
&post_tool_use,
|
||||
&pre_compact,
|
||||
&post_compact,
|
||||
&user_prompt_submit,
|
||||
&stop,
|
||||
] {
|
||||
|
||||
Reference in New Issue
Block a user