#![allow(clippy::expect_used)] use std::sync::Arc; use std::sync::Mutex; use codex_extension_api::ApprovalReviewContributor; use codex_extension_api::ConfigContributor; use codex_extension_api::ContextContributor; use codex_extension_api::ContextualUserFragment; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionFuture; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::PromptFragment; use codex_extension_api::PromptSlot; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::TokenUsageContributor; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; use codex_extension_api::ToolExecutor; use codex_extension_api::ToolLifecycleContributor; use codex_extension_api::TurnContextContributionInput; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputContributor; use codex_extension_api::TurnItemContributor; use codex_extension_api::TurnLifecycleContributor; use codex_extension_api::empty_extension_registry; use codex_protocol::items::HookPromptItem; use codex_protocol::items::TurnItem; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::WarningEvent; use pretty_assertions::assert_eq; struct AllContributors; impl ContextContributor for AllContributors { fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, ) -> ExtensionFuture<'a, Vec> { Box::pin(std::future::ready(Vec::new())) } } impl ThreadLifecycleContributor<()> for AllContributors {} impl TurnLifecycleContributor for AllContributors {} impl ConfigContributor<()> for AllContributors {} impl TokenUsageContributor for AllContributors {} impl TurnInputContributor for AllContributors { fn contribute<'a>( &'a self, input: TurnInputContext, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, _turn_store: &'a ExtensionData, ) -> ExtensionFuture<'a, Vec>> { Box::pin(async move { let _self = self; let _input = input; Vec::new() }) } } impl ToolContributor for AllContributors { fn tools( &self, _session_store: &ExtensionData, _thread_store: &ExtensionData, ) -> Vec>> { Vec::new() } } impl ToolLifecycleContributor for AllContributors {} impl TurnItemContributor for AllContributors { fn contribute<'a>( &'a self, _thread_store: &'a ExtensionData, _turn_store: &'a ExtensionData, _item: &'a mut TurnItem, ) -> ExtensionFuture<'a, Result<(), String>> { Box::pin(async move { let _self = self; Ok(()) }) } } impl ApprovalReviewContributor for AllContributors { fn contribute<'a>( &'a self, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, _prompt: &'a str, ) -> ExtensionFuture<'a, Option> { Box::pin(async move { let _self = self; Some(ReviewDecision::ApprovedForSession) }) } } #[tokio::test] async fn build_round_trips_every_contributor_category() { let contributor = Arc::new(AllContributors); let mut builder = ExtensionRegistryBuilder::<()>::new(); builder.thread_lifecycle_contributor(contributor.clone()); builder.turn_lifecycle_contributor(contributor.clone()); builder.config_contributor(contributor.clone()); builder.token_usage_contributor(contributor.clone()); builder.prompt_contributor(contributor.clone()); builder.turn_input_contributor(contributor.clone()); builder.tool_contributor(contributor.clone()); builder.tool_lifecycle_contributor(contributor.clone()); builder.turn_item_contributor(contributor.clone()); builder.approval_review_contributor(contributor); let registry = builder.build(); assert_eq!(registry.thread_lifecycle_contributors().len(), 1); assert_eq!(registry.turn_lifecycle_contributors().len(), 1); assert_eq!(registry.config_contributors().len(), 1); assert_eq!(registry.token_usage_contributors().len(), 1); assert_eq!(registry.context_contributors().len(), 1); assert_eq!(registry.turn_input_contributors().len(), 1); assert_eq!(registry.tool_contributors().len(), 1); assert_eq!(registry.tool_lifecycle_contributors().len(), 1); assert_eq!(registry.turn_item_contributors().len(), 1); assert_eq!( registry .approval_review( &ExtensionData::new("session"), &ExtensionData::new("thread"), "review this", ) .await, Some(ReviewDecision::ApprovedForSession) ); } struct NamedContextContributor(&'static str); impl ContextContributor for NamedContextContributor { fn contribute_thread_context<'a>( &'a self, _session_store: &'a ExtensionData, _thread_store: &'a ExtensionData, ) -> ExtensionFuture<'a, Vec> { Box::pin(std::future::ready(vec![PromptFragment::developer_policy( self.0, )])) } } struct NamedTurnContextContributor(&'static str); impl ContextContributor for NamedTurnContextContributor { fn contribute_turn_context<'a>( &'a self, _input: TurnContextContributionInput<'a>, ) -> ExtensionFuture<'a, Vec> { Box::pin(std::future::ready(vec![PromptFragment::new( PromptSlot::ContextualUser, self.0, )])) } } struct RecordingTurnItemContributor { name: &'static str, calls: Arc>>, } impl TurnItemContributor for RecordingTurnItemContributor { fn contribute<'a>( &'a self, _thread_store: &'a ExtensionData, _turn_store: &'a ExtensionData, _item: &'a mut TurnItem, ) -> ExtensionFuture<'a, Result<(), String>> { Box::pin(async move { self.calls .lock() .expect("turn item calls lock should not be poisoned") .push(self.name); Ok(()) }) } } #[tokio::test] async fn contributors_preserve_registration_order() { let turn_item_calls = Arc::new(Mutex::new(Vec::new())); let mut builder = ExtensionRegistryBuilder::<()>::new(); builder.prompt_contributor(Arc::new(NamedContextContributor("first"))); builder.prompt_contributor(Arc::new(NamedContextContributor("second"))); builder.prompt_contributor(Arc::new(NamedTurnContextContributor("turn-first"))); builder.prompt_contributor(Arc::new(NamedTurnContextContributor("turn-second"))); for name in ["first", "second"] { builder.turn_item_contributor(Arc::new(RecordingTurnItemContributor { name, calls: Arc::clone(&turn_item_calls), })); } let registry = builder.build(); let session_store = ExtensionData::new("session"); let thread_store = ExtensionData::new("thread"); let turn_store = ExtensionData::new("turn"); let mut fragments = Vec::new(); for contributor in registry.context_contributors() { fragments.extend( contributor .contribute_thread_context(&session_store, &thread_store) .await, ); } for contributor in registry.context_contributors() { fragments.extend( contributor .contribute_turn_context(TurnContextContributionInput { thread_id: codex_protocol::ThreadId::default(), turn_id: turn_store.level_id(), session_store: &session_store, thread_store: &thread_store, turn_store: &turn_store, model_context_window: Some(123), }) .await, ); } let mut item = TurnItem::HookPrompt(HookPromptItem { id: "item".to_string(), fragments: Vec::new(), }); for contributor in registry.turn_item_contributors() { contributor .contribute(&thread_store, &turn_store, &mut item) .await .expect("turn item contribution should succeed"); } assert_eq!( fragments, vec![ PromptFragment::developer_policy("first"), PromptFragment::developer_policy("second"), PromptFragment::new(PromptSlot::ContextualUser, "turn-first"), PromptFragment::new(PromptSlot::ContextualUser, "turn-second"), ] ); assert_eq!( turn_item_calls .lock() .expect("turn item calls lock") .as_slice(), ["first", "second"] ); } #[derive(Debug, PartialEq, Eq)] struct ApprovalCall { contributor: &'static str, session_id: String, thread_id: String, prompt: String, } struct RecordingApprovalContributor { name: &'static str, decision: Option, calls: Arc>>, } impl ApprovalReviewContributor for RecordingApprovalContributor { fn contribute<'a>( &'a self, session_store: &'a ExtensionData, thread_store: &'a ExtensionData, prompt: &'a str, ) -> ExtensionFuture<'a, Option> { Box::pin(async move { self.calls .lock() .expect("approval calls lock should not be poisoned") .push(ApprovalCall { contributor: self.name, session_id: session_store.level_id().to_string(), thread_id: thread_store.level_id().to_string(), prompt: prompt.to_string(), }); self.decision.clone() }) } } #[tokio::test] async fn approval_review_returns_first_claim_and_short_circuits() { let calls = Arc::new(Mutex::new(Vec::new())); let mut builder = ExtensionRegistryBuilder::<()>::new(); for (name, decision) in [ ("first", None), ("second", Some(ReviewDecision::Approved)), ("third", Some(ReviewDecision::Denied)), ] { builder.approval_review_contributor(Arc::new(RecordingApprovalContributor { name, decision, calls: Arc::clone(&calls), })); } let registry = builder.build(); let decision = registry .approval_review( &ExtensionData::new("session-1"), &ExtensionData::new("thread-1"), "allow command?", ) .await; assert_eq!(decision, Some(ReviewDecision::Approved)); assert_eq!( calls.lock().expect("approval calls lock").as_slice(), [ ApprovalCall { contributor: "first", session_id: "session-1".to_string(), thread_id: "thread-1".to_string(), prompt: "allow command?".to_string(), }, ApprovalCall { contributor: "second", session_id: "session-1".to_string(), thread_id: "thread-1".to_string(), prompt: "allow command?".to_string(), }, ] ); } #[derive(Default)] struct RecordingEventSink { events: Mutex>, } impl ExtensionEventSink for RecordingEventSink { fn emit(&self, event: Event) { let EventMsg::Warning(warning) = event.msg else { panic!("test sink only accepts warning events"); }; self.events .lock() .expect("recording event sink lock should not be poisoned") .push((event.id, warning.message)); } } #[test] fn custom_event_sink_survives_registry_build() { let sink = Arc::new(RecordingEventSink::default()); let builder = ExtensionRegistryBuilder::<()>::with_event_sink(sink.clone()); builder .event_sink() .emit(warning_event("builder", "before")); let registry = builder.build(); registry .event_sink() .emit(warning_event("registry", "after")); assert_eq!( sink.events .lock() .expect("recording event sink lock") .as_slice(), [ ("builder".to_string(), "before".to_string()), ("registry".to_string(), "after".to_string()), ] ); } #[tokio::test] async fn empty_registry_does_not_claim_approval_review() { let registry = empty_extension_registry::<()>(); assert_eq!( registry .approval_review( &ExtensionData::new("session"), &ExtensionData::new("thread"), "unclaimed", ) .await, None ); } fn warning_event(id: &str, message: &str) -> Event { Event { id: id.to_string(), msg: EventMsg::Warning(WarningEvent { message: message.to_string(), }), } }