diff --git a/codex-rs/core/src/context/hook_additional_context.rs b/codex-rs/core/src/context/hook_additional_context.rs index 01c95a6524..5ef6218aa0 100644 --- a/codex-rs/core/src/context/hook_additional_context.rs +++ b/codex-rs/core/src/context/hook_additional_context.rs @@ -13,8 +13,8 @@ impl HookAdditionalContext { impl ContextualUserFragment for HookAdditionalContext { const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + const START_MARKER: &'static str = ""; + const END_MARKER: &'static str = ""; fn body(&self) -> String { self.text.clone() diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 74f4d29bfb..4cafbeee93 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -902,6 +902,7 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { user_input_text_msg("turn 1 user"), assistant_msg("turn 1 assistant"), developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."), + developer_msg("ROLLED_BACK_HOOK_CONTEXT"), developer_msg("ROLLED_BACK_DEV_INSTRUCTIONS"), user_input_text_msg( "PRETURN_CONTEXT_DIFF_CWD", diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index e7c79e6dd2..3fabe854ac 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -30,6 +30,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ COLLABORATION_MODE_OPEN_TAG, REALTIME_CONVERSATION_OPEN_TAG, "", + "", ]; pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 3d72e4a7c6..c0d1364ea0 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -33,6 +33,7 @@ use serde_json::Value; use crate::context::ContextualUserFragment; use crate::context::HookAdditionalContext; +use crate::context_manager::updates::build_developer_update_item; use crate::event_mapping::parse_turn_item; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -452,11 +453,13 @@ pub(crate) async fn record_additional_contexts( } fn additional_context_messages(additional_contexts: Vec) -> Vec { - additional_contexts + let sections = additional_contexts .into_iter() .map(HookAdditionalContext::new) - .map(ContextualUserFragment::into) - .collect() + .map(|context| context.render()) + .collect(); + + build_developer_update_item(sections).into_iter().collect() } async fn emit_hook_started_events( @@ -616,36 +619,39 @@ mod tests { use codex_utils_absolute_path::test_support::test_path_buf; #[test] - fn additional_context_messages_stay_separate_and_ordered() { + fn additional_context_messages_merge_and_preserve_order() { let messages = additional_context_messages(vec![ "first tide note".to_string(), "second tide note".to_string(), ]); - assert_eq!(messages.len(), 2); + assert_eq!(messages.len(), 1); assert_eq!( messages .iter() .map(|message| match message { codex_protocol::models::ResponseItem::Message { role, content, .. } => { - let text = content + let texts = content .iter() .map(|item| match item { - ContentItem::InputText { text } => text.as_str(), + ContentItem::InputText { text } => text.clone(), ContentItem::InputImage { .. } | ContentItem::OutputText { .. } => { panic!("expected input text content, got {item:?}") } }) - .collect::(); - (role.as_str(), text) + .collect::>(); + (role.as_str(), texts) } other => panic!("expected developer message, got {other:?}"), }) .collect::>(), - vec![ - ("developer", "first tide note".to_string()), - ("developer", "second tide note".to_string()), - ], + vec![( + "developer", + vec![ + "first tide note".to_string(), + "second tide note".to_string(), + ], + )], ); } diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 635aeaf488..821fbc94e4 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -180,6 +180,50 @@ else: Ok(()) } +fn write_parallel_session_start_hooks(home: &Path, contexts: &[&str]) -> Result<()> { + let hook_entries = contexts + .iter() + .enumerate() + .map(|(index, context)| { + let script_path = home.join(format!("session_start_hook_{index}.py")); + let context_json = + serde_json::to_string(context).context("serialize session start context")?; + let script = format!( + r#"import json + +print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "SessionStart", + "additionalContext": {context_json} + }} +}})) +"# + ); + fs::write(&script_path, script).with_context(|| { + format!( + "write session start hook script fixture at {}", + script_path.display() + ) + })?; + Ok(serde_json::json!({ + "type": "command", + "command": format!("python3 {}", script_path.display()), + })) + }) + .collect::>>()?; + + let hooks = serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": hook_entries, + }] + } + }); + + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_user_prompt_submit_hook( home: &Path, blocked_prompt: &str, @@ -1064,6 +1108,56 @@ async fn session_start_hook_spills_large_additional_context() -> Result<()> { Ok(()) } +#[tokio::test] +async fn parallel_session_start_additional_contexts_share_one_developer_message() -> 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", "merged hook context observed"), + ev_completed("resp-1"), + ]), + ) + .await; + let contexts = ["first tide note", "second tide note"]; + + let mut builder = test_codex() + .with_pre_build_hook(move |home| { + if let Err(error) = write_parallel_session_start_hooks(home, &contexts) { + panic!("failed to write parallel session start hook fixtures: {error}"); + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + + test.submit_turn("hello").await?; + + let request = response.single_request(); + let merged_developer_messages = request + .input() + .iter() + .filter(|item| item.get("role").and_then(Value::as_str) == Some("developer")) + .filter(|item| { + let joined = item["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|content| content.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + contexts + .iter() + .all(|context| joined.contains(&format!("{context}"))) + }) + .count(); + assert_eq!(merged_developer_messages, 1); + + Ok(()) +} + #[tokio::test] async fn stop_hook_spills_large_continuation_prompt() -> Result<()> { skip_if_no_network!(Ok(()));