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(()));