diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 5c2284bfe..793b4fedb 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -88,15 +88,20 @@ fn write_user_prompt_submit_hook( additional_context: &str, ) -> Result<()> { let script_path = home.join("user_prompt_submit_hook.py"); + let log_path = home.join("user_prompt_submit_hook_log.jsonl"); + let log_path = log_path.display(); let blocked_prompt_json = serde_json::to_string(blocked_prompt).context("serialize blocked prompt for test")?; let additional_context_json = serde_json::to_string(additional_context) .context("serialize user prompt submit additional context for test")?; 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") if payload.get("prompt") == {blocked_prompt_json}: print(json.dumps({{ @@ -202,6 +207,15 @@ fn read_session_start_hook_inputs(home: &Path) -> Result> .collect() } +fn read_user_prompt_submit_hook_inputs(home: &Path) -> Result> { + fs::read_to_string(home.join("user_prompt_submit_hook_log.jsonl")) + .context("read user prompt submit hook log")? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("parse user prompt submit hook log line")) + .collect() +} + fn ev_message_item_done(id: &str, text: &str) -> Value { serde_json::json!({ "type": "response.output_item.done", @@ -305,6 +319,31 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> { let hook_inputs = read_stop_hook_inputs(test.codex_home_path())?; assert_eq!(hook_inputs.len(), 3); + let stop_turn_ids = hook_inputs + .iter() + .map(|input| { + input["turn_id"] + .as_str() + .expect("stop hook input turn_id") + .to_string() + }) + .collect::>(); + assert!( + stop_turn_ids.iter().all(|turn_id| !turn_id.is_empty()), + "stop hook turn ids should be non-empty", + ); + let first_stop_turn_id = stop_turn_ids + .first() + .expect("stop hook inputs should include a first turn id") + .clone(); + assert_eq!( + stop_turn_ids, + vec![ + first_stop_turn_id.clone(), + first_stop_turn_id.clone(), + first_stop_turn_id, + ], + ); assert_eq!( hook_inputs .iter() @@ -508,6 +547,30 @@ async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() "second request should include the accepted prompt", ); + let hook_inputs = read_user_prompt_submit_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 2); + assert_eq!( + hook_inputs + .iter() + .map(|input| { + input["prompt"] + .as_str() + .expect("user prompt submit hook prompt") + .to_string() + }) + .collect::>(), + vec![ + "blocked first prompt".to_string(), + "second prompt".to_string() + ], + ); + assert!( + hook_inputs.iter().all(|input| input["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty())), + "blocked and accepted prompt hooks should both receive a non-empty turn_id", + ); + Ok(()) } @@ -624,6 +687,50 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu "second request should not include the blocked queued prompt", ); + let hook_inputs = read_user_prompt_submit_hook_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 3); + assert_eq!( + hook_inputs + .iter() + .map(|input| { + input["prompt"] + .as_str() + .expect("queued prompt hook prompt") + .to_string() + }) + .collect::>(), + vec![ + "initial prompt".to_string(), + "accepted queued prompt".to_string(), + "blocked queued prompt".to_string(), + ], + ); + let queued_turn_ids = hook_inputs + .iter() + .map(|input| { + input["turn_id"] + .as_str() + .expect("queued prompt hook turn_id") + .to_string() + }) + .collect::>(); + assert!( + queued_turn_ids.iter().all(|turn_id| !turn_id.is_empty()), + "queued prompt hook turn ids should be non-empty", + ); + let first_queued_turn_id = queued_turn_ids + .first() + .expect("queued prompt hook inputs should include a first turn id") + .clone(); + assert_eq!( + queued_turn_ids, + vec![ + first_queued_turn_id.clone(), + first_queued_turn_id.clone(), + first_queued_turn_id, + ], + ); + server.shutdown().await; Ok(()) } diff --git a/codex-rs/hooks/schema/generated/stop.command.input.schema.json b/codex-rs/hooks/schema/generated/stop.command.input.schema.json index 9e500fd83..dbd4a3f64 100644 --- a/codex-rs/hooks/schema/generated/stop.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/stop.command.input.schema.json @@ -41,6 +41,10 @@ }, "transcript_path": { "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" } }, "required": [ @@ -51,7 +55,8 @@ "permission_mode", "session_id", "stop_hook_active", - "transcript_path" + "transcript_path", + "turn_id" ], "title": "stop.command.input", "type": "object" diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json index 6198ecf33..be5e16fc5 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.input.schema.json @@ -38,6 +38,10 @@ }, "transcript_path": { "$ref": "#/definitions/NullableString" + }, + "turn_id": { + "description": "Codex extension: expose the active turn id to internal turn-scoped hooks.", + "type": "string" } }, "required": [ @@ -47,7 +51,8 @@ "permission_mode", "prompt", "session_id", - "transcript_path" + "transcript_path", + "turn_id" ], "title": "user-prompt-submit.command.input", "type": "object" diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 434e12f50..837f287af 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -14,6 +14,7 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::schema::NullableString; use crate::schema::StopCommandInput; #[derive(Debug, Clone)] @@ -75,15 +76,17 @@ pub(crate) async fn run( }; } - let input_json = match serde_json::to_string(&StopCommandInput::new( - request.session_id.to_string(), - request.transcript_path.clone(), - request.cwd.display().to_string(), - request.model.clone(), - request.permission_mode.clone(), - request.stop_hook_active, - request.last_assistant_message.clone(), - )) { + let input_json = match serde_json::to_string(&StopCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "Stop".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + stop_hook_active: request.stop_hook_active, + last_assistant_message: NullableString::from_string(request.last_assistant_message.clone()), + }) { Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome(common::serialization_failure_hook_events( diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index cc937d44d..b909c183b 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -14,6 +14,7 @@ use crate::engine::ConfiguredHandler; use crate::engine::command_runner::CommandRunResult; use crate::engine::dispatcher; use crate::engine::output_parser; +use crate::schema::NullableString; use crate::schema::UserPromptSubmitCommandInput; #[derive(Debug, Clone)] @@ -75,14 +76,16 @@ pub(crate) async fn run( }; } - let input_json = match serde_json::to_string(&UserPromptSubmitCommandInput::new( - request.session_id.to_string(), - request.transcript_path.clone(), - request.cwd.display().to_string(), - request.model.clone(), - request.permission_mode.clone(), - request.prompt.clone(), - )) { + let input_json = match serde_json::to_string(&UserPromptSubmitCommandInput { + session_id: request.session_id.to_string(), + turn_id: request.turn_id.clone(), + transcript_path: NullableString::from_path(request.transcript_path.clone()), + cwd: request.cwd.display().to_string(), + hook_event_name: "UserPromptSubmit".to_string(), + model: request.model.clone(), + permission_mode: request.permission_mode.clone(), + prompt: request.prompt.clone(), + }) { Ok(input_json) => input_json, Err(error) => { return serialization_failure_outcome(common::serialization_failure_hook_events( diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index 3b896cfa4..067658541 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -25,11 +25,11 @@ const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json"; pub(crate) struct NullableString(Option); impl NullableString { - fn from_path(path: Option) -> Self { + pub(crate) fn from_path(path: Option) -> Self { Self(path.map(|path| path.display().to_string())) } - fn from_string(value: Option) -> Self { + pub(crate) fn from_string(value: Option) -> Self { Self(value) } } @@ -178,6 +178,8 @@ impl SessionStartCommandInput { #[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")] @@ -188,32 +190,13 @@ pub(crate) struct UserPromptSubmitCommandInput { pub prompt: String, } -impl UserPromptSubmitCommandInput { - pub(crate) fn new( - session_id: impl Into, - transcript_path: Option, - cwd: impl Into, - model: impl Into, - permission_mode: impl Into, - prompt: impl Into, - ) -> Self { - Self { - session_id: session_id.into(), - transcript_path: NullableString::from_path(transcript_path), - cwd: cwd.into(), - hook_event_name: "UserPromptSubmit".to_string(), - model: model.into(), - permission_mode: permission_mode.into(), - prompt: prompt.into(), - } - } -} - #[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")] @@ -225,29 +208,6 @@ pub(crate) struct StopCommandInput { pub last_assistant_message: NullableString, } -impl StopCommandInput { - pub(crate) fn new( - session_id: impl Into, - transcript_path: Option, - cwd: impl Into, - model: impl Into, - permission_mode: impl Into, - stop_hook_active: bool, - last_assistant_message: Option, - ) -> Self { - Self { - session_id: session_id.into(), - transcript_path: NullableString::from_path(transcript_path), - cwd: cwd.into(), - hook_event_name: "Stop".to_string(), - model: model.into(), - permission_mode: permission_mode.into(), - stop_hook_active, - last_assistant_message: NullableString::from_string(last_assistant_message), - } - } -} - pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> { let generated_dir = schema_root.join(GENERATED_DIR); ensure_empty_dir(&generated_dir)?; @@ -390,10 +350,14 @@ mod tests { 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 { @@ -445,4 +409,29 @@ mod tests { 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 user_prompt_submit: Value = serde_json::from_slice( + &schema_json::() + .expect("serialize user prompt submit input schema"), + ) + .expect("parse user prompt submit input schema"); + let stop: Value = serde_json::from_slice( + &schema_json::().expect("serialize stop input schema"), + ) + .expect("parse stop input schema"); + + for schema in [&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())) + ); + } + } }