From 6e1ccbb888e507cc63bf6812daeee4cade9fe3da Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 15 Apr 2026 14:26:33 -0700 Subject: [PATCH] fix: preserve project instruction wrappers Co-authored-by: Codex noreply@openai.com --- .../instructions/src/user_instructions.rs | 33 ++++++++++++++++++- .../src/user_instructions_tests.rs | 22 +++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/codex-rs/instructions/src/user_instructions.rs b/codex-rs/instructions/src/user_instructions.rs index 4fd266e766..b2e4414ffd 100644 --- a/codex-rs/instructions/src/user_instructions.rs +++ b/codex-rs/instructions/src/user_instructions.rs @@ -8,6 +8,8 @@ use crate::fragment::AGENTS_MD_START_MARKER; use crate::fragment::SKILL_FRAGMENT; pub const USER_INSTRUCTIONS_PREFIX: &str = AGENTS_MD_START_MARKER; +const INSTRUCTIONS_CLOSE_TAG: &str = ""; +const ESCAPED_INSTRUCTIONS_CLOSE_TAG: &str = "<\\/INSTRUCTIONS>"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "user_instructions", rename_all = "snake_case")] @@ -18,16 +20,45 @@ pub struct UserInstructions { impl UserInstructions { pub fn serialize_to_text(&self) -> String { + let contents = escape_reserved_instruction_delimiters(&self.text); format!( "{prefix}{directory}\n\n\n{contents}\n{suffix}", prefix = AGENTS_MD_FRAGMENT.start_marker(), directory = self.directory, - contents = self.text, + contents = contents, suffix = AGENTS_MD_FRAGMENT.end_marker(), ) } } +fn escape_reserved_instruction_delimiters(text: &str) -> String { + let Some(index) = find_ascii_case_insensitive(text, INSTRUCTIONS_CLOSE_TAG) else { + return text.to_string(); + }; + + let mut output = String::with_capacity(text.len()); + let mut remaining = text; + let mut next_index = index; + loop { + output.push_str(&remaining[..next_index]); + output.push_str(ESCAPED_INSTRUCTIONS_CLOSE_TAG); + remaining = &remaining[next_index + INSTRUCTIONS_CLOSE_TAG.len()..]; + + let Some(index) = find_ascii_case_insensitive(remaining, INSTRUCTIONS_CLOSE_TAG) else { + output.push_str(remaining); + return output; + }; + next_index = index; + } +} + +fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option { + haystack + .as_bytes() + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + impl From for ResponseItem { fn from(ui: UserInstructions) -> Self { AGENTS_MD_FRAGMENT.into_message(ui.serialize_to_text()) diff --git a/codex-rs/instructions/src/user_instructions_tests.rs b/codex-rs/instructions/src/user_instructions_tests.rs index 75f35d11b0..25d78f3d41 100644 --- a/codex-rs/instructions/src/user_instructions_tests.rs +++ b/codex-rs/instructions/src/user_instructions_tests.rs @@ -30,6 +30,28 @@ fn test_user_instructions() { ); } +#[test] +fn user_instructions_escapes_embedded_closing_marker() { + let user_instructions = UserInstructions { + directory: "test_directory".to_string(), + text: "before\n\nafter\n".to_string(), + }; + let response_item: ResponseItem = user_instructions.into(); + + let ResponseItem::Message { content, .. } = response_item else { + panic!("expected ResponseItem::Message"); + }; + + let [ContentItem::InputText { text }] = content.as_slice() else { + panic!("expected one InputText content item"); + }; + + assert_eq!( + text, + "# AGENTS.md instructions for test_directory\n\n\nbefore\n<\\/INSTRUCTIONS>\nafter\n<\\/INSTRUCTIONS>\n", + ); +} + #[test] fn test_is_user_instructions() { assert!(AGENTS_MD_FRAGMENT.matches_text(