From 56ff187df5771a91ce3939bb0bcdede23af48a03 Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Tue, 21 Apr 2026 15:38:51 -0700 Subject: [PATCH] core: remove duplicate session-start hook check --- codex-rs/core/src/session/turn.rs | 6 +- codex-rs/core/tests/suite/hooks.rs | 127 +++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 98f572243a..c5db2f1c6d 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -297,6 +297,8 @@ pub(crate) async fn run_turn( }) .collect::>(); + // Run SessionStart before UserPromptSubmit so startup hooks can shape the + // turn seen by prompt-submit hooks. if run_pending_session_start_hooks(&sess, &turn_context).await { return None; } @@ -377,10 +379,6 @@ pub(crate) async fn run_turn( let mut can_drain_pending_input = input.is_empty(); loop { - if run_pending_session_start_hooks(&sess, &turn_context).await { - break; - } - // Note that pending_input would be something like a message the user // submitted through the UI while the model was running. Though the UI // may support this, the model might not. diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index b2d8e07b65..0186423b48 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -443,6 +443,70 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: Ok(()) } +fn write_session_start_and_user_prompt_submit_order_hooks(home: &Path) -> Result<()> { + let session_start_script_path = home.join("session_start_hook.py"); + let user_prompt_submit_script_path = home.join("user_prompt_submit_hook.py"); + let log_path = home.join("hook_order_log.jsonl"); + + let session_start_script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +record = {{ + "event": "session_start", + "transcript_path": payload.get("transcript_path"), +}} + +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record) + "\n") +"#, + log_path = log_path.display(), + ); + let user_prompt_submit_script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +record = {{ + "event": "user_prompt_submit", + "prompt": payload.get("prompt"), +}} + +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record) + "\n") +"#, + log_path = log_path.display(), + ); + let hooks = serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", session_start_script_path.display()), + "statusMessage": "running session start hook", + }] + }], + "UserPromptSubmit": [{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", user_prompt_submit_script_path.display()), + "statusMessage": "running user prompt submit hook", + }] + }] + } + }); + + fs::write(&session_start_script_path, session_start_script) + .context("write session start order hook script")?; + fs::write(&user_prompt_submit_script_path, user_prompt_submit_script) + .context("write user prompt submit order hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn rollout_hook_prompt_texts(text: &str) -> Result> { let mut texts = Vec::new(); for line in text.lines() { @@ -561,6 +625,15 @@ fn read_user_prompt_submit_hook_inputs(home: &Path) -> Result Result> { + fs::read_to_string(home.join("hook_order_log.jsonl")) + .context("read hook order log")? + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("parse hook order log line")) + .collect() +} + fn ev_message_item_done(id: &str, text: &str) -> Value { serde_json::json!({ "type": "response.output_item.done", @@ -754,6 +827,60 @@ async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn session_start_runs_before_user_prompt_submit() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello from the reef"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_pre_build_hook(|home| { + if let Err(error) = write_session_start_and_user_prompt_submit_order_hooks(home) { + panic!("failed to write session start order hook test fixture: {error}"); + } + }) + .with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn("hello").await?; + + let hook_inputs = read_hook_order_inputs(test.codex_home_path())?; + assert_eq!(hook_inputs.len(), 2); + assert_eq!( + hook_inputs + .iter() + .map(|input| input["event"] + .as_str() + .expect("hook event name") + .to_string()) + .collect::>(), + vec![ + "session_start".to_string(), + "user_prompt_submit".to_string(), + ], + ); + assert_eq!( + hook_inputs[1].get("prompt").and_then(Value::as_str), + Some("hello"), + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()> { skip_if_no_network!(Ok(()));