diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 6d201bdee3..b6caa2f71d 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -37,9 +37,11 @@ codex-core = { workspace = true } codex-core-plugins = { workspace = true } codex-device-key = { workspace = true } codex-exec-server = { workspace = true } +codex-extension-api = { workspace = true } codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } +codex-git-attribution = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs new file mode 100644 index 0000000000..3d6f64793e --- /dev/null +++ b/codex-rs/app-server/src/extensions.rs @@ -0,0 +1,13 @@ +use std::sync::Arc; + +use codex_core::ExtensionContext; +use codex_extension_api::ExtensionRegistry; +use codex_extension_api::ExtensionRegistryBuilder; + +pub(crate) fn thread_extensions() -> Arc> { + Arc::new( + ExtensionRegistryBuilder::::new() + .with_extension(codex_git_attribution::extension()) + .build(), + ) +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 08aab99f65..5a8a357519 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -82,6 +82,7 @@ mod config_manager_service; mod connection_rpc_gate; mod dynamic_tools; mod error_code; +mod extensions; mod filters; mod fs_watch; mod fuzzy_file_search; diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 8e1ccd3c0a..6a28c31e2b 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -120,6 +120,7 @@ mod tests { use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use tempfile::TempDir; + use crate::extensions::thread_extensions; #[tokio::test] async fn strict_refresh_reports_thread_planning_failures() -> anyhow::Result<()> { @@ -183,6 +184,7 @@ mod tests { auth_manager, SessionSource::Exec, Arc::new(EnvironmentManager::default_for_tests()), + thread_extensions(), /*analytics_events_client*/ None, thread_store, Some(state_db.clone()), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 2e7414a178..c098b4680b 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -7,6 +7,7 @@ use std::sync::atomic::AtomicBool; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; use crate::error_code::invalid_request; +use crate::extensions::thread_extensions; use crate::fs_watch::FsWatchManager; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; @@ -298,6 +299,7 @@ impl MessageProcessor { auth_manager.clone(), session_source, environment_manager, + thread_extensions(), Some(analytics_events_client.clone()), Arc::clone(&thread_store), state_db.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 11fa4109bf..3c23c6a4e3 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -364,6 +364,7 @@ impl Session { skills_manager: Arc, plugins_manager: Arc, mcp_manager: Arc, + extensions: Arc>, skills_watcher: Arc, agent_control: AgentControl, environment_manager: Arc, @@ -845,6 +846,9 @@ impl Session { skills_manager, plugins_manager: Arc::clone(&plugins_manager), mcp_manager: Arc::clone(&mcp_manager), + extensions, + session_extension_data: codex_extension_api::ExtensionData::new(), + thread_extension_data: codex_extension_api::ExtensionData::new(), skills_watcher, agent_control, network_proxy, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b250cadfb0..846489647c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3596,6 +3596,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { skills_manager, plugins_manager, mcp_manager, + Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), Arc::new(SkillsWatcher::noop()), AgentControl::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), @@ -3743,6 +3744,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { skills_manager, plugins_manager, mcp_manager, + extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), + session_extension_data: codex_extension_api::ExtensionData::new(), + thread_extension_data: codex_extension_api::ExtensionData::new(), skills_watcher, agent_control, network_proxy: None, @@ -3922,6 +3926,7 @@ async fn make_session_with_config_and_rx( skills_manager, plugins_manager, mcp_manager, + Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), Arc::new(SkillsWatcher::noop()), AgentControl::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), @@ -4024,6 +4029,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( skills_manager, plugins_manager, mcp_manager, + Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), Arc::new(SkillsWatcher::noop()), agent_control, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), @@ -5448,6 +5454,9 @@ where skills_manager, plugins_manager, mcp_manager, + extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()), + session_extension_data: codex_extension_api::ExtensionData::new(), + thread_extension_data: codex_extension_api::ExtensionData::new(), skills_watcher, agent_control, network_proxy: None, @@ -5994,6 +6003,52 @@ async fn make_multi_agent_v2_usage_hint_test_session( (session, turn_context) } +#[tokio::test] +async fn build_initial_context_includes_git_attribution_from_extensions() { + let (mut session, mut turn_context) = make_session_and_context().await; + session.services.extensions = Arc::new( + codex_extension_api::ExtensionRegistryBuilder::::new() + .with_extension(codex_git_attribution::extension()) + .build(), + ); + turn_context + .features + .enable(Feature::CodexGitCommit) + .expect("test features should allow git attribution"); + + let initial_context = session.build_initial_context(&turn_context).await; + let developer_messages = developer_message_texts(&initial_context); + + assert!( + developer_messages + .iter() + .flatten() + .any(|text| text.contains("Co-authored-by: Codex ")), + "expected git attribution developer text, got {developer_messages:?}" + ); +} + +#[tokio::test] +async fn build_initial_context_omits_git_attribution_when_feature_is_disabled() { + let (mut session, turn_context) = make_session_and_context().await; + session.services.extensions = Arc::new( + codex_extension_api::ExtensionRegistryBuilder::::new() + .with_extension(codex_git_attribution::extension()) + .build(), + ); + + let initial_context = session.build_initial_context(&turn_context).await; + let developer_messages = developer_message_texts(&initial_context); + + assert!( + !developer_messages + .iter() + .flatten() + .any(|text| text.contains("Co-authored-by:")), + "did not expect git attribution developer text, got {developer_messages:?}" + ); +} + #[tokio::test] async fn build_initial_context_adds_multi_agent_v2_root_usage_hint_as_developer_message() { let (session, turn_context) = diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 857ec950c2..66b85b432f 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -743,6 +743,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { skills_manager, plugins_manager, mcp_manager, + extensions: crate::empty_extension_registry(), skills_watcher, conversation_history: InitialHistory::New, session_source: SessionSource::SubAgent(SubAgentSource::Other( diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 9cd9e97fbb..d89f74c015 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -6,6 +6,7 @@ use crate::agent::AgentControl; use crate::client::ModelClient; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; +use crate::extensions::ExtensionContext; use crate::guardian::GuardianRejection; use crate::guardian::GuardianRejectionCircuitBreaker; use crate::mcp::McpManager; @@ -18,6 +19,8 @@ use arc_swap::ArcSwap; use codex_analytics::AnalyticsEventsClient; use codex_core_plugins::PluginsManager; use codex_exec_server::EnvironmentManager; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistry; use codex_hooks::Hooks; use codex_login::AuthManager; use codex_mcp::McpConnectionManager; @@ -59,6 +62,9 @@ pub(crate) struct SessionServices { pub(crate) skills_manager: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, + pub(crate) extensions: Arc>, + pub(crate) session_extension_data: ExtensionData, + pub(crate) thread_extension_data: ExtensionData, pub(crate) skills_watcher: Arc, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: Option, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 6f44673f13..b80064f1ab 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::ThreadManager; use crate::config::AgentRoleConfig; use crate::config::DEFAULT_AGENT_MAX_DEPTH; +use crate::empty_extension_registry; use crate::function_tool::FunctionCallError; use crate::init_state_db; use crate::session::tests::make_session_and_context; @@ -3163,6 +3164,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + empty_extension_registry(), /*analytics_events_client*/ None, thread_store_from_config(&config, state_db.clone()), state_db.clone(), diff --git a/codex-rs/ext/git-attribution/src/lib.rs b/codex-rs/ext/git-attribution/src/lib.rs index 3a7e99195c..dcdafd3dc4 100644 --- a/codex-rs/ext/git-attribution/src/lib.rs +++ b/codex-rs/ext/git-attribution/src/lib.rs @@ -2,13 +2,14 @@ use std::sync::Arc; use codex_extension_api::CodexExtension; use codex_extension_api::ContextContributor; +use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::PromptFragment; -use codex_extension_api::Stores; const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex "; pub trait GitAttributionContext { + fn git_attribution_enabled(&self) -> bool; fn commit_attribution(&self) -> Option<&str>; } @@ -24,6 +25,9 @@ impl GitAttributionExtension { /// Returns the model-visible trailer instruction, if attribution is enabled. pub fn instruction(&self, context: &C) -> Option { + if !context.git_attribution_enabled() { + return None; + } let trailer = build_commit_message_trailer(context.commit_attribution())?; Some(format!( "When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block." @@ -32,9 +36,14 @@ impl GitAttributionExtension { } impl ContextContributor for GitAttributionExtension { - fn contribute(&self, context: &C, _stores: &Stores<'_>) -> Vec { + fn contribute( + &self, + context: &C, + _session_store: &ExtensionData, + _thread_store: &ExtensionData, + ) -> Vec { self.instruction(context) - .map(PromptFragment::developer_capability) + .map(PromptFragment::developer_policy) .into_iter() .collect() } @@ -51,7 +60,6 @@ pub fn extension() -> Arc { Arc::new(GitAttributionExtension::new()) } -// This is just a copy/paste fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option { let value = resolve_attribution_value(config_attribution)?; Some(format!("Co-authored-by: {value}")) @@ -70,3 +78,6 @@ fn resolve_attribution_value(config_attribution: Option<&str>) -> Option None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()), } } + +#[cfg(test)] +mod tests; diff --git a/codex-rs/ext/git-attribution/src/tests.rs b/codex-rs/ext/git-attribution/src/tests.rs new file mode 100644 index 0000000000..56ec3d717e --- /dev/null +++ b/codex-rs/ext/git-attribution/src/tests.rs @@ -0,0 +1,81 @@ +use super::GitAttributionContext; +use super::GitAttributionExtension; +use super::build_commit_message_trailer; +use super::resolve_attribution_value; + +struct TestContext { + git_attribution_enabled: bool, + commit_attribution: Option<&'static str>, +} + +impl GitAttributionContext for TestContext { + fn git_attribution_enabled(&self) -> bool { + self.git_attribution_enabled + } + + fn commit_attribution(&self) -> Option<&str> { + self.commit_attribution + } +} + +#[test] +fn blank_attribution_disables_trailer_prompt() { + assert_eq!(build_commit_message_trailer(Some("")), None); + assert_eq!( + GitAttributionExtension::new().instruction(&TestContext { + git_attribution_enabled: true, + commit_attribution: Some(" "), + }), + None + ); +} + +#[test] +fn disabled_context_omits_instruction() { + assert_eq!( + GitAttributionExtension::new().instruction(&TestContext { + git_attribution_enabled: false, + commit_attribution: None, + }), + None + ); +} + +#[test] +fn default_attribution_uses_codex_trailer() { + assert_eq!( + build_commit_message_trailer(/*config_attribution*/ None).as_deref(), + Some("Co-authored-by: Codex ") + ); +} + +#[test] +fn resolve_value_handles_default_custom_and_blank() { + assert_eq!( + resolve_attribution_value(/*config_attribution*/ None), + Some("Codex ".to_string()) + ); + assert_eq!( + resolve_attribution_value(Some("MyAgent ")), + Some("MyAgent ".to_string()) + ); + assert_eq!( + resolve_attribution_value(Some("MyAgent")), + Some("MyAgent".to_string()) + ); + assert_eq!(resolve_attribution_value(Some(" ")), None); +} + +#[test] +fn instruction_mentions_trailer_and_omits_generated_with() { + let instruction = GitAttributionExtension::new() + .instruction(&TestContext { + git_attribution_enabled: true, + commit_attribution: Some("AgentX "), + }) + .expect("instruction expected"); + + assert!(instruction.contains("Co-authored-by: AgentX ")); + assert!(instruction.contains("exactly once")); + assert!(!instruction.contains("Generated-with")); +}