merge hook developer context messages

This commit is contained in:
Abhinav Vedmala
2026-05-13 11:41:42 -07:00
parent 83decfa300
commit 4ad8ea55bf
5 changed files with 117 additions and 15 deletions

View File

@@ -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 = "<hook_context>";
const END_MARKER: &'static str = "</hook_context>";
fn body(&self) -> String {
self.text.clone()

View File

@@ -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("<hook_context>ROLLED_BACK_HOOK_CONTEXT</hook_context>"),
developer_msg("<collaboration_mode>ROLLED_BACK_DEV_INSTRUCTIONS</collaboration_mode>"),
user_input_text_msg(
"<environment_context><cwd>PRETURN_CONTEXT_DIFF_CWD</cwd></environment_context>",

View File

@@ -30,6 +30,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
COLLABORATION_MODE_OPEN_TAG,
REALTIME_CONVERSATION_OPEN_TAG,
"<personality_spec>",
"<hook_context>",
];
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {

View File

@@ -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<String>) -> Vec<ResponseItem> {
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::<String>();
(role.as_str(), text)
.collect::<Vec<_>>();
(role.as_str(), texts)
}
other => panic!("expected developer message, got {other:?}"),
})
.collect::<Vec<_>>(),
vec![
("developer", "first tide note".to_string()),
("developer", "second tide note".to_string()),
],
vec![(
"developer",
vec![
"<hook_context>first tide note</hook_context>".to_string(),
"<hook_context>second tide note</hook_context>".to_string(),
],
)],
);
}

View File

@@ -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::<Result<Vec<_>>>()?;
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::<Vec<_>>()
.join("\n");
contexts
.iter()
.all(|context| joined.contains(&format!("<hook_context>{context}</hook_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(()));