Files
codex/codex-rs/hooks/src/schema.rs
Andrei Eternal 527d52df03 Add compact lifecycle hooks (started by vincentkoc - external contrib) (#19905)
Based on work from Vincent K -
https://github.com/openai/codex/pull/19060

<img width="1836" height="642" alt="CleanShot 2026-04-29 at 20 47 40@2x"
src="https://github.com/user-attachments/assets/b647bb89-65fe-40c8-80b0-7a6b7c984634"
/>

## Why

Compaction rewrites the conversation context that future model turns
receive, but hooks currently have no deterministic lifecycle point
around that rewrite. This adds compact lifecycle hooks so users can
audit manual and automatic compaction, surface hook messages in the UI,
and run post-compaction follow-up without overloading tool or prompt
hooks.

## What Changed

- Added `PreCompact` and `PostCompact` hook events across hook config,
discovery, dispatch, generated schemas, app-server notifications,
analytics, and TUI hook rendering.
- Added trigger matching for compact hooks with the documented `manual`
and `auto` matcher values.
- Wired `PreCompact` before both local and remote compaction, and
`PostCompact` after successful local or remote compaction.
- Kept compact hook command input to lifecycle metadata: session id,
Codex turn id, transcript path, cwd, hook event name, model, and
trigger.
- Made compact stdout handling consistent with other hooks: plain stdout
is ignored as debug output, while malformed JSON-looking stdout is
reported as failed hook output.
- Added integration coverage for compact hook dispatch, trigger
matching, post-compact execution, and the audited behavior that
`decision:"block"` does not block compaction.

## Out of Scope

- Hook-specific compaction blocking is not implemented;
`decision:"block"` and exit-code-2 blocking semantics are intentionally
unsupported for `PreCompact`.
- Custom compaction instructions are not exposed to compact hooks in
this PR.
- Compact summaries, summary character counts, and summary previews are
not exposed to compact hooks in this PR.

## Verification

- `cargo test -p codex-hooks`
- `cargo test -p codex-core
manual_pre_compact_block_decision_does_not_block_compaction`
- `cargo test -p codex-app-server hooks_list`
- `cargo test -p codex-core config_schema_matches_fixture`
- `cargo test -p codex-tui hooks_browser`

## Docs

The developer documentation for Codex hooks should be updated alongside
this feature to document `PreCompact` and `PostCompact`, the
`manual`/`auto` matcher values, and the compact hook payload fields.

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-06 18:08:31 -07:00

841 lines
29 KiB
Rust

use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::r#gen::SchemaSettings;
use schemars::schema::InstanceType;
use schemars::schema::RootSchema;
use schemars::schema::Schema;
use schemars::schema::SchemaObject;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Map;
use serde_json::Value;
use std::path::Path;
use std::path::PathBuf;
const GENERATED_DIR: &str = "generated";
const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json";
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";
const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json";
const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json";
const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json";
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub(crate) struct NullableString(Option<String>);
impl NullableString {
pub(crate) fn from_path(path: Option<PathBuf>) -> Self {
Self(path.map(|path| path.display().to_string()))
}
pub(crate) fn from_string(value: Option<String>) -> Self {
Self(value)
}
}
impl JsonSchema for NullableString {
fn schema_name() -> String {
"NullableString".to_string()
}
fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
instance_type: Some(vec![InstanceType::String, InstanceType::Null].into()),
..Default::default()
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct HookUniversalOutputWire {
#[serde(default = "default_continue")]
pub r#continue: bool,
#[serde(default)]
pub stop_reason: Option<String>,
#[serde(default)]
pub suppress_output: bool,
#[serde(default)]
pub system_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum HookEventNameWire {
#[serde(rename = "PreToolUse")]
PreToolUse,
#[serde(rename = "PermissionRequest")]
PermissionRequest,
#[serde(rename = "PostToolUse")]
PostToolUse,
#[serde(rename = "PreCompact")]
PreCompact,
#[serde(rename = "PostCompact")]
PostCompact,
#[serde(rename = "SessionStart")]
SessionStart,
#[serde(rename = "UserPromptSubmit")]
UserPromptSubmit,
#[serde(rename = "Stop")]
Stop,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "pre-tool-use.command.output")]
pub(crate) struct PreToolUseCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<PreToolUseDecisionWire>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub hook_specific_output: Option<PreToolUseHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "post-tool-use.command.output")]
pub(crate) struct PostToolUseCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<BlockDecisionWire>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub hook_specific_output: Option<PostToolUseHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "permission-request.command.output")]
pub(crate) struct PermissionRequestCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub hook_specific_output: Option<PermissionRequestHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "pre-compact.command.output")]
pub(crate) struct PreCompactCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "post-compact.command.output")]
pub(crate) struct PostCompactCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PermissionRequestHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub decision: Option<PermissionRequestDecisionWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PermissionRequestDecisionWire {
pub behavior: PermissionRequestBehaviorWire,
/// Reserved for a future input-rewrite capability.
///
/// PermissionRequest hooks currently fail closed if this field is present.
#[serde(default)]
pub updated_input: Option<Value>,
/// Reserved for a future permission-rewrite capability.
///
/// PermissionRequest hooks currently fail closed if this field is present.
#[serde(default)]
pub updated_permissions: Option<Value>,
#[serde(default)]
pub message: Option<String>,
/// Reserved for future short-circuiting semantics.
///
/// PermissionRequest hooks currently fail closed if this field is `true`.
#[serde(default)]
pub interrupt: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum PermissionRequestBehaviorWire {
#[serde(rename = "allow")]
Allow,
#[serde(rename = "deny")]
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PostToolUseHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
#[serde(default)]
#[serde(rename = "updatedMCPToolOutput")]
pub updated_mcp_tool_output: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PreToolUseHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub permission_decision: Option<PreToolUsePermissionDecisionWire>,
#[serde(default)]
pub permission_decision_reason: Option<String>,
#[serde(default)]
pub updated_input: Option<Value>,
#[serde(default)]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum PreToolUsePermissionDecisionWire {
#[serde(rename = "allow")]
Allow,
#[serde(rename = "deny")]
Deny,
#[serde(rename = "ask")]
Ask,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum PreToolUseDecisionWire {
#[serde(rename = "approve")]
Approve,
#[serde(rename = "block")]
Block,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "pre-tool-use.command.input")]
pub(crate) struct PreToolUseCommandInput {
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_tool_use_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
pub tool_name: String,
pub tool_input: Value,
pub tool_use_id: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "permission-request.command.input")]
pub(crate) struct PermissionRequestCommandInput {
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 = "permission_request_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
pub tool_name: String,
pub tool_input: Value,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "post-tool-use.command.input")]
pub(crate) struct PostToolUseCommandInput {
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_tool_use_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
pub tool_name: String,
pub tool_input: Value,
pub tool_response: Value,
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)]
#[schemars(rename = "session-start.command.output")]
pub(crate) struct SessionStartCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub hook_specific_output: Option<SessionStartHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct SessionStartHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "user-prompt-submit.command.output")]
pub(crate) struct UserPromptSubmitCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<BlockDecisionWire>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub hook_specific_output: Option<UserPromptSubmitHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct UserPromptSubmitHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "stop.command.output")]
pub(crate) struct StopCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<BlockDecisionWire>,
/// Claude requires `reason` when `decision` is `block`; we enforce that
/// semantic rule during output parsing rather than in the JSON schema.
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum BlockDecisionWire {
#[serde(rename = "block")]
Block,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "session-start.command.input")]
pub(crate) struct SessionStartCommandInput {
pub session_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "session_start_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 = "session_start_source_schema")]
pub source: String,
}
impl SessionStartCommandInput {
pub(crate) fn new(
session_id: impl Into<String>,
transcript_path: Option<PathBuf>,
cwd: impl Into<String>,
model: impl Into<String>,
permission_mode: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self {
session_id: session_id.into(),
transcript_path: NullableString::from_path(transcript_path),
cwd: cwd.into(),
hook_event_name: "SessionStart".to_string(),
model: model.into(),
permission_mode: permission_mode.into(),
source: source.into(),
}
}
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "user-prompt-submit.command.input")]
pub(crate) struct UserPromptSubmitCommandInput {
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 = "user_prompt_submit_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
pub prompt: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "stop.command.input")]
pub(crate) struct StopCommandInput {
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 = "stop_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
pub stop_hook_active: bool,
pub last_assistant_message: NullableString,
}
pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
let generated_dir = schema_root.join(GENERATED_DIR);
ensure_empty_dir(&generated_dir)?;
write_schema(
&generated_dir.join(POST_TOOL_USE_INPUT_FIXTURE),
schema_json::<PostToolUseCommandInput>()?,
)?;
write_schema(
&generated_dir.join(POST_TOOL_USE_OUTPUT_FIXTURE),
schema_json::<PostToolUseCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(PERMISSION_REQUEST_INPUT_FIXTURE),
schema_json::<PermissionRequestCommandInput>()?,
)?;
write_schema(
&generated_dir.join(PERMISSION_REQUEST_OUTPUT_FIXTURE),
schema_json::<PermissionRequestCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(POST_COMPACT_INPUT_FIXTURE),
schema_json::<PostCompactCommandInput>()?,
)?;
write_schema(
&generated_dir.join(POST_COMPACT_OUTPUT_FIXTURE),
schema_json::<PostCompactCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(PRE_COMPACT_INPUT_FIXTURE),
schema_json::<PreCompactCommandInput>()?,
)?;
write_schema(
&generated_dir.join(PRE_COMPACT_OUTPUT_FIXTURE),
schema_json::<PreCompactCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
schema_json::<PreToolUseCommandInput>()?,
)?;
write_schema(
&generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE),
schema_json::<PreToolUseCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(SESSION_START_INPUT_FIXTURE),
schema_json::<SessionStartCommandInput>()?,
)?;
write_schema(
&generated_dir.join(SESSION_START_OUTPUT_FIXTURE),
schema_json::<SessionStartCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(USER_PROMPT_SUBMIT_INPUT_FIXTURE),
schema_json::<UserPromptSubmitCommandInput>()?,
)?;
write_schema(
&generated_dir.join(USER_PROMPT_SUBMIT_OUTPUT_FIXTURE),
schema_json::<UserPromptSubmitCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(STOP_INPUT_FIXTURE),
schema_json::<StopCommandInput>()?,
)?;
write_schema(
&generated_dir.join(STOP_OUTPUT_FIXTURE),
schema_json::<StopCommandOutputWire>()?,
)?;
Ok(())
}
fn write_schema(path: &Path, json: Vec<u8>) -> anyhow::Result<()> {
std::fs::write(path, json)?;
Ok(())
}
fn ensure_empty_dir(dir: &Path) -> anyhow::Result<()> {
if dir.exists() {
std::fs::remove_dir_all(dir)?;
}
std::fs::create_dir_all(dir)?;
Ok(())
}
fn schema_json<T>() -> anyhow::Result<Vec<u8>>
where
T: JsonSchema,
{
let schema = schema_for_type::<T>();
let value = serde_json::to_value(schema)?;
let value = canonicalize_json(&value);
Ok(serde_json::to_vec_pretty(&value)?)
}
fn schema_for_type<T>() -> RootSchema
where
T: JsonSchema,
{
SchemaSettings::draft07()
.with(|settings| {
settings.option_add_null_type = false;
})
.into_generator()
.into_root_schema_for::<T>()
}
fn canonicalize_json(value: &Value) -> Value {
match value {
Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()),
Value::Object(map) => {
let mut entries: Vec<_> = map.iter().collect();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut sorted = Map::with_capacity(map.len());
for (key, child) in entries {
sorted.insert(key.clone(), canonicalize_json(child));
}
Value::Object(sorted)
}
_ => value.clone(),
}
}
fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("SessionStart")
}
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")
}
fn permission_request_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("PermissionRequest")
}
fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("UserPromptSubmit")
}
fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Stop")
}
fn permission_mode_schema(_gen: &mut SchemaGenerator) -> Schema {
string_enum_schema(&[
"default",
"acceptEdits",
"plan",
"dontAsk",
"bypassPermissions",
])
}
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()),
..Default::default()
};
schema.const_value = Some(Value::String(value.to_string()));
Schema::Object(schema)
}
fn string_enum_schema(values: &[&str]) -> Schema {
let mut schema = SchemaObject {
instance_type: Some(InstanceType::String.into()),
..Default::default()
};
schema.enum_values = Some(
values
.iter()
.map(|value| Value::String((*value).to_string()))
.collect(),
);
Schema::Object(schema)
}
fn default_continue() -> bool {
true
}
#[cfg(test)]
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;
use super::STOP_INPUT_FIXTURE;
use super::STOP_OUTPUT_FIXTURE;
use super::StopCommandInput;
use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE;
use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE;
use super::UserPromptSubmitCommandInput;
use super::schema_json;
use super::write_schema_fixtures;
use pretty_assertions::assert_eq;
use serde_json::Value;
use tempfile::TempDir;
fn expected_fixture(name: &str) -> &'static str {
match name {
POST_TOOL_USE_INPUT_FIXTURE => {
include_str!("../schema/generated/post-tool-use.command.input.schema.json")
}
POST_TOOL_USE_OUTPUT_FIXTURE => {
include_str!("../schema/generated/post-tool-use.command.output.schema.json")
}
PERMISSION_REQUEST_INPUT_FIXTURE => {
include_str!("../schema/generated/permission-request.command.input.schema.json")
}
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")
}
PRE_TOOL_USE_OUTPUT_FIXTURE => {
include_str!("../schema/generated/pre-tool-use.command.output.schema.json")
}
SESSION_START_INPUT_FIXTURE => {
include_str!("../schema/generated/session-start.command.input.schema.json")
}
SESSION_START_OUTPUT_FIXTURE => {
include_str!("../schema/generated/session-start.command.output.schema.json")
}
USER_PROMPT_SUBMIT_INPUT_FIXTURE => {
include_str!("../schema/generated/user-prompt-submit.command.input.schema.json")
}
USER_PROMPT_SUBMIT_OUTPUT_FIXTURE => {
include_str!("../schema/generated/user-prompt-submit.command.output.schema.json")
}
STOP_INPUT_FIXTURE => {
include_str!("../schema/generated/stop.command.input.schema.json")
}
STOP_OUTPUT_FIXTURE => {
include_str!("../schema/generated/stop.command.output.schema.json")
}
_ => panic!("unexpected fixture name: {name}"),
}
}
fn normalize_newlines(value: &str) -> String {
value.replace("\r\n", "\n")
}
#[test]
fn generated_hook_schemas_match_fixtures() {
let temp_dir = TempDir::new().expect("create temp dir");
let schema_root = temp_dir.path().join("schema");
write_schema_fixtures(&schema_root).expect("write generated hook schemas");
for fixture in [
POST_TOOL_USE_INPUT_FIXTURE,
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,
SESSION_START_OUTPUT_FIXTURE,
USER_PROMPT_SUBMIT_INPUT_FIXTURE,
USER_PROMPT_SUBMIT_OUTPUT_FIXTURE,
STOP_INPUT_FIXTURE,
STOP_OUTPUT_FIXTURE,
] {
let expected = normalize_newlines(expected_fixture(fixture));
let actual = std::fs::read_to_string(schema_root.join("generated").join(fixture))
.unwrap_or_else(|err| panic!("read generated schema {fixture}: {err}"));
let actual = normalize_newlines(&actual);
assert_eq!(expected, actual, "fixture should match generated schema");
}
}
#[test]
fn turn_scoped_hook_inputs_include_codex_turn_id_extension() {
// Codex intentionally diverges from Claude's public hook docs here so
// internal hook consumers can key off the active turn.
let pre_tool_use: Value = serde_json::from_slice(
&schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
)
.expect("parse pre tool use input schema");
let post_tool_use: Value = serde_json::from_slice(
&schema_json::<PostToolUseCommandInput>()
.expect("serialize post tool use input schema"),
)
.expect("parse post tool use input schema");
let pre_compact: Value = serde_json::from_slice(
&schema_json::<PreCompactCommandInput>().expect("serialize pre compact input schema"),
)
.expect("parse pre compact input schema");
let post_compact: Value = serde_json::from_slice(
&schema_json::<PostCompactCommandInput>().expect("serialize post compact input schema"),
)
.expect("parse post compact input schema");
let permission_request: Value = serde_json::from_slice(
&schema_json::<PermissionRequestCommandInput>()
.expect("serialize permission request input schema"),
)
.expect("parse permission request input schema");
let user_prompt_submit: Value = serde_json::from_slice(
&schema_json::<UserPromptSubmitCommandInput>()
.expect("serialize user prompt submit input schema"),
)
.expect("parse user prompt submit input schema");
let stop: Value = serde_json::from_slice(
&schema_json::<StopCommandInput>().expect("serialize stop input schema"),
)
.expect("parse stop input schema");
for schema in [
&pre_tool_use,
&permission_request,
&post_tool_use,
&pre_compact,
&post_compact,
&user_prompt_submit,
&stop,
] {
assert_eq!(schema["properties"]["turn_id"]["type"], "string");
assert!(
schema["required"]
.as_array()
.expect("schema required fields")
.contains(&Value::String("turn_id".to_string()))
);
}
}
}