From a44e6ea811cc85c80582bb2f3914360eb35ba5bd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 22 Apr 2026 17:53:26 -0700 Subject: [PATCH 1/3] feat(hooks): add compaction lifecycle hooks --- codex-rs/analytics/src/events.rs | 2 + .../schema/json/ServerNotification.json | 2 + .../codex_app_server_protocol.schemas.json | 2 + .../codex_app_server_protocol.v2.schemas.json | 2 + .../json/v2/HookCompletedNotification.json | 2 + .../json/v2/HookStartedNotification.json | 2 + .../schema/typescript/v2/HookEventName.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 2 +- codex-rs/config/src/hook_config.rs | 16 +- codex-rs/core/src/compact.rs | 32 +- codex-rs/core/src/compact_remote.rs | 32 +- codex-rs/core/src/hook_runtime.rs | 103 ++++ ...mission-request.command.output.schema.json | 2 + .../post-compact.command.input.schema.json | 102 ++++ .../post-compact.command.output.schema.json | 24 + .../post-tool-use.command.output.schema.json | 2 + .../pre-compact.command.input.schema.json | 89 ++++ .../pre-compact.command.output.schema.json | 24 + .../pre-tool-use.command.output.schema.json | 2 + .../session-start.command.output.schema.json | 2 + ...r-prompt-submit.command.output.schema.json | 2 + codex-rs/hooks/src/engine/dispatcher.rs | 7 +- codex-rs/hooks/src/engine/mod.rs | 24 + codex-rs/hooks/src/engine/output_parser.rs | 47 ++ codex-rs/hooks/src/engine/schema_loader.rs | 24 + codex-rs/hooks/src/events/common.rs | 13 +- codex-rs/hooks/src/events/compact.rs | 450 ++++++++++++++++++ codex-rs/hooks/src/events/mod.rs | 1 + codex-rs/hooks/src/lib.rs | 3 + codex-rs/hooks/src/registry.rs | 25 + codex-rs/hooks/src/schema.rs | 153 ++++++ codex-rs/protocol/src/protocol.rs | 2 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + codex-rs/tui/src/history_cell/hook_cell.rs | 2 + 34 files changed, 1182 insertions(+), 19 deletions(-) create mode 100644 codex-rs/hooks/schema/generated/post-compact.command.input.schema.json create mode 100644 codex-rs/hooks/schema/generated/post-compact.command.output.schema.json create mode 100644 codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json create mode 100644 codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json create mode 100644 codex-rs/hooks/src/events/compact.rs diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 73f2886f2f4..26b0d372093 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -671,6 +671,8 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { HookEventName::PreToolUse => "PreToolUse", HookEventName::PermissionRequest => "PermissionRequest", HookEventName::PostToolUse => "PostToolUse", + HookEventName::PreCompact => "PreCompact", + HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::Stop => "Stop", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 59b3f5b45a0..1686cfc74d3 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1750,6 +1750,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 3eaf25b9b2d..5df8c0b52a3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9491,6 +9491,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b1f9935150c..6c8840a5d3c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6162,6 +6162,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index a4d378649b6..43d3862e5b3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -10,6 +10,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index ac77d6163f2..7b2b3a77acf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -10,6 +10,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index 28657d22821..91c2def7098 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 937678793a2..cc6ae7967fa 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -436,7 +436,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, Stop } ); diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 8a5c73d6b9b..fcfde93e962 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -20,6 +20,10 @@ pub struct HookEventsToml { pub permission_request: Vec, #[serde(rename = "PostToolUse", default)] pub post_tool_use: Vec, + #[serde(rename = "PreCompact", default)] + pub pre_compact: Vec, + #[serde(rename = "PostCompact", default)] + pub post_compact: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] @@ -34,6 +38,8 @@ impl HookEventsToml { pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -41,6 +47,8 @@ impl HookEventsToml { pre_tool_use.is_empty() && permission_request.is_empty() && post_tool_use.is_empty() + && pre_compact.is_empty() + && post_compact.is_empty() && session_start.is_empty() && user_prompt_submit.is_empty() && stop.is_empty() @@ -51,6 +59,8 @@ impl HookEventsToml { pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -59,6 +69,8 @@ impl HookEventsToml { pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -69,11 +81,13 @@ impl HookEventsToml { .sum() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 6] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 8] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), (HookEventName::PostToolUse, self.post_tool_use), + (HookEventName::PreCompact, self.pre_compact), + (HookEventName::PostCompact, self.post_compact), (HookEventName::SessionStart, self.session_start), (HookEventName::UserPromptSubmit, self.user_prompt_submit), (HookEventName::Stop, self.stop), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 4ae9e9fcdc2..52ca9f6de5c 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -4,6 +4,8 @@ use std::time::Instant; use crate::Prompt; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; +use crate::hook_runtime::run_post_compact_hooks; +use crate::hook_runtime::run_pre_compact_hooks; #[cfg(test)] use crate::session::PreviousTurnSettings; use crate::session::session::Session; @@ -132,6 +134,15 @@ async fn run_compact_task_inner( phase, ) .await; + run_pre_compact_hooks( + &sess, + &turn_context, + trigger, + reason, + phase, + CompactionImplementation::Responses, + ) + .await; let result = run_compact_task_inner_impl( Arc::clone(&sess), Arc::clone(&turn_context), @@ -139,13 +150,20 @@ async fn run_compact_task_inner( initial_context_injection, ) .await; - attempt - .track( - sess.as_ref(), - compaction_status_from_result(&result), - result.as_ref().err().map(ToString::to_string), - ) - .await; + let status = compaction_status_from_result(&result); + let error = result.as_ref().err().map(ToString::to_string); + run_post_compact_hooks( + &sess, + &turn_context, + trigger, + reason, + phase, + CompactionImplementation::Responses, + status, + error.clone(), + ) + .await; + attempt.track(sess.as_ref(), status, error).await; result } diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 962b3e67216..d5a702103ba 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -10,6 +10,8 @@ use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; use crate::context_manager::is_codex_generated_item; +use crate::hook_runtime::run_post_compact_hooks; +use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; @@ -91,15 +93,31 @@ async fn run_remote_compact_task_inner( phase, ) .await; + run_pre_compact_hooks( + sess, + turn_context, + trigger, + reason, + phase, + CompactionImplementation::ResponsesCompact, + ) + .await; let result = run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await; - attempt - .track( - sess.as_ref(), - compaction_status_from_result(&result), - result.as_ref().err().map(ToString::to_string), - ) - .await; + let status = compaction_status_from_result(&result); + let error = result.as_ref().err().map(ToString::to_string); + run_post_compact_hooks( + sess, + turn_context, + trigger, + reason, + phase, + CompactionImplementation::ResponsesCompact, + status, + error.clone(), + ) + .await; + attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index db47688685b..5395ea180a6 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -2,6 +2,11 @@ use std::future::Future; use std::sync::Arc; use std::time::Duration; +use codex_analytics::CompactionImplementation; +use codex_analytics::CompactionPhase; +use codex_analytics::CompactionReason; +use codex_analytics::CompactionStatus; +use codex_analytics::CompactionTrigger; use codex_analytics::HookRunFact; use codex_analytics::build_track_events_context; use codex_hooks::PermissionRequestDecision; @@ -250,6 +255,64 @@ pub(crate) async fn run_post_tool_use_hooks( outcome } +pub(crate) async fn run_pre_compact_hooks( + sess: &Arc, + turn_context: &Arc, + trigger: CompactionTrigger, + reason: CompactionReason, + phase: CompactionPhase, + implementation: CompactionImplementation, +) { + let request = codex_hooks::PreCompactRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.clone(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + trigger: compaction_trigger_label(trigger).to_string(), + reason: compaction_reason_label(reason).to_string(), + phase: compaction_phase_label(phase).to_string(), + implementation: compaction_implementation_label(implementation).to_string(), + }; + let preview_runs = sess.hooks().preview_pre_compact(&request); + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let outcome = sess.hooks().run_pre_compact(request).await; + emit_hook_completed_events(sess, turn_context, outcome.hook_events).await; +} + +pub(crate) async fn run_post_compact_hooks( + sess: &Arc, + turn_context: &Arc, + trigger: CompactionTrigger, + reason: CompactionReason, + phase: CompactionPhase, + implementation: CompactionImplementation, + status: CompactionStatus, + error: Option, +) { + let request = codex_hooks::PostCompactRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.clone(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + trigger: compaction_trigger_label(trigger).to_string(), + reason: compaction_reason_label(reason).to_string(), + phase: compaction_phase_label(phase).to_string(), + implementation: compaction_implementation_label(implementation).to_string(), + status: compaction_status_label(status).to_string(), + error, + }; + let preview_runs = sess.hooks().preview_post_compact(&request); + emit_hook_started_events(sess, turn_context, preview_runs).await; + + let outcome = sess.hooks().run_post_compact(request).await; + emit_hook_completed_events(sess, turn_context, outcome.hook_events).await; +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, @@ -463,6 +526,8 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); HookEventName::PreToolUse => "PreToolUse", HookEventName::PermissionRequest => "PermissionRequest", HookEventName::PostToolUse => "PostToolUse", + HookEventName::PreCompact => "PreCompact", + HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::Stop => "Stop", @@ -503,6 +568,44 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { .to_string() } +fn compaction_trigger_label(value: CompactionTrigger) -> &'static str { + match value { + CompactionTrigger::Manual => "manual", + CompactionTrigger::Auto => "auto", + } +} + +fn compaction_reason_label(value: CompactionReason) -> &'static str { + match value { + CompactionReason::UserRequested => "user_requested", + CompactionReason::ContextLimit => "context_limit", + CompactionReason::ModelDownshift => "model_downshift", + } +} + +fn compaction_implementation_label(value: CompactionImplementation) -> &'static str { + match value { + CompactionImplementation::Responses => "responses", + CompactionImplementation::ResponsesCompact => "responses_compact", + } +} + +fn compaction_phase_label(value: CompactionPhase) -> &'static str { + match value { + CompactionPhase::StandaloneTurn => "standalone_turn", + CompactionPhase::PreTurn => "pre_turn", + CompactionPhase::MidTurn => "mid_turn", + } +} + +fn compaction_status_label(value: CompactionStatus) -> &'static str { + match value { + CompactionStatus::Completed => "completed", + CompactionStatus::Failed => "failed", + CompactionStatus::Interrupted => "interrupted", + } +} + #[cfg(test)] mod tests { use codex_protocol::models::ContentItem; diff --git a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json index 21d45382b0e..c89f80a3b09 100644 --- a/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/permission-request.command.output.schema.json @@ -7,6 +7,8 @@ "PreToolUse", "PermissionRequest", "PostToolUse", + "PreCompact", + "PostCompact", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json b/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json new file mode 100644 index 00000000000..171208946c7 --- /dev/null +++ b/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json @@ -0,0 +1,102 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "NullableString": { + "type": [ + "string", + "null" + ] + } + }, + "properties": { + "cwd": { + "type": "string" + }, + "error": { + "$ref": "#/definitions/NullableString" + }, + "hook_event_name": { + "const": "PostCompact", + "type": "string" + }, + "implementation": { + "enum": [ + "responses", + "responses_compact" + ], + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "phase": { + "enum": [ + "standalone_turn", + "pre_turn", + "mid_turn" + ], + "type": "string" + }, + "reason": { + "enum": [ + "user_requested", + "context_limit", + "model_downshift" + ], + "type": "string" + }, + "session_id": { + "type": "string" + }, + "status": { + "enum": [ + "completed", + "failed", + "interrupted" + ], + "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", + "error", + "hook_event_name", + "implementation", + "model", + "permission_mode", + "phase", + "reason", + "session_id", + "status", + "transcript_path", + "trigger", + "turn_id" + ], + "title": "post-compact.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/post-compact.command.output.schema.json b/codex-rs/hooks/schema/generated/post-compact.command.output.schema.json new file mode 100644 index 00000000000..0221ac29b44 --- /dev/null +++ b/codex-rs/hooks/schema/generated/post-compact.command.output.schema.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json index 43a2a4828e3..2b64c0ad004 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json @@ -13,6 +13,8 @@ "PreToolUse", "PermissionRequest", "PostToolUse", + "PreCompact", + "PostCompact", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json b/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json new file mode 100644 index 00000000000..3837dd14d71 --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json @@ -0,0 +1,89 @@ +{ + "$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" + }, + "implementation": { + "enum": [ + "responses", + "responses_compact" + ], + "type": "string" + }, + "model": { + "type": "string" + }, + "permission_mode": { + "enum": [ + "default", + "acceptEdits", + "plan", + "dontAsk", + "bypassPermissions" + ], + "type": "string" + }, + "phase": { + "enum": [ + "standalone_turn", + "pre_turn", + "mid_turn" + ], + "type": "string" + }, + "reason": { + "enum": [ + "user_requested", + "context_limit", + "model_downshift" + ], + "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", + "implementation", + "model", + "permission_mode", + "phase", + "reason", + "session_id", + "transcript_path", + "trigger", + "turn_id" + ], + "title": "pre-compact.command.input", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json new file mode 100644 index 00000000000..644cd1a8b4a --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-compact.command.output.schema.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json index ba6bb3401b6..3ab17b9f5c1 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -7,6 +7,8 @@ "PreToolUse", "PermissionRequest", "PostToolUse", + "PreCompact", + "PostCompact", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index 30d2b0f9779..4d6ba033fed 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -7,6 +7,8 @@ "PreToolUse", "PermissionRequest", "PostToolUse", + "PreCompact", + "PostCompact", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index 26353d97db1..6baceb149f4 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -13,6 +13,8 @@ "PreToolUse", "PermissionRequest", "PostToolUse", + "PreCompact", + "PostCompact", "SessionStart", "UserPromptSubmit", "Stop" diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index d1cda96541a..7bec0528356 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -55,7 +55,10 @@ pub(crate) fn select_handlers_for_matcher_inputs( .any(|input| matches_matcher(handler.matcher.as_deref(), Some(input))) } } - HookEventName::UserPromptSubmit | HookEventName::Stop => true, + HookEventName::PreCompact + | HookEventName::PostCompact + | HookEventName::UserPromptSubmit + | HookEventName::Stop => true, }) .cloned() .collect() @@ -132,6 +135,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, } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 3bfb17f6d6f..f89498945a1 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -9,6 +9,9 @@ use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::AbsolutePathBuf; +use crate::events::compact::PostCompactRequest; +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; @@ -56,6 +59,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", @@ -148,6 +153,25 @@ impl ClaudeHooksEngine { crate::events::post_tool_use::run(&self.handlers, &self.shell, request).await } + pub(crate) fn preview_pre_compact(&self, request: &PreCompactRequest) -> Vec { + crate::events::compact::preview_pre(&self.handlers, request) + } + + pub(crate) async fn run_pre_compact(&self, request: PreCompactRequest) -> StatelessHookOutcome { + crate::events::compact::run_pre(&self.handlers, &self.shell, request).await + } + + pub(crate) fn preview_post_compact(&self, request: &PostCompactRequest) -> Vec { + 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, diff --git a/codex-rs/hooks/src/engine/output_parser.rs b/codex-rs/hooks/src/engine/output_parser.rs index 0a3a994e19d..03b77fa42e8 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -59,12 +59,20 @@ pub(crate) struct StopOutput { pub invalid_block_reason: Option, } +#[derive(Debug, Clone)] +pub(crate) struct StatelessHookOutput { + pub universal: UniversalOutput, + pub invalid_reason: Option, +} + 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; @@ -188,6 +196,26 @@ pub(crate) fn parse_post_tool_use(stdout: &str) -> Option { }) } +pub(crate) fn parse_pre_compact(stdout: &str) -> Option { + let wire: PreCompactCommandOutputWire = parse_json(stdout)?; + let universal = UniversalOutput::from(wire.universal); + let invalid_reason = unsupported_stateless_hook_universal("PreCompact", &universal); + Some(StatelessHookOutput { + universal, + invalid_reason, + }) +} + +pub(crate) fn parse_post_compact(stdout: &str) -> Option { + let wire: PostCompactCommandOutputWire = parse_json(stdout)?; + let universal = UniversalOutput::from(wire.universal); + let invalid_reason = unsupported_stateless_hook_universal("PostCompact", &universal); + Some(StatelessHookOutput { + universal, + invalid_reason, + }) +} + pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option { let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?; let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block)); @@ -294,6 +322,25 @@ fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option Option { + if !universal.continue_processing { + Some(format!( + "{event_name} hook returned unsupported continue:false" + )) + } else if universal.stop_reason.is_some() { + Some(format!("{event_name} hook returned unsupported stopReason")) + } else if universal.suppress_output { + Some(format!( + "{event_name} hook returned unsupported suppressOutput" + )) + } else { + None + } +} + fn unsupported_permission_request_hook_specific_output( decision: Option<&PermissionRequestDecisionWire>, ) -> Option { diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index d8bdb141c28..704edea4bac 100644 --- a/codex-rs/hooks/src/engine/schema_loader.rs +++ b/codex-rs/hooks/src/engine/schema_loader.rs @@ -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"); diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index de3f3292acd..5b97adaf5f5 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -104,7 +104,10 @@ pub(crate) fn matcher_pattern_for_event( | HookEventName::PermissionRequest | HookEventName::PostToolUse | HookEventName::SessionStart => matcher, - HookEventName::UserPromptSubmit | HookEventName::Stop => None, + HookEventName::PreCompact + | HookEventName::PostCompact + | HookEventName::UserPromptSubmit + | HookEventName::Stop => None, } } @@ -251,6 +254,14 @@ mod tests { matcher_pattern_for_event(HookEventName::Stop, Some("^done$")), None ); + assert_eq!( + matcher_pattern_for_event(HookEventName::PreCompact, Some("^auto$")), + None + ); + assert_eq!( + matcher_pattern_for_event(HookEventName::PostCompact, Some("^auto$")), + None + ); } #[test] diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs new file mode 100644 index 00000000000..fcd130fcfc4 --- /dev/null +++ b/codex-rs/hooks/src/events/compact.rs @@ -0,0 +1,450 @@ +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, + pub model: String, + pub permission_mode: String, + pub trigger: String, + pub reason: String, + pub phase: String, + pub implementation: String, +} + +#[derive(Debug, Clone)] +pub struct PostCompactRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: AbsolutePathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub trigger: String, + pub reason: String, + pub phase: String, + pub implementation: String, + pub status: String, + pub error: Option, +} + +#[derive(Debug)] +pub struct StatelessHookOutcome { + pub hook_events: Vec, +} + +pub(crate) fn preview_pre( + handlers: &[ConfiguredHandler], + _request: &PreCompactRequest, +) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::PreCompact, + /*matcher_input*/ None, + ) + .into_iter() + .map(|handler| dispatcher::running_summary(&handler)) + .collect() +} + +pub(crate) async fn run_pre( + handlers: &[ConfiguredHandler], + shell: &CommandShell, + request: PreCompactRequest, +) -> StatelessHookOutcome { + let matched = dispatcher::select_handlers( + handlers, + HookEventName::PreCompact, + /*matcher_input*/ None, + ); + if matched.is_empty() { + return StatelessHookOutcome { + hook_events: Vec::new(), + }; + } + + let input_json = match pre_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 pre compact hook input: {error}"), + ), + }; + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id), + parse_pre_completed, + ) + .await; + StatelessHookOutcome { + hook_events: results.into_iter().map(|result| result.completed).collect(), + } +} + +fn pre_command_input_json(request: &PreCompactRequest) -> Result { + 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(), + permission_mode: request.permission_mode.clone(), + trigger: request.trigger.clone(), + reason: request.reason.clone(), + phase: request.phase.clone(), + implementation: request.implementation.clone(), + }) +} + +pub(crate) fn preview_post( + handlers: &[ConfiguredHandler], + _request: &PostCompactRequest, +) -> Vec { + dispatcher::select_handlers( + handlers, + HookEventName::PostCompact, + /*matcher_input*/ None, + ) + .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, + /*matcher_input*/ None, + ); + if matched.is_empty() { + return StatelessHookOutcome { + hook_events: Vec::new(), + }; + } + + 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}"), + ), + }; + } + }; + + let results = dispatcher::execute_handlers( + shell, + matched, + input_json, + request.cwd.as_path(), + Some(request.turn_id), + parse_post_completed, + ) + .await; + StatelessHookOutcome { + hook_events: results.into_iter().map(|result| result.completed).collect(), + } +} + +fn post_command_input_json(request: &PostCompactRequest) -> Result { + 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(), + permission_mode: request.permission_mode.clone(), + trigger: request.trigger.clone(), + reason: request.reason.clone(), + phase: request.phase.clone(), + implementation: request.implementation.clone(), + status: request.status.clone(), + error: crate::schema::NullableString::from_string(request.error.clone()), + }) +} + +#[derive(Default)] +struct CompactHandlerData; + +fn parse_pre_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + parse_completed( + handler, + run_result, + turn_id, + "PreCompact", + output_parser::parse_pre_compact, + ) +} + +fn parse_post_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + parse_completed( + handler, + run_result, + turn_id, + "PostCompact", + output_parser::parse_post_compact, + ) +} + +fn parse_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, + event_label: &'static str, + parse_output: fn(&str) -> Option, +) -> dispatcher::ParsedHandler { + let mut entries = Vec::new(); + let mut status = HookRunStatus::Completed; + + 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, + }); + } + if let Some(invalid_reason) = parsed.invalid_reason { + status = HookRunStatus::Failed; + entries.push(HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: invalid_reason, + }); + } + } else { + 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, + } +} + +#[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_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", + "permission_mode": "default", + "trigger": "manual", + "reason": "user_requested", + "phase": "manual", + "implementation": "responses", + }) + ); + } + + #[test] + fn post_compact_input_includes_result_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", + "permission_mode": "default", + "trigger": "manual", + "reason": "user_requested", + "phase": "manual", + "implementation": "responses", + "status": "failed", + "error": "summary request failed", + }) + ); + } + + #[test] + fn stateless_output_cannot_stop_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::Failed); + assert_eq!( + parsed.completed.run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: "PreCompact hook returned unsupported continue:false".to_string(), + }] + ); + } + + 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(), + permission_mode: "default".to_string(), + trigger: "manual".to_string(), + reason: "user_requested".to_string(), + phase: "manual".to_string(), + implementation: "responses".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(), + permission_mode: "default".to_string(), + trigger: "manual".to_string(), + reason: "user_requested".to_string(), + phase: "manual".to_string(), + implementation: "responses".to_string(), + status: "failed".to_string(), + error: Some("summary request failed".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, + } + } + + fn run_result(exit_code: Option, 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, + } + } +} diff --git a/codex-rs/hooks/src/events/mod.rs b/codex-rs/hooks/src/events/mod.rs index 52148324e2f..5ec24462b93 100644 --- a/codex-rs/hooks/src/events/mod.rs +++ b/codex-rs/hooks/src/events/mod.rs @@ -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; diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index c8358c678c7..7e59a8bfa8e 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -5,6 +5,9 @@ mod registry; mod schema; mod types; +pub use events::compact::PostCompactRequest; +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; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 6f4e56b1bfa..44ca7827f03 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -3,6 +3,9 @@ use tokio::process::Command; use crate::engine::ClaudeHooksEngine; use crate::engine::CommandShell; +use crate::events::compact::PostCompactRequest; +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; @@ -142,6 +145,28 @@ impl Hooks { self.engine.run_post_tool_use(request).await } + pub fn preview_pre_compact( + &self, + request: &PreCompactRequest, + ) -> Vec { + self.engine.preview_pre_compact(request) + } + + pub async fn run_pre_compact(&self, request: PreCompactRequest) -> StatelessHookOutcome { + self.engine.run_pre_compact(request).await + } + + pub fn preview_post_compact( + &self, + request: &PostCompactRequest, + ) -> Vec { + 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, diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index d08cce6ee29..3ba04795f01 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -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, } +#[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,57 @@ 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 = "permission_mode_schema")] + pub permission_mode: String, + #[schemars(schema_with = "compaction_trigger_schema")] + pub trigger: String, + #[schemars(schema_with = "compaction_reason_schema")] + pub reason: String, + #[schemars(schema_with = "compaction_phase_schema")] + pub phase: String, + #[schemars(schema_with = "compaction_implementation_schema")] + pub implementation: 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 = "permission_mode_schema")] + pub permission_mode: String, + #[schemars(schema_with = "compaction_trigger_schema")] + pub trigger: String, + #[schemars(schema_with = "compaction_reason_schema")] + pub reason: String, + #[schemars(schema_with = "compaction_phase_schema")] + pub phase: String, + #[schemars(schema_with = "compaction_implementation_schema")] + pub implementation: String, + #[schemars(schema_with = "compaction_status_schema")] + pub status: String, + pub error: NullableString, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -424,6 +501,22 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { &generated_dir.join(PERMISSION_REQUEST_OUTPUT_FIXTURE), schema_json::()?, )?; + write_schema( + &generated_dir.join(POST_COMPACT_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(POST_COMPACT_OUTPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(PRE_COMPACT_INPUT_FIXTURE), + schema_json::()?, + )?; + write_schema( + &generated_dir.join(PRE_COMPACT_OUTPUT_FIXTURE), + schema_json::()?, + )?; write_schema( &generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE), schema_json::()?, @@ -519,6 +612,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 +650,26 @@ 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 compaction_reason_schema(_gen: &mut SchemaGenerator) -> Schema { + string_enum_schema(&["user_requested", "context_limit", "model_downshift"]) +} + +fn compaction_phase_schema(_gen: &mut SchemaGenerator) -> Schema { + string_enum_schema(&["standalone_turn", "pre_turn", "mid_turn"]) +} + +fn compaction_implementation_schema(_gen: &mut SchemaGenerator) -> Schema { + string_enum_schema(&["responses", "responses_compact"]) +} + +fn compaction_status_schema(_gen: &mut SchemaGenerator) -> Schema { + string_enum_schema(&["completed", "failed", "interrupted"]) +} + fn string_const_schema(value: &str) -> Schema { let mut schema = SchemaObject { instance_type: Some(InstanceType::String.into()), @@ -580,12 +701,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 +742,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 +797,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 +831,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::().expect("serialize pre compact input schema"), + ) + .expect("parse pre compact input schema"); + let post_compact: Value = serde_json::from_slice( + &schema_json::().expect("serialize post compact input schema"), + ) + .expect("parse post compact input schema"); let permission_request: Value = serde_json::from_slice( &schema_json::() .expect("serialize permission request input schema"), @@ -707,6 +858,8 @@ mod tests { &pre_tool_use, &permission_request, &post_tool_use, + &pre_compact, + &post_compact, &user_prompt_submit, &stop, ] { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e087cc02796..67c43c231bd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1733,6 +1733,8 @@ pub enum HookEventName { PreToolUse, PermissionRequest, PostToolUse, + PreCompact, + PostCompact, SessionStart, UserPromptSubmit, Stop, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 960ab992a00..1eea81f2bbc 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1090,6 +1090,8 @@ fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'st codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::PermissionRequest => "PermissionRequest", codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse", + codex_protocol::protocol::HookEventName::PreCompact => "PreCompact", + codex_protocol::protocol::HookEventName::PostCompact => "PostCompact", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index 138a17b5a8d..53da030f54b 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -703,6 +703,8 @@ fn hook_event_label(event_name: HookEventName) -> &'static str { HookEventName::PreToolUse => "PreToolUse", HookEventName::PermissionRequest => "PermissionRequest", HookEventName::PostToolUse => "PostToolUse", + HookEventName::PreCompact => "PreCompact", + HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::Stop => "Stop", From acea69eb8d683dc8a7e57cc1747edddd0227454f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 23 Apr 2026 01:16:52 -0700 Subject: [PATCH 2/3] fix(hooks): align compact hooks with managed config --- codex-rs/core/config.schema.json | 14 ++++++++++++++ codex-rs/hooks/src/events/compact.rs | 1 + 2 files changed, 15 insertions(+) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1223b45a621..9dd56c03899 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -865,6 +865,13 @@ }, "type": "array" }, + "PostCompact": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "PostToolUse": { "default": [], "items": { @@ -872,6 +879,13 @@ }, "type": "array" }, + "PreCompact": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "PreToolUse": { "default": [], "items": { diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs index fcd130fcfc4..a7835374b90 100644 --- a/codex-rs/hooks/src/events/compact.rs +++ b/codex-rs/hooks/src/events/compact.rs @@ -426,6 +426,7 @@ mod tests { fn handler(event_name: HookEventName) -> ConfiguredHandler { ConfiguredHandler { event_name, + is_managed: false, matcher: None, command: "python3 compact_hook.py".to_string(), timeout_sec: 5, From 44dd1fc42ba8b5ffbbccab999213e571f6def111 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 23 Apr 2026 01:41:34 -0700 Subject: [PATCH 3/3] fix(hooks): reduce post compact hook arguments --- codex-rs/core/src/compact.rs | 7 +++++-- codex-rs/core/src/compact_remote.rs | 7 +++++-- codex-rs/core/src/hook_runtime.rs | 12 ++++++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 52ca9f6de5c..0b861214a37 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -4,6 +4,7 @@ use std::time::Instant; use crate::Prompt; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; +use crate::hook_runtime::PostCompactHookResult; use crate::hook_runtime::run_post_compact_hooks; use crate::hook_runtime::run_pre_compact_hooks; #[cfg(test)] @@ -159,8 +160,10 @@ async fn run_compact_task_inner( reason, phase, CompactionImplementation::Responses, - status, - error.clone(), + PostCompactHookResult { + status, + error: error.clone(), + }, ) .await; attempt.track(sess.as_ref(), status, error).await; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index d5a702103ba..df7bc1ae26f 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -10,6 +10,7 @@ use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; use crate::context_manager::is_codex_generated_item; +use crate::hook_runtime::PostCompactHookResult; use crate::hook_runtime::run_post_compact_hooks; use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; @@ -113,8 +114,10 @@ async fn run_remote_compact_task_inner( reason, phase, CompactionImplementation::ResponsesCompact, - status, - error.clone(), + PostCompactHookResult { + status, + error: error.clone(), + }, ) .await; attempt.track(sess.as_ref(), status, error.clone()).await; diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 5395ea180a6..0aa16b34ebe 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -282,6 +282,11 @@ pub(crate) async fn run_pre_compact_hooks( emit_hook_completed_events(sess, turn_context, outcome.hook_events).await; } +pub(crate) struct PostCompactHookResult { + pub(crate) status: CompactionStatus, + pub(crate) error: Option, +} + pub(crate) async fn run_post_compact_hooks( sess: &Arc, turn_context: &Arc, @@ -289,8 +294,7 @@ pub(crate) async fn run_post_compact_hooks( reason: CompactionReason, phase: CompactionPhase, implementation: CompactionImplementation, - status: CompactionStatus, - error: Option, + result: PostCompactHookResult, ) { let request = codex_hooks::PostCompactRequest { session_id: sess.conversation_id, @@ -303,8 +307,8 @@ pub(crate) async fn run_post_compact_hooks( reason: compaction_reason_label(reason).to_string(), phase: compaction_phase_label(phase).to_string(), implementation: compaction_implementation_label(implementation).to_string(), - status: compaction_status_label(status).to_string(), - error, + status: compaction_status_label(result.status).to_string(), + error: result.error, }; let preview_runs = sess.hooks().preview_post_compact(&request); emit_hook_started_events(sess, turn_context, preview_runs).await;