[codex] Make contextual user fragments dyn-renderable (#23397)

## Why
`ContextualUserFragment` needs to be usable behind `dyn` for render-only
paths, but associated constants made the trait non-object-safe.

## What changed
- Replaced associated constants with trait methods so `dyn
ContextualUserFragment` can render fragments.
- Preserved the existing typed `T::matches_text(text)` registration
pattern via `type_markers()`.
- Kept default `render()` on the main trait so implementations only
provide role, markers, and body.
- Added unit coverage for rendering a `Box<dyn ContextualUserFragment>`.

## Verification
- `cargo test -p codex-core contextual_user_fragment_is_dyn_compatible`
- `just fix -p codex-core`
This commit is contained in:
pakrym-oai
2026-05-19 10:42:54 -07:00
committed by GitHub
parent ae10708ae0
commit ccbf0137db
29 changed files with 350 additions and 99 deletions

View File

@@ -14,9 +14,17 @@ impl ApprovedCommandPrefixSaved {
}
impl ContextualUserFragment for ApprovedCommandPrefixSaved {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
format!("Approved command prefix saved:\n{}", self.prefixes)

View File

@@ -18,9 +18,17 @@ impl AppsInstructions {
}
impl ContextualUserFragment for AppsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = APPS_INSTRUCTIONS_OPEN_TAG;
const END_MARKER: &'static str = APPS_INSTRUCTIONS_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(APPS_INSTRUCTIONS_OPEN_TAG, APPS_INSTRUCTIONS_CLOSE_TAG)
}
fn body(&self) -> String {
format!(

View File

@@ -22,9 +22,20 @@ impl AvailablePluginsInstructions {
}
impl ContextualUserFragment for AvailablePluginsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = PLUGINS_INSTRUCTIONS_OPEN_TAG;
const END_MARKER: &'static str = PLUGINS_INSTRUCTIONS_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
PLUGINS_INSTRUCTIONS_OPEN_TAG,
PLUGINS_INSTRUCTIONS_CLOSE_TAG,
)
}
fn body(&self) -> String {
let mut lines = vec![

View File

@@ -21,9 +21,17 @@ impl From<AvailableSkills> for AvailableSkillsInstructions {
}
impl ContextualUserFragment for AvailableSkillsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = SKILLS_INSTRUCTIONS_OPEN_TAG;
const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG)
}
fn body(&self) -> String {
render_available_skills_body(&self.skill_root_lines, &self.skill_lines)

View File

@@ -22,9 +22,17 @@ impl CollaborationModeInstructions {
}
impl ContextualUserFragment for CollaborationModeInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = COLLABORATION_MODE_OPEN_TAG;
const END_MARKER: &'static str = COLLABORATION_MODE_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(COLLABORATION_MODE_OPEN_TAG, COLLABORATION_MODE_CLOSE_TAG)
}
fn body(&self) -> String {
self.instructions.clone()

View File

@@ -1,8 +1,11 @@
use super::*;
use crate::context::ContextualUserFragment;
use crate::context::GoalContext;
use crate::context::SubagentNotification;
use codex_protocol::items::HookPromptFragment;
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
#[test]
fn detects_environment_context_fragment() {
@@ -38,6 +41,18 @@ fn detects_goal_context_fragment() {
}));
}
#[test]
fn contextual_user_fragment_is_dyn_compatible() {
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
});
assert_eq!(
fragment.render(),
"<goal_context>\nContinue working toward the active thread goal.\n</goal_context>"
);
}
#[test]
fn ignores_regular_user_text() {
assert!(!is_contextual_user_fragment(&ContentItem::InputText {

View File

@@ -269,9 +269,20 @@ impl EnvironmentContext {
}
impl ContextualUserFragment for EnvironmentContext {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
const END_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG,
codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG,
)
}
fn body(&self) -> String {
let mut lines = Vec::new();

View File

@@ -38,37 +38,34 @@ impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationPro
/// in which case the default helpers render only the body and never match
/// arbitrary text.
pub trait ContextualUserFragment {
const ROLE: &'static str;
const START_MARKER: &'static str;
const END_MARKER: &'static str;
fn role() -> &'static str
where
Self: Sized;
fn markers(&self) -> (&'static str, &'static str);
fn body(&self) -> String;
fn type_markers() -> (&'static str, &'static str)
where
Self: Sized;
fn matches_text(text: &str) -> bool
where
Self: Sized,
{
if Self::START_MARKER.is_empty() || Self::END_MARKER.is_empty() {
return false;
}
let trimmed = text.trim_start();
let starts_with_marker = trimmed
.get(..Self::START_MARKER.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::START_MARKER));
let trimmed = trimmed.trim_end();
let ends_with_marker = trimmed
.get(trimmed.len().saturating_sub(Self::END_MARKER.len())..)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::END_MARKER));
starts_with_marker && ends_with_marker
let (start_marker, end_marker) = Self::type_markers();
matches_marked_text(start_marker, end_marker, text)
}
fn render(&self) -> String {
if Self::START_MARKER.is_empty() && Self::END_MARKER.is_empty() {
return self.body();
let (start_marker, end_marker) = self.markers();
let body = self.body();
if start_marker.is_empty() && end_marker.is_empty() {
return body;
}
format!("{}{}{}", Self::START_MARKER, self.body(), Self::END_MARKER)
format!("{start_marker}{body}{end_marker}")
}
fn into(self) -> ResponseItem
@@ -77,7 +74,7 @@ pub trait ContextualUserFragment {
{
ResponseItem::Message {
id: None,
role: Self::ROLE.to_string(),
role: Self::role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
@@ -85,3 +82,19 @@ pub trait ContextualUserFragment {
}
}
}
fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
if start_marker.is_empty() || end_marker.is_empty() {
return false;
}
let trimmed = text.trim_start();
let starts_with_marker = trimmed
.get(..start_marker.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(start_marker));
let trimmed = trimmed.trim_end();
let ends_with_marker = trimmed
.get(trimmed.len().saturating_sub(end_marker.len())..)
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(end_marker));
starts_with_marker && ends_with_marker
}

View File

@@ -8,9 +8,17 @@ pub(crate) struct GoalContext {
}
impl ContextualUserFragment for GoalContext {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<goal_context>";
const END_MARKER: &'static str = "</goal_context>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<goal_context>", "</goal_context>")
}
fn body(&self) -> String {
format!("\n{}\n", self.prompt)

View File

@@ -4,9 +4,17 @@ use super::ContextualUserFragment;
pub(crate) struct GuardianFollowupReviewReminder;
impl ContextualUserFragment for GuardianFollowupReviewReminder {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
concat!(

View File

@@ -12,9 +12,17 @@ impl HookAdditionalContext {
}
impl ContextualUserFragment for HookAdditionalContext {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
self.text.clone()

View File

@@ -17,9 +17,17 @@ impl ImageGenerationInstructions {
}
impl ContextualUserFragment for ImageGenerationInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
format!(

View File

@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
pub(crate) struct LegacyApplyPatchExecCommandWarning;
impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn matches_text(text: &str) -> bool {
let trimmed = text.trim();

View File

@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
pub(crate) struct LegacyModelMismatchWarning;
impl ContextualUserFragment for LegacyModelMismatchWarning {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn matches_text(text: &str) -> bool {
text.trim().starts_with(

View File

@@ -5,9 +5,17 @@ use super::ContextualUserFragment;
pub(crate) struct LegacyUnifiedExecProcessLimitWarning;
impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn matches_text(text: &str) -> bool {
text.trim().starts_with(

View File

@@ -14,9 +14,17 @@ impl ModelSwitchInstructions {
}
impl ContextualUserFragment for ModelSwitchInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<model_switch>";
const END_MARKER: &'static str = "</model_switch>";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<model_switch>", "</model_switch>")
}
fn body(&self) -> String {
format!(

View File

@@ -18,9 +18,17 @@ impl NetworkRuleSaved {
}
impl ContextualUserFragment for NetworkRuleSaved {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
let (action, list_name) = match self.action {

View File

@@ -145,9 +145,17 @@ fn network_access_from_policy(network_policy: NetworkSandboxPolicy) -> NetworkAc
}
impl ContextualUserFragment for PermissionsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<permissions instructions>";
const END_MARKER: &'static str = "</permissions instructions>";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<permissions instructions>", "</permissions instructions>")
}
fn body(&self) -> String {
self.text.clone()

View File

@@ -12,9 +12,17 @@ impl PersonalitySpecInstructions {
}
impl ContextualUserFragment for PersonalitySpecInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "<personality_spec>";
const END_MARKER: &'static str = "</personality_spec>";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<personality_spec>", "</personality_spec>")
}
fn body(&self) -> String {
format!(

View File

@@ -12,9 +12,17 @@ impl PluginInstructions {
}
impl ContextualUserFragment for PluginInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = "";
const END_MARKER: &'static str = "";
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
self.text.clone()

View File

@@ -18,9 +18,20 @@ impl RealtimeEndInstructions {
}
impl ContextualUserFragment for RealtimeEndInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
REALTIME_CONVERSATION_OPEN_TAG,
REALTIME_CONVERSATION_CLOSE_TAG,
)
}
fn body(&self) -> String {
format!(

View File

@@ -8,9 +8,20 @@ const REALTIME_START_INSTRUCTIONS: &str = include_str!("prompts/realtime/realtim
pub(crate) struct RealtimeStartInstructions;
impl ContextualUserFragment for RealtimeStartInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
REALTIME_CONVERSATION_OPEN_TAG,
REALTIME_CONVERSATION_CLOSE_TAG,
)
}
fn body(&self) -> String {
format!("\n{}\n", REALTIME_START_INSTRUCTIONS.trim())

View File

@@ -16,9 +16,20 @@ impl RealtimeStartWithInstructions {
}
impl ContextualUserFragment for RealtimeStartWithInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = REALTIME_CONVERSATION_OPEN_TAG;
const END_MARKER: &'static str = REALTIME_CONVERSATION_CLOSE_TAG;
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
REALTIME_CONVERSATION_OPEN_TAG,
REALTIME_CONVERSATION_CLOSE_TAG,
)
}
fn body(&self) -> String {
format!("\n{}\n", self.instructions)

View File

@@ -20,9 +20,17 @@ impl From<&SkillInjection> for SkillInstructions {
}
impl ContextualUserFragment for SkillInstructions {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<skill>";
const END_MARKER: &'static str = "</skill>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<skill>", "</skill>")
}
fn body(&self) -> String {
format!(

View File

@@ -18,9 +18,17 @@ impl SubagentNotification {
}
impl ContextualUserFragment for SubagentNotification {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<subagent_notification>";
const END_MARKER: &'static str = "</subagent_notification>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<subagent_notification>", "</subagent_notification>")
}
fn body(&self) -> String {
format!(

View File

@@ -17,9 +17,17 @@ impl TurnAborted {
}
impl ContextualUserFragment for TurnAborted {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<turn_aborted>";
const END_MARKER: &'static str = "</turn_aborted>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<turn_aborted>", "</turn_aborted>")
}
fn body(&self) -> String {
format!("\n{}\n", self.guidance)

View File

@@ -7,9 +7,17 @@ pub(crate) struct UserInstructions {
}
impl ContextualUserFragment for UserInstructions {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "# AGENTS.md instructions for ";
const END_MARKER: &'static str = "</INSTRUCTIONS>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("# AGENTS.md instructions for ", "</INSTRUCTIONS>")
}
fn body(&self) -> String {
format!("{}\n\n<INSTRUCTIONS>\n{}\n", self.directory, self.text)

View File

@@ -27,9 +27,17 @@ impl UserShellCommand {
}
impl ContextualUserFragment for UserShellCommand {
const ROLE: &'static str = "user";
const START_MARKER: &'static str = "<user_shell_command>";
const END_MARKER: &'static str = "</user_shell_command>";
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<user_shell_command>", "</user_shell_command>")
}
fn body(&self) -> String {
format!(

View File

@@ -1634,7 +1634,7 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
fn goal_context_input_item(prompt: String) -> ResponseInputItem {
let context = GoalContext { prompt };
ResponseInputItem::Message {
role: <GoalContext as ContextualUserFragment>::ROLE.to_string(),
role: GoalContext::role().to_string(),
content: vec![ContentItem::InputText {
text: context.render(),
}],