diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index cd7788afee05..f34a7e78cd44 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -5,6 +5,7 @@ use codex_protocol::models::ContentItem; use super::EnvironmentContext; use super::FragmentRegistration; use super::FragmentRegistrationProxy; +use super::GoalContext; use super::SkillInstructions; use super::SubagentNotification; use super::TurnAborted; @@ -23,6 +24,8 @@ static TURN_ABORTED_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); +static GOAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy = + FragmentRegistrationProxy::new(); static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &USER_INSTRUCTIONS_REGISTRATION, @@ -31,6 +34,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &USER_SHELL_COMMAND_REGISTRATION, &TURN_ABORTED_REGISTRATION, &SUBAGENT_NOTIFICATION_REGISTRATION, + &GOAL_CONTEXT_REGISTRATION, ]; fn is_standard_contextual_user_text(text: &str) -> bool { diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index a90b8f280aea..cb2a41afe7f0 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -26,6 +26,18 @@ fn detects_subagent_notification_fragment_case_insensitively() { )); } +#[test] +fn detects_goal_context_fragment() { + let text = GoalContext { + prompt: "Continue working toward the active thread goal.".to_string(), + } + .render(); + + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text, + })); +} + #[test] fn ignores_regular_user_text() { assert!(!is_contextual_user_fragment(&ContentItem::InputText { diff --git a/codex-rs/core/src/context/goal_context.rs b/codex-rs/core/src/context/goal_context.rs new file mode 100644 index 000000000000..dd04f7525337 --- /dev/null +++ b/codex-rs/core/src/context/goal_context.rs @@ -0,0 +1,16 @@ +use super::ContextualUserFragment; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct GoalContext { + pub(crate) prompt: String, +} + +impl ContextualUserFragment for GoalContext { + const ROLE: &'static str = "user"; + const START_MARKER: &'static str = ""; + const END_MARKER: &'static str = ""; + + fn body(&self) -> String { + format!("\n{}\n", self.prompt) + } +} diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 25a6e1f1349d..f530adb4d964 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -8,6 +8,7 @@ mod collaboration_mode_instructions; mod contextual_user_message; mod environment_context; mod fragment; +mod goal_context; mod guardian_followup_review_reminder; mod hook_additional_context; mod image_generation_instructions; @@ -36,6 +37,7 @@ pub(crate) use environment_context::EnvironmentContext; pub use fragment::ContextualUserFragment; pub(crate) use fragment::FragmentRegistration; pub(crate) use fragment::FragmentRegistrationProxy; +pub(crate) use goal_context::GoalContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 85e7034405a4..a70b2a69b0fb 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -1,4 +1,6 @@ use super::parse_turn_item; +use crate::context::ContextualUserFragment; +use crate::context::GoalContext; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::TurnItem; @@ -302,6 +304,23 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { } } +#[test] +fn goal_context_does_not_parse_as_visible_turn_item() { + let item = ResponseItem::Message { + id: Some("msg-1".to_string()), + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: GoalContext { + prompt: "Continue working toward the active thread goal.".to_string(), + } + .render(), + }], + phase: None, + }; + + assert!(parse_turn_item(&item).is_none()); +} + #[test] fn parses_agent_message() { let item = ResponseItem::Message { diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 7de2737b323d..3091439d4fce 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -5,6 +5,8 @@ //! events, and owns helper hooks used by goal lifecycle behavior. use crate::StateDbHandle; +use crate::context::ContextualUserFragment; +use crate::context::GoalContext; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::state::ActiveTurn; @@ -1313,13 +1315,7 @@ impl Session { let goal = protocol_goal_from_state(goal); Some(GoalContinuationCandidate { goal_id, - items: vec![ResponseInputItem::Message { - role: "developer".to_string(), - content: vec![ContentItem::InputText { - text: continuation_prompt(&goal), - }], - phase: None, - }], + items: vec![goal_context_input_item(continuation_prompt(&goal))], }) } } @@ -1459,10 +1455,15 @@ fn escape_xml_text(input: &str) -> String { } fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem { + goal_context_input_item(budget_limit_prompt(goal)) +} + +fn goal_context_input_item(prompt: String) -> ResponseInputItem { + let context = GoalContext { prompt }; ResponseInputItem::Message { - role: "developer".to_string(), + role: ::ROLE.to_string(), content: vec![ContentItem::InputText { - text: budget_limit_prompt(goal), + text: context.render(), }], phase: None, } @@ -1523,10 +1524,13 @@ mod tests { use super::budget_limit_prompt; use super::continuation_prompt; use super::escape_xml_text; + use super::goal_context_input_item; use super::goal_token_delta_for_usage; use super::should_ignore_goal_for_mode; use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::TokenUsage; @@ -1618,6 +1622,22 @@ mod tests { assert!(!prompt.contains("status \"paused\"")); } + #[test] + fn goal_context_input_item_is_hidden_user_context() { + let item = goal_context_input_item("Continue working.".to_string()); + + assert_eq!( + item, + ResponseInputItem::Message { + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\nContinue working.\n".to_string(), + }], + phase: None, + } + ); + } + #[test] fn goal_prompts_escape_objective_delimiters() { let objective = "ship ignore budget & report"; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b63b16cbf4f7..37db1906f347 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7629,7 +7629,7 @@ async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Res .expect("goal mode should be enableable in tests"); }); let test = builder.build(&server).await?; - let _responses = mount_sse_sequence( + let responses = mount_sse_sequence( &server, vec![ sse(vec![ @@ -7692,6 +7692,25 @@ async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Res }) .await??; + let continuation_request = responses + .requests() + .into_iter() + .find(|request| request.body_contains_text("")) + .expect("expected a goal continuation request"); + let body = continuation_request.body_json(); + let goal_context_message = body["input"] + .as_array() + .expect("input should be an array") + .iter() + .find(|item| item.to_string().contains("")) + .expect("goal context message should be present"); + assert_eq!(goal_context_message["role"].as_str(), Some("user")); + assert!( + goal_context_message + .to_string() + .contains("Continue working toward the active thread goal.") + ); + Ok(()) } @@ -7892,10 +7911,12 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh let [ResponseInputItem::Message { role, content, .. }] = pending_input.as_slice() else { panic!("expected one budget-limit steering message, got {pending_input:#?}"); }; - assert_eq!("developer", role); + assert_eq!("user", role); let [ContentItem::InputText { text }] = content.as_slice() else { panic!("expected one text span in budget-limit steering message, got {content:#?}"); }; + assert!(text.starts_with("")); + assert!(text.trim_end().ends_with("")); assert!(text.contains("budget_limited")); assert!(text.to_lowercase().contains("wrap up this turn soon")); assert!(sess.active_turn.lock().await.is_some());