From 527d52df03a99862ec3bfa9af0f356847ab247e9 Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Wed, 6 May 2026 18:08:31 -0700 Subject: [PATCH] Add compact lifecycle hooks (started by vincentkoc - external contrib) (#19905) Based on work from Vincent K - https://github.com/openai/codex/pull/19060 CleanShot 2026-04-29 at 20 47 40@2x ## 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 --- codex-rs/analytics/src/events.rs | 2 + .../schema/json/ServerNotification.json | 2 + .../codex_app_server_protocol.schemas.json | 16 + .../codex_app_server_protocol.v2.schemas.json | 16 + .../v2/ConfigRequirementsReadResponse.json | 14 + .../json/v2/HookCompletedNotification.json | 2 + .../json/v2/HookStartedNotification.json | 2 + .../schema/json/v2/HooksListResponse.json | 2 + .../schema/typescript/v2/HookEventName.ts | 2 +- .../typescript/v2/ManagedHooksRequirements.ts | 2 +- .../src/protocol/v2/config.rs | 6 + .../src/protocol/v2/hook.rs | 2 +- .../request_processors/config_processor.rs | 4 + codex-rs/config/src/hook_config.rs | 16 +- codex-rs/core/config.schema.json | 14 + codex-rs/core/src/compact.rs | 41 +- codex-rs/core/src/compact_remote.rs | 39 +- codex-rs/core/src/hook_runtime.rs | 72 +++ codex-rs/core/tests/suite/compact.rs | 246 +++++++ ...mission-request.command.output.schema.json | 2 + .../post-compact.command.input.schema.json | 52 ++ .../post-compact.command.output.schema.json | 24 + .../post-tool-use.command.output.schema.json | 2 + .../pre-compact.command.input.schema.json | 52 ++ .../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/discovery.rs | 2 + codex-rs/hooks/src/engine/dispatcher.rs | 29 +- codex-rs/hooks/src/engine/mod.rs | 25 + codex-rs/hooks/src/engine/output_parser.rs | 37 ++ codex-rs/hooks/src/engine/schema_loader.rs | 24 + codex-rs/hooks/src/events/common.rs | 12 +- codex-rs/hooks/src/events/compact.rs | 608 ++++++++++++++++++ codex-rs/hooks/src/events/mod.rs | 1 + .../hooks/src/events/permission_request.rs | 2 +- codex-rs/hooks/src/events/post_tool_use.rs | 2 +- codex-rs/hooks/src/events/pre_tool_use.rs | 2 +- codex-rs/hooks/src/events/session_start.rs | 2 +- .../hooks/src/events/user_prompt_submit.rs | 2 +- codex-rs/hooks/src/lib.rs | 17 +- codex-rs/hooks/src/registry.rs | 26 + codex-rs/hooks/src/schema.rs | 118 ++++ codex-rs/protocol/src/protocol.rs | 2 + .../tui/src/bottom_pane/hooks_browser_view.rs | 4 + ...ser_view__tests__hooks_browser_events.snap | 2 + ...sts__hooks_browser_events_with_issues.snap | 2 + ...oks_browser_events_with_review_column.snap | 2 + ...s__hooks_popup_shows_list_diagnostics.snap | 2 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + codex-rs/tui/src/history_cell/hook_cell.rs | 2 + 52 files changed, 1555 insertions(+), 34 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 2232c88d39..2bcd43f380 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -880,6 +880,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 883ab8e68a..5dc3c09a44 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1736,6 +1736,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 24b8ea9cc0..06ccdb48b2 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 @@ -9824,6 +9824,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" @@ -10664,12 +10666,24 @@ }, "type": "array" }, + "PostCompact": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PostToolUse": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" }, "type": "array" }, + "PreCompact": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PreToolUse": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" @@ -10709,7 +10723,9 @@ }, "required": [ "PermissionRequest", + "PostCompact", "PostToolUse", + "PreCompact", "PreToolUse", "SessionStart", "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 76d81c729e..852cc2489d 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 @@ -6391,6 +6391,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" @@ -7275,12 +7277,24 @@ }, "type": "array" }, + "PostCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PostToolUse": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" }, "type": "array" }, + "PreCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PreToolUse": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -7320,7 +7334,9 @@ }, "required": [ "PermissionRequest", + "PostCompact", "PostToolUse", + "PreCompact", "PreToolUse", "SessionStart", "Stop", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 545d8dc9b4..14a8d572d6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -213,12 +213,24 @@ }, "type": "array" }, + "PostCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PostToolUse": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" }, "type": "array" }, + "PreCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PreToolUse": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -258,7 +270,9 @@ }, "required": [ "PermissionRequest", + "PostCompact", "PostToolUse", + "PreCompact", "PreToolUse", "SessionStart", "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 d55c059a73..f63e6a5cfe 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 03d2998ca5..f8eeecfe4d 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/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index ae9cd9e63c..f2a7c80cf0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -25,6 +25,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 28657d2282..91c2def709 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/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index 3386d16ec3..cde0e4a503 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index da4f493d77..8bc50bb1f2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -380,6 +380,12 @@ pub struct ManagedHooksRequirements { #[serde(rename = "PostToolUse")] #[ts(rename = "PostToolUse")] pub post_tool_use: Vec, + #[serde(rename = "PreCompact")] + #[ts(rename = "PreCompact")] + pub pre_compact: Vec, + #[serde(rename = "PostCompact")] + #[ts(rename = "PostCompact")] + pub post_compact: Vec, #[serde(rename = "SessionStart")] #[ts(rename = "SessionStart")] pub session_start: Vec, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index e0f74b5ded..4a07bd495b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -17,7 +17,7 @@ use ts_rs::TS; 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/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index f084867719..840542419a 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -462,6 +462,8 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -473,6 +475,8 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use), permission_request: map_hook_matcher_groups_to_api(permission_request), post_tool_use: map_hook_matcher_groups_to_api(post_tool_use), + pre_compact: map_hook_matcher_groups_to_api(pre_compact), + post_compact: map_hook_matcher_groups_to_api(post_compact), session_start: map_hook_matcher_groups_to_api(session_start), user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), stop: map_hook_matcher_groups_to_api(stop), diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 27cca781c7..630d18c569 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -37,6 +37,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)] @@ -51,6 +55,8 @@ impl HookEventsToml { pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -58,6 +64,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() @@ -68,6 +76,8 @@ impl HookEventsToml { pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -76,6 +86,8 @@ impl HookEventsToml { pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -86,11 +98,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/config.schema.json b/codex-rs/core/config.schema.json index fe3e48a2df..6eab1d4c55 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1033,6 +1033,13 @@ }, "type": "array" }, + "PostCompact": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "PostToolUse": { "default": [], "items": { @@ -1040,6 +1047,13 @@ }, "type": "array" }, + "PreCompact": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "PreToolUse": { "default": [], "items": { diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 38eb19f1ba..9d1c82eb65 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -4,6 +4,10 @@ use std::time::Instant; use crate::Prompt; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; +use crate::hook_runtime::PostCompactHookOutcome; +use crate::hook_runtime::PreCompactHookOutcome; +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; @@ -110,7 +114,8 @@ pub(crate) async fn run_compact_task( CompactionReason::UserRequested, CompactionPhase::StandaloneTurn, ) - .await + .await?; + Ok(()) } async fn run_compact_task_inner( @@ -131,6 +136,17 @@ async fn run_compact_task_inner( phase, ) .await; + let pre_compact_outcome = run_pre_compact_hooks(&sess, &turn_context, trigger).await; + match pre_compact_outcome { + PreCompactHookOutcome::Continue => {} + PreCompactHookOutcome::Stopped { reason } => { + let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string()); + attempt + .track(sess.as_ref(), CompactionStatus::Interrupted, Some(error)) + .await; + return Err(CodexErr::TurnAborted); + } + } let result = run_compact_task_inner_impl( Arc::clone(&sess), Arc::clone(&turn_context), @@ -138,14 +154,17 @@ 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; - result + let status = compaction_status_from_result(&result); + let error = result.as_ref().err().map(ToString::to_string); + if result.is_ok() { + let post_compact_outcome = run_post_compact_hooks(&sess, &turn_context, trigger).await; + if let PostCompactHookOutcome::Stopped = post_compact_outcome { + attempt.track(sess.as_ref(), status, error).await; + return Err(CodexErr::TurnAborted); + } + } + attempt.track(sess.as_ref(), status, error).await; + result.map(|_| ()) } async fn run_compact_task_inner_impl( @@ -153,7 +172,7 @@ async fn run_compact_task_inner_impl( turn_context: Arc, input: Vec, initial_context_injection: InitialContextInjection, -) -> CodexResult<()> { +) -> CodexResult { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) .await; @@ -272,7 +291,7 @@ async fn run_compact_task_inner_impl( message: "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.".to_string(), }); sess.send_event(&turn_context, warning).await; - Ok(()) + Ok(summary_suffix) } pub(crate) struct CompactionAnalyticsAttempt { diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index bc0516ce01..eb9e454677 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -11,6 +11,10 @@ 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::PostCompactHookOutcome; +use crate::hook_runtime::PreCompactHookOutcome; +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; @@ -72,7 +76,8 @@ pub(crate) async fn run_remote_compact_task( CompactionReason::UserRequested, CompactionPhase::StandaloneTurn, ) - .await + .await?; + Ok(()) } async fn run_remote_compact_task_inner( @@ -92,15 +97,33 @@ async fn run_remote_compact_task_inner( phase, ) .await; + let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await; + match pre_compact_outcome { + PreCompactHookOutcome::Continue => {} + PreCompactHookOutcome::Stopped { reason } => { + let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string()); + attempt + .track( + sess.as_ref(), + codex_analytics::CompactionStatus::Interrupted, + Some(error), + ) + .await; + return Err(CodexErr::TurnAborted); + } + } 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); + if result.is_ok() { + let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; + if let PostCompactHookOutcome::Stopped = post_compact_outcome { + attempt.track(sess.as_ref(), status, error).await; + return Err(CodexErr::TurnAborted); + } + } + 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 01d5ce1534..2bea729339 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -2,6 +2,7 @@ use std::future::Future; use std::sync::Arc; use std::time::Duration; +use codex_analytics::CompactionTrigger; use codex_analytics::HookRunFact; use codex_analytics::build_track_events_context; use codex_hooks::PermissionRequestDecision; @@ -255,6 +256,68 @@ 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, +) -> PreCompactHookOutcome { + 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(), + trigger: compaction_trigger_label(trigger).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; + if outcome.should_stop { + PreCompactHookOutcome::Stopped { + reason: outcome.stop_reason, + } + } else { + PreCompactHookOutcome::Continue + } +} + +pub(crate) enum PreCompactHookOutcome { + Continue, + Stopped { reason: Option }, +} + +pub(crate) enum PostCompactHookOutcome { + Continue, + Stopped, +} + +pub(crate) async fn run_post_compact_hooks( + sess: &Arc, + turn_context: &Arc, + trigger: CompactionTrigger, +) -> PostCompactHookOutcome { + 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(), + trigger: compaction_trigger_label(trigger).to_string(), + }; + 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; + if outcome.should_stop { + PostCompactHookOutcome::Stopped + } else { + PostCompactHookOutcome::Continue + } +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, @@ -469,6 +532,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", @@ -511,6 +576,13 @@ 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", + } +} + #[cfg(test)] mod tests { use codex_protocol::models::ContentItem; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 1d770649d0..b1620ee36b 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -13,6 +13,8 @@ use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::Op; @@ -23,6 +25,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; +use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ev_local_shell_call; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; @@ -47,7 +50,10 @@ use core_test_support::responses::sse_failed; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; +use std::fs; +use std::path::Path; use wiremock::MockServer; // --- Test helpers ----------------------------------------------------------- @@ -119,6 +125,107 @@ fn json_fragment(text: &str) -> String { .to_string() } +fn read_hook_inputs(path: &Path) -> Vec { + let text = fs::read_to_string(path) + .unwrap_or_else(|err| panic!("failed to read hook input log {}: {err}", path.display())); + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str(line) + .unwrap_or_else(|err| panic!("failed to parse hook input log line: {err}")) + }) + .collect() +} + +fn python_hook_command(script_path: &Path) -> String { + format!("python3 \"{}\"", script_path.display()) +} + +fn write_unsupported_blocking_pre_compact_hook(home: &Path) { + let script_path = home.join("pre_compact_block.py"); + let log_path = home.join("pre_compact_block_log.jsonl"); + let script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +print(json.dumps({{"decision": "block", "reason": "blocked by policy"}})) +"#, + log_path = log_path.display(), + ); + let hooks = json!({ + "hooks": { + "PreCompact": [{ + "matcher": "manual", + "hooks": [{ + "type": "command", + "command": python_hook_command(&script_path), + "statusMessage": "checking compact policy", + }] + }] + } + }); + + fs::write(&script_path, script).expect("write pre compact hook script"); + fs::write(home.join("hooks.json"), hooks.to_string()).expect("write hooks.json"); +} + +fn write_matching_compact_hooks(home: &Path) { + let auto_script_path = home.join("pre_compact_auto.py"); + let auto_log_path = home.join("pre_compact_auto_log.jsonl"); + let manual_post_script_path = home.join("post_compact_manual.py"); + let manual_post_log_path = home.join("post_compact_manual_log.jsonl"); + let auto_script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{auto_log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + auto_log_path = auto_log_path.display(), + ); + let manual_post_script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{manual_post_log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + manual_post_log_path = manual_post_log_path.display(), + ); + let hooks = json!({ + "hooks": { + "PreCompact": [{ + "matcher": "auto", + "hooks": [{ + "type": "command", + "command": python_hook_command(&auto_script_path), + }] + }], + "PostCompact": [{ + "matcher": "manual", + "hooks": [{ + "type": "command", + "command": python_hook_command(&manual_post_script_path), + }] + }] + } + }); + + fs::write(&auto_script_path, auto_script).expect("write auto pre compact hook script"); + fs::write(&manual_post_script_path, manual_post_script) + .expect("write manual post compact hook script"); + fs::write(home.join("hooks.json"), hooks.to_string()).expect("write hooks.json"); +} + fn non_openai_model_provider(server: &MockServer) -> ModelProviderInfo { let mut provider = built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None)["openai"].clone(); @@ -437,6 +544,145 @@ async fn summarize_context_three_requests_and_instructions() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn manual_pre_compact_block_decision_does_not_block_compaction() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let first_turn = sse(vec![ + ev_assistant_message("m0", FIRST_REPLY), + ev_completed_with_tokens("r0", /*total_tokens*/ 80), + ]); + let compact_turn = sse(vec![ + ev_assistant_message("m1", SUMMARY_TEXT), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]); + let request_log = mount_sse_sequence(&server, vec![first_turn, compact_turn]).await; + + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_pre_build_hook(write_unsupported_blocking_pre_compact_hook) + .with_config(move |config| { + config.model_provider = model_provider; + trust_discovered_hooks(config); + set_test_compact_prompt(config); + }); + let test = builder.build(&server).await.expect("create conversation"); + let codex = test.codex.clone(); + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello before blocked compact".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await + .expect("submit first user turn"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex.submit(Op::Compact).await.expect("trigger compact"); + + let completed = wait_for_event_match(&codex, |ev| match ev { + EventMsg::HookCompleted(completed) + if completed.run.event_name == HookEventName::PreCompact => + { + Some(completed.clone()) + } + _ => None, + }) + .await; + assert_eq!(completed.run.status, HookRunStatus::Failed); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 2, + "unsupported PreCompact block output should not prevent the compact request" + ); + + let hook_inputs = read_hook_inputs(&test.codex_home_path().join("pre_compact_block_log.jsonl")); + assert_eq!(hook_inputs.len(), 1); + let input = &hook_inputs[0]; + assert_eq!(input["hook_event_name"], "PreCompact"); + assert_eq!(input["trigger"], "manual"); + assert!(input.get("reason").is_none()); + assert!(input.get("phase").is_none()); + assert!(input.get("implementation").is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compact_hooks_respect_matchers_and_post_runs_after_compaction() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let first_turn = sse(vec![ + ev_assistant_message("m0", FIRST_REPLY), + ev_completed_with_tokens("r0", /*total_tokens*/ 80), + ]); + let compact_turn = sse(vec![ + ev_assistant_message("m1", SUMMARY_TEXT), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]); + let request_log = mount_sse_sequence(&server, vec![first_turn, compact_turn]).await; + + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_pre_build_hook(write_matching_compact_hooks) + .with_config(move |config| { + config.model_provider = model_provider; + trust_discovered_hooks(config); + set_test_compact_prompt(config); + }); + let test = builder.build(&server).await.expect("create conversation"); + let codex = test.codex.clone(); + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello before matched compact".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await + .expect("submit first user turn"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex.submit(Op::Compact).await.expect("trigger compact"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::Warning(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!(request_log.requests().len(), 2); + assert!( + !test + .codex_home_path() + .join("pre_compact_auto_log.jsonl") + .exists(), + "auto matcher should not run for manual compaction" + ); + + let hook_inputs = + read_hook_inputs(&test.codex_home_path().join("post_compact_manual_log.jsonl")); + assert_eq!(hook_inputs.len(), 1); + let input = &hook_inputs[0]; + assert_eq!(input["hook_event_name"], "PostCompact"); + assert_eq!(input["trigger"], "manual"); + assert!(input.get("compact_summary").is_none()); + assert!(input.get("status").is_none()); + assert!(input.get("error").is_none()); + assert!(input.get("reason").is_none()); + assert!(input.get("phase").is_none()); + assert!(input.get("implementation").is_none()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn manual_compact_uses_custom_prompt() { skip_if_no_network!(); 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 21d45382b0..c89f80a3b0 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 0000000000..e80ed092b7 --- /dev/null +++ b/codex-rs/hooks/schema/generated/post-compact.command.input.schema.json @@ -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" +} \ 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 0000000000..0221ac29b4 --- /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 43a2a4828e..2b64c0ad00 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 0000000000..816fae23c8 --- /dev/null +++ b/codex-rs/hooks/schema/generated/pre-compact.command.input.schema.json @@ -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" +} \ 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 0000000000..644cd1a8b4 --- /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 ba6bb3401b..3ab17b9f5c 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 30d2b0f977..4d6ba033fe 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 26353d97db..6baceb149f 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/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index f24da13bb4..ed4322fb58 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -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", diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index c44b1fe69d..9c71bf49b5 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -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![ diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 18262989e8..579f7594d4 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -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 { + 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 { + 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 464c7f8608..3bccb10127 100644 --- a/codex-rs/hooks/src/engine/output_parser.rs +++ b/codex-rs/hooks/src/engine/output_parser.rs @@ -60,12 +60,26 @@ pub(crate) struct StopOutput { pub invalid_block_reason: Option, } +#[derive(Debug, Clone)] +pub(crate) struct PreCompactOutput { + pub universal: UniversalOutput, + pub invalid_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; @@ -191,6 +205,24 @@ 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); + Some(PreCompactOutput { + universal, + invalid_reason: None, + }) +} + +pub(crate) fn parse_post_compact(stdout: &str) -> Option { + 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 { 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") } diff --git a/codex-rs/hooks/src/engine/schema_loader.rs b/codex-rs/hooks/src/engine/schema_loader.rs index d8bdb141c2..704edea4ba 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 de3f3292ac..e79a139b79 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -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") + ); } } diff --git a/codex-rs/hooks/src/events/compact.rs b/codex-rs/hooks/src/events/compact.rs new file mode 100644 index 0000000000..67c13c34eb --- /dev/null +++ b/codex-rs/hooks/src/events/compact.rs @@ -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, + 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, + pub model: String, + pub trigger: String, +} + +#[derive(Debug)] +pub struct StatelessHookOutcome { + pub hook_events: Vec, + pub should_stop: bool, + pub stop_reason: Option, +} + +#[derive(Debug)] +pub struct PreCompactOutcome { + pub hook_events: Vec, + pub should_stop: bool, + pub stop_reason: Option, +} + +pub(crate) fn preview_pre( + handlers: &[ConfiguredHandler], + request: &PreCompactRequest, +) -> Vec { + 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 { + 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 { + 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 { + 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, +} + +fn parse_pre_completed( + handler: &ConfiguredHandler, + run_result: CommandRunResult, + turn_id: Option, +) -> dispatcher::ParsedHandler { + 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, +) -> 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; + 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, 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 52148324e2..5ec24462b9 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/events/permission_request.rs b/codex-rs/hooks/src/events/permission_request.rs index 2cebd0e002..11ab4d2e47 100644 --- a/codex-rs/hooks/src/events/permission_request.rs +++ b/codex-rs/hooks/src/events/permission_request.rs @@ -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, diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 63045ef425..223efa7260 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -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, diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 39d0e61258..77e6d3f3fa 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -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, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index f064f67b20..195bb11257 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -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, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index a04711eb40..a10798ea62 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -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, diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index ad627b497a..6c4c672bca 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -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; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index ae80015729..74c9a84539 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -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 { + 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 { + 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 d08cce6ee2..d4f408d8c3 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,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::()?, )?; + 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 +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::().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 +823,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 5c8f268e2c..91fd02d858 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1454,6 +1454,8 @@ pub enum HookEventName { PreToolUse, PermissionRequest, PostToolUse, + PreCompact, + PostCompact, SessionStart, UserPromptSubmit, Stop, diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index c146bae8b4..7e1a41cf30 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -662,6 +662,8 @@ fn 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", @@ -673,6 +675,8 @@ fn event_description(event_name: HookEventName) -> &'static str { HookEventName::PreToolUse => "Before a tool executes", HookEventName::PermissionRequest => "When permission is requested", HookEventName::PostToolUse => "After a tool executes", + HookEventName::PreCompact => "Before context compaction", + HookEventName::PostCompact => "After context compaction", HookEventName::SessionStart => "When a new session starts", HookEventName::UserPromptSubmit => "When the user submits a prompt", HookEventName::Stop => "Right before Codex ends its turn", diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap index 522105c30d..34c1d8ceec 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -10,6 +10,8 @@ expression: "render_lines(&view, 112)" PreToolUse 2 1 Before a tool executes PermissionRequest 1 1 When permission is requested PostToolUse 0 0 After a tool executes + PreCompact 0 0 Before context compaction + PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt Stop 0 0 Right before Codex ends its turn diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap index 18e3b9f849..511f173813 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -14,6 +14,8 @@ expression: "render_lines(&view, 112)" PreToolUse 0 0 Before a tool executes PermissionRequest 0 0 When permission is requested PostToolUse 0 0 After a tool executes + PreCompact 0 0 Before context compaction + PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt Stop 0 0 Right before Codex ends its turn diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap index 85e930c68c..d6768118ca 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_review_column.snap @@ -10,6 +10,8 @@ expression: "render_lines(&view, 112)" PreToolUse 1 0 1 Before a tool executes PermissionRequest 0 0 0 When permission is requested PostToolUse 0 0 0 After a tool executes + PreCompact 0 0 0 Before context compaction + PostCompact 0 0 0 After context compaction SessionStart 0 0 0 When a new session starts UserPromptSubmit 0 0 0 When the user submits a prompt Stop 0 0 0 Right before Codex ends its turn diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap index 865d19031f..5224197d2f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap @@ -13,6 +13,8 @@ expression: popup PreToolUse 0 0 Before a tool executes PermissionRequest 0 0 When permission is requested PostToolUse 0 0 After a tool executes + PreCompact 0 0 Before context compaction + PostCompact 0 0 After context compaction SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt Stop 0 0 Right before Codex ends its turn diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index b5a6dad825..56fe0b5b5d 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1677,6 +1677,8 @@ fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'s codex_app_server_protocol::HookEventName::PreToolUse => "PreToolUse", codex_app_server_protocol::HookEventName::PermissionRequest => "PermissionRequest", codex_app_server_protocol::HookEventName::PostToolUse => "PostToolUse", + codex_app_server_protocol::HookEventName::PreCompact => "PreCompact", + codex_app_server_protocol::HookEventName::PostCompact => "PostCompact", codex_app_server_protocol::HookEventName::SessionStart => "SessionStart", codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_app_server_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 3b87eff6d3..e78ce27fb0 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -716,6 +716,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",