use std::sync::Arc; use std::sync::Weak; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_core::NewThread; use codex_core::StartThreadOptions; use codex_core::ThreadManager; use codex_core::config::Config; use codex_extension_api::AgentSpawnFuture; use codex_extension_api::AgentSpawner; use codex_extension_api::ExtensionEventFuture; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ResponseItemInjectionFuture; use codex_extension_api::ResponseItemInjector; use codex_features::Feature; use codex_login::AuthManager; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_rollout::state_db::StateDbHandle; use crate::outgoing_message::OutgoingMessageSender; pub(crate) fn thread_extensions( guardian_agent_spawner: S, event_sink: Arc, auth_manager: Arc, state_db: Option, thread_manager: Weak, ) -> Arc> where S: AgentSpawner + 'static, { let mut builder = ExtensionRegistryBuilder::::with_event_sink(event_sink); codex_guardian::install(&mut builder, guardian_agent_spawner); if let Some(state_db) = state_db { codex_goal_extension::install_with_backend( &mut builder, state_db, codex_otel::global(), Arc::new(ThreadManagerResponseItemInjector { thread_manager }), |config: &Config| config.features.enabled(Feature::Goals), ); } codex_memories_extension::install(&mut builder, codex_otel::global()); codex_web_search_extension::install(&mut builder, auth_manager); Arc::new(builder.build()) } struct ThreadManagerResponseItemInjector { thread_manager: Weak, } impl ResponseItemInjector for ThreadManagerResponseItemInjector { fn inject_response_items<'a>( &'a self, thread_id: ThreadId, items: Vec, ) -> ResponseItemInjectionFuture<'a> { let thread_manager = self.thread_manager.clone(); Box::pin(async move { let Some(thread_manager) = thread_manager.upgrade() else { return Err(items); }; let Ok(thread) = thread_manager.get_thread(thread_id).await else { return Err(items); }; thread.inject_response_items_into_active_turn(items).await }) } } pub(crate) fn app_server_extension_event_sink( outgoing: Arc, ) -> Arc { Arc::new(AppServerExtensionEventSink { outgoing }) } struct AppServerExtensionEventSink { outgoing: Arc, } impl ExtensionEventSink for AppServerExtensionEventSink { fn emit<'a>(&'a self, event: Event) -> ExtensionEventFuture<'a> { Box::pin(async move { match event.msg { EventMsg::ThreadGoalUpdated(thread_goal_event) => { let notification = ServerNotification::ThreadGoalUpdated(ThreadGoalUpdatedNotification { thread_id: thread_goal_event.thread_id.to_string(), turn_id: thread_goal_event.turn_id, goal: thread_goal_event.goal.into(), }); self.outgoing.send_server_notification(notification).await; } msg => { tracing::debug!(event_id = %event.id, ?msg, "dropping unsupported extension event"); } } }) } } pub(crate) fn guardian_agent_spawner( thread_manager: Weak, ) -> impl AgentSpawner { move |forked_from_thread_id: ThreadId, options: StartThreadOptions| -> AgentSpawnFuture<'static, NewThread, CodexErr> { let thread_manager = thread_manager.clone(); Box::pin(async move { let thread_manager = thread_manager.upgrade().ok_or_else(|| { CodexErr::UnsupportedOperation("thread manager dropped".to_string()) })?; thread_manager .spawn_subagent(forked_from_thread_id, options) .await }) } } #[cfg(test)] mod tests { use std::time::Duration; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadGoal as AppServerThreadGoal; use codex_app_server_protocol::ThreadGoalStatus as AppServerThreadGoalStatus; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; use pretty_assertions::assert_eq; use tokio::sync::mpsc; use tokio::time::timeout; use super::*; use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingMessage; #[tokio::test] async fn app_server_event_sink_waits_for_outgoing_capacity() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(1); let outgoing = Arc::new(OutgoingMessageSender::new( outgoing_tx.clone(), AnalyticsEventsClient::disabled(), )); let thread_id = ThreadId::default(); outgoing_tx .try_send(OutgoingEnvelope::Broadcast { message: OutgoingMessage::AppServerNotification( ServerNotification::ThreadGoalUpdated(app_server_goal_update( thread_id, "prefill channel", "prefill", )), ), }) .expect("prefill should fit in one-slot channel"); let sink = app_server_extension_event_sink(outgoing); let emit = tokio::spawn(async move { sink.emit(thread_goal_update_event( thread_id, "wait for capacity", "turn-1", )) .await; }); let _prefill = recv_goal_update(&mut outgoing_rx).await; emit.await.expect("event emission should complete"); let notification = recv_goal_update(&mut outgoing_rx).await; assert_eq!( app_server_goal_update(thread_id, "wait for capacity", "turn-1"), notification ); } #[tokio::test] async fn app_server_event_sink_preserves_goal_update_order() { let (outgoing_tx, mut outgoing_rx) = mpsc::channel(2); let outgoing = Arc::new(OutgoingMessageSender::new( outgoing_tx, AnalyticsEventsClient::disabled(), )); let thread_id = ThreadId::default(); let sink = app_server_extension_event_sink(outgoing); sink.emit(thread_goal_update_event(thread_id, "first", "turn-1")) .await; sink.emit(thread_goal_update_event(thread_id, "second", "turn-2")) .await; assert_eq!( app_server_goal_update(thread_id, "first", "turn-1"), recv_goal_update(&mut outgoing_rx).await ); assert_eq!( app_server_goal_update(thread_id, "second", "turn-2"), recv_goal_update(&mut outgoing_rx).await ); } fn thread_goal_update_event(thread_id: ThreadId, objective: &str, turn_id: &str) -> Event { Event { id: "call-1".to_string(), msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { thread_id, turn_id: Some(turn_id.to_string()), goal: ThreadGoal { thread_id, objective: objective.to_string(), status: ThreadGoalStatus::Active, token_budget: Some(123), tokens_used: 45, time_used_seconds: 6, created_at: 7, updated_at: 8, }, }), } } fn app_server_goal_update( thread_id: ThreadId, objective: &str, turn_id: &str, ) -> ThreadGoalUpdatedNotification { ThreadGoalUpdatedNotification { thread_id: thread_id.to_string(), turn_id: Some(turn_id.to_string()), goal: AppServerThreadGoal { thread_id: thread_id.to_string(), objective: objective.to_string(), status: AppServerThreadGoalStatus::Active, token_budget: Some(123), tokens_used: 45, time_used_seconds: 6, created_at: 7, updated_at: 8, }, } } async fn recv_goal_update( outgoing_rx: &mut mpsc::Receiver, ) -> ThreadGoalUpdatedNotification { let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv()) .await .expect("timed out waiting for forwarded extension event") .expect("outgoing channel closed unexpectedly"); let OutgoingEnvelope::Broadcast { message } = envelope else { panic!("expected broadcast notification"); }; let OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated( notification, )) = message else { panic!("expected thread goal updated notification"); }; notification } }