diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c8255e4dd8..3853a661ed 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -123,6 +123,7 @@ use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestUserInputResponse; @@ -699,7 +700,7 @@ pub(crate) async fn apply_bespoke_event_handling( turn_id: request.turn_id.clone(), item_id: request.call_id.clone(), reason: request.reason, - permissions: request.permissions.into(), + permissions: CorePermissionProfile::from(request.permissions).into(), }; let (pending_request_id, rx) = outgoing .send_request(ServerRequestPayload::PermissionsRequestApproval(params)) @@ -2227,7 +2228,7 @@ fn mcp_server_elicitation_response_from_client_result( async fn on_request_permissions_response( call_id: String, - requested_permissions: CorePermissionProfile, + requested_permissions: CoreRequestPermissionProfile, pending_request_id: RequestId, receiver: oneshot::Receiver, conversation: Arc, @@ -2255,7 +2256,7 @@ async fn on_request_permissions_response( } fn request_permissions_response_from_client_result( - requested_permissions: CorePermissionProfile, + requested_permissions: CoreRequestPermissionProfile, response: std::result::Result, ) -> Option { let value = match response { @@ -2287,9 +2288,10 @@ fn request_permissions_response_from_client_result( }); Some(CoreRequestPermissionsResponse { permissions: intersect_permission_profiles( - requested_permissions, + requested_permissions.into(), response.permissions.into(), - ), + ) + .into(), scope: response.scope.to_core(), }) } @@ -2646,10 +2648,8 @@ mod tests { use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; use codex_protocol::mcp::CallToolResult; - use codex_protocol::models::MacOsAutomationPermission; - use codex_protocol::models::MacOsContactsPermission; - use codex_protocol::models::MacOsPreferencesPermission; - use codex_protocol::models::MacOsSeatbeltProfileExtensions; + use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; + use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::protocol::CollabResumeBeginEvent; @@ -2660,6 +2660,7 @@ mod tests { use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use rmcp::model::Content; use serde_json::Value as JsonValue; @@ -2721,7 +2722,7 @@ mod tests { }; let response = request_permissions_response_from_client_result( - CorePermissionProfile::default(), + CoreRequestPermissionProfile::default(), Ok(Err(error)), ); @@ -2729,156 +2730,91 @@ mod tests { } #[test] - fn request_permissions_response_accepts_partial_macos_grants() { - let requested_permissions = CorePermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadWrite, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - "com.apple.Reminders".to_string(), - ]), - macos_launch_services: true, - macos_accessibility: true, - macos_calendar: true, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::ReadWrite, + fn request_permissions_response_accepts_partial_network_and_file_system_grants() { + let input_path = if cfg!(target_os = "windows") { + r"C:\tmp\input" + } else { + "/tmp/input" + }; + let output_path = if cfg!(target_os = "windows") { + r"C:\tmp\output" + } else { + "/tmp/output" + }; + let ignored_path = if cfg!(target_os = "windows") { + r"C:\tmp\ignored" + } else { + "/tmp/ignored" + }; + let absolute_path = |path: &str| { + AbsolutePathBuf::try_from(std::path::PathBuf::from(path)).expect("absolute path") + }; + let requested_permissions = CoreRequestPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions { + read: Some(vec![absolute_path(input_path)]), + write: Some(vec![absolute_path(output_path)]), }), - ..Default::default() }; let cases = vec![ - (serde_json::json!({}), CorePermissionProfile::default()), ( - serde_json::json!({ - "preferences": "read_only", - }), - CorePermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::ReadOnly, - macos_automation: MacOsAutomationPermission::None, - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }, + serde_json::json!({}), + CoreRequestPermissionProfile::default(), ), ( serde_json::json!({ - "automations": { - "bundle_ids": ["com.apple.Notes"], + "network": { + "enabled": true, }, }), - CorePermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::None, - macos_automation: MacOsAutomationPermission::BundleIds(vec![ - "com.apple.Notes".to_string(), - ]), - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, + CoreRequestPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), }), - ..Default::default() + ..CoreRequestPermissionProfile::default() }, ), ( serde_json::json!({ - "launchServices": true, + "fileSystem": { + "write": [output_path], + }, }), - CorePermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::None, - macos_automation: MacOsAutomationPermission::None, - macos_launch_services: true, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, + CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions { + read: None, + write: Some(vec![absolute_path(output_path)]), }), - ..Default::default() + ..CoreRequestPermissionProfile::default() }, ), ( serde_json::json!({ - "accessibility": true, + "fileSystem": { + "read": [input_path], + "write": [output_path, ignored_path], + }, + "macos": { + "calendar": true, + }, }), - CorePermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::None, - macos_automation: MacOsAutomationPermission::None, - macos_launch_services: false, - macos_accessibility: true, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, + CoreRequestPermissionProfile { + file_system: Some(CoreFileSystemPermissions { + read: Some(vec![absolute_path(input_path)]), + write: Some(vec![absolute_path(output_path)]), }), - ..Default::default() - }, - ), - ( - serde_json::json!({ - "calendar": true, - }), - CorePermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::None, - macos_automation: MacOsAutomationPermission::None, - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: true, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }, - ), - ( - serde_json::json!({ - "reminders": true, - }), - CorePermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::None, - macos_automation: MacOsAutomationPermission::None, - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: true, - macos_contacts: MacOsContactsPermission::None, - }), - ..Default::default() - }, - ), - ( - serde_json::json!({ - "contacts": "read_only", - }), - CorePermissionProfile { - macos: Some(MacOsSeatbeltProfileExtensions { - macos_preferences: MacOsPreferencesPermission::None, - macos_automation: MacOsAutomationPermission::None, - macos_launch_services: false, - macos_accessibility: false, - macos_calendar: false, - macos_reminders: false, - macos_contacts: MacOsContactsPermission::ReadOnly, - }), - ..Default::default() + ..CoreRequestPermissionProfile::default() }, ), ]; - for (granted_macos, expected_permissions) in cases { + for (granted_permissions, expected_permissions) in cases { let response = request_permissions_response_from_client_result( requested_permissions.clone(), Ok(Ok(serde_json::json!({ - "permissions": { - "macos": granted_macos, - }, + "permissions": granted_permissions, }))), ) .expect("response should be accepted"); @@ -2896,7 +2832,7 @@ mod tests { #[test] fn request_permissions_response_preserves_session_scope() { let response = request_permissions_response_from_client_result( - CorePermissionProfile::default(), + CoreRequestPermissionProfile::default(), Ok(Ok(serde_json::json!({ "scope": "session", "permissions": {}, @@ -2907,7 +2843,7 @@ mod tests { assert_eq!( response, CoreRequestPermissionsResponse { - permissions: CorePermissionProfile::default(), + permissions: CoreRequestPermissionProfile::default(), scope: CorePermissionGrantScope::Session, } ); diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 7a73304f59..d35e18ca01 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -201,6 +201,8 @@ use codex_core::config::NetworkProxyAuditMetadata; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::McpServerTransportConfig; +use codex_core::config_loader::CloudRequirementsLoadError; +use codex_core::config_loader::CloudRequirementsLoadErrorCode; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::error::CodexErr; @@ -1959,11 +1961,7 @@ impl CodexMessageProcessor { { Ok(config) => config, Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; + let error = config_load_error(&err); listener_task_context .outgoing .send_error(request_id, error) @@ -3366,11 +3364,7 @@ impl CodexMessageProcessor { { Ok(config) => config, Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; + let error = config_load_error(&err); self.outgoing.send_error(request_id, error).await; return; } @@ -3889,11 +3883,9 @@ impl CodexMessageProcessor { { Ok(config) => config, Err(err) => { - self.send_invalid_request_error( - request_id, - format!("error deriving config: {err}"), - ) - .await; + self.outgoing + .send_error(request_id, config_load_error(&err)) + .await; return; } }; @@ -7464,6 +7456,42 @@ fn errors_to_info( .collect() } +fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> { + let mut current: Option<&(dyn std::error::Error + 'static)> = err + .get_ref() + .map(|source| source as &(dyn std::error::Error + 'static)); + while let Some(source) = current { + if let Some(cloud_error) = source.downcast_ref::() { + return Some(cloud_error); + } + current = source.source(); + } + None +} + +fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { + let data = cloud_requirements_load_error(err).map(|cloud_error| { + let mut data = serde_json::json!({ + "reason": "cloudRequirements", + "errorCode": format!("{:?}", cloud_error.code()), + "detail": cloud_error.to_string(), + }); + if let Some(status_code) = cloud_error.status_code() { + data["statusCode"] = serde_json::json!(status_code); + } + if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth { + data["action"] = serde_json::json!("relogin"); + } + data + }); + + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to load configuration: {err}"), + data, + } +} + fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { let mut seen = HashSet::new(); for tool in tools { @@ -8099,6 +8127,67 @@ mod tests { validate_dynamic_tools(&tools).expect("valid schema"); } + #[test] + fn config_load_error_marks_cloud_requirements_failures_for_relogin() { + let err = std::io::Error::other(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + Some(401), + "Your authentication session could not be refreshed automatically. Please log out and sign in again.", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your authentication session could not be refreshed automatically. Please log out and sign in again.", + })) + ); + assert!( + error.message.contains("failed to load configuration"), + "unexpected error message: {}", + error.message + ); + } + + #[test] + fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() { + let err = std::io::Error::other("required MCP servers failed to initialize"); + + let error = config_load_error(&err); + + assert_eq!(error.data, None); + assert!( + error.message.contains("failed to load configuration"), + "unexpected error message: {}", + error.message + ); + } + + #[test] + fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { + let err = std::io::Error::other(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::RequestFailed, + None, + "failed to load your workspace-managed config", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "RequestFailed", + "detail": "failed to load your workspace-managed config", + })) + ); + } + #[test] fn collect_resume_override_mismatches_includes_service_tier() { let request = ThreadResumeParams { diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 7e1427181b..ef33807064 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -12,6 +12,7 @@ use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::SandboxMode; +use codex_core::AnalyticsEventsClient; use codex_core::ThreadManager; use codex_core::config::ConfigService; use codex_core::config::ConfigServiceError; @@ -20,6 +21,9 @@ use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::LoaderOverrides; use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement; use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement; +use codex_core::plugins::PluginId; +use codex_core::plugins::collect_plugin_enabled_candidates; +use codex_core::plugins::installed_plugin_telemetry_metadata; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::Op; use serde_json::json; @@ -56,6 +60,7 @@ pub(crate) struct ConfigApi { loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, + analytics_events_client: AnalyticsEventsClient, } impl ConfigApi { @@ -65,6 +70,7 @@ impl ConfigApi { loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, + analytics_events_client: AnalyticsEventsClient, ) -> Self { Self { codex_home, @@ -72,6 +78,7 @@ impl ConfigApi { loader_overrides, cloud_requirements, user_config_reloader, + analytics_events_client, } } @@ -113,10 +120,15 @@ impl ConfigApi { &self, params: ConfigValueWriteParams, ) -> Result { - self.config_service() + let pending_changes = + collect_plugin_enabled_candidates([(¶ms.key_path, ¶ms.value)].into_iter()); + let response = self + .config_service() .write_value(params) .await - .map_err(map_error) + .map_err(map_error)?; + self.emit_plugin_toggle_events(pending_changes); + Ok(response) } pub(crate) async fn batch_write( @@ -124,16 +136,38 @@ impl ConfigApi { params: ConfigBatchWriteParams, ) -> Result { let reload_user_config = params.reload_user_config; + let pending_changes = collect_plugin_enabled_candidates( + params + .edits + .iter() + .map(|edit| (&edit.key_path, &edit.value)), + ); let response = self .config_service() .batch_write(params) .await .map_err(map_error)?; + self.emit_plugin_toggle_events(pending_changes); if reload_user_config { self.user_config_reloader.reload_user_config().await; } Ok(response) } + + fn emit_plugin_toggle_events(&self, pending_changes: std::collections::BTreeMap) { + for (plugin_id, enabled) in pending_changes { + let Ok(plugin_id) = PluginId::parse(&plugin_id) else { + continue; + }; + let metadata = + installed_plugin_telemetry_metadata(self.codex_home.as_path(), &plugin_id); + if enabled { + self.analytics_events_client.track_plugin_enabled(metadata); + } else { + self.analytics_events_client.track_plugin_disabled(metadata); + } + } + } } fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { @@ -229,6 +263,7 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::*; + use codex_core::AnalyticsEventsClient; use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use pretty_assertions::assert_eq; @@ -359,12 +394,24 @@ mod tests { let user_config_path = codex_home.path().join("config.toml"); std::fs::write(&user_config_path, "").expect("write config"); let reloader = Arc::new(RecordingUserConfigReloader::default()); + let analytics_config = Arc::new( + codex_core::config::ConfigBuilder::default() + .build() + .await + .expect("load analytics config"), + ); let config_api = ConfigApi::new( codex_home.path().to_path_buf(), Vec::new(), LoaderOverrides::default(), Arc::new(RwLock::new(CloudRequirementsLoader::default())), reloader.clone(), + AnalyticsEventsClient::new( + analytics_config, + codex_core::test_support::auth_manager_from_auth( + codex_core::CodexAuth::from_api_key("test"), + ), + ), ); let response = config_api diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 3b85cc77cc..911adef35a 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -39,6 +39,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; +use codex_core::AnalyticsEventsClient; use codex_core::AuthManager; use codex_core::ThreadManager; use codex_core::auth::ExternalAuthRefreshContext; @@ -191,6 +192,8 @@ impl MessageProcessor { auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let analytics_events_client = + AnalyticsEventsClient::new(Arc::clone(&config), Arc::clone(&auth_manager)); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager.clone(), @@ -201,6 +204,9 @@ impl MessageProcessor { .enabled(codex_core::features::Feature::DefaultModeRequestUserInput), }, )); + thread_manager + .plugins_manager() + .set_analytics_events_client(analytics_events_client.clone()); // TODO(xl): Move into PluginManager once this no longer depends on config feature gating. thread_manager .plugins_manager() @@ -223,6 +229,7 @@ impl MessageProcessor { loader_overrides, cloud_requirements, thread_manager, + analytics_events_client, ); let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone()); diff --git a/codex-rs/app-server/tests/common/analytics_server.rs b/codex-rs/app-server/tests/common/analytics_server.rs new file mode 100644 index 0000000000..75b8df60ec --- /dev/null +++ b/codex-rs/app-server/tests/common/analytics_server.rs @@ -0,0 +1,16 @@ +use anyhow::Result; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +pub async fn start_analytics_events_server() -> Result { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/analytics-events/events")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Ok(server) +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 74d1b47d40..3f89765851 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -1,3 +1,4 @@ +mod analytics_server; mod auth_fixtures; mod config; mod mcp_process; @@ -6,6 +7,7 @@ mod models_cache; mod responses; mod rollout; +pub use analytics_server::start_analytics_events_server; pub use auth_fixtures::ChatGptAuthFixture; pub use auth_fixtures::ChatGptIdTokenClaims; pub use auth_fixtures::encode_id_token; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 6b9ed72c0d..54afd89f37 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -5,7 +5,9 @@ use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; +use app_test_support::start_analytics_events_server; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use axum::Json; @@ -136,6 +138,85 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re Ok(()) } +#[tokio::test] +async fn plugin_install_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_analytics_config(codex_home.path(), &analytics_server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + None, + None, + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &[])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + assert_eq!(response.apps_needing_auth, Vec::::new()); + + let payloads = timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = analytics_server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + if !requests.is_empty() { + break requests; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await?; + let payload: serde_json::Value = + serde_json::from_slice(&payloads[0].body).expect("analytics payload"); + assert_eq!( + payload, + json!({ + "events": [{ + "event_type": "codex_plugin_installed", + "event_params": { + "plugin_id": "sample-plugin@debug", + "plugin_name": "sample-plugin", + "marketplace_name": "debug", + "has_skills": false, + "mcp_server_count": 0, + "connector_ids": [], + "product_client_id": DEFAULT_CLIENT_NAME, + } + }] + }) + ); + Ok(()) +} + #[tokio::test] async fn plugin_install_returns_apps_needing_auth() -> Result<()> { let connectors = vec![ @@ -461,6 +542,13 @@ connectors = true ) } +fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!("chatgpt_base_url = \"{base_url}\"\n"), + ) +} + fn write_plugin_marketplace( repo_root: &std::path::Path, marketplace_name: &str, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index fb8cf582aa..b068552a81 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -1,13 +1,19 @@ use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::McpProcess; +use app_test_support::start_analytics_events_server; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; +use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; @@ -64,6 +70,78 @@ enabled = true Ok(()) } +#[tokio::test] +async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + write_installed_plugin(&codex_home, "debug", "sample-plugin")?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + "chatgpt_base_url = \"{}\"\n\n[features]\nplugins = true\n\n[plugins.\"sample-plugin@debug\"]\nenabled = true\n", + analytics_server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_uninstall_request(PluginUninstallParams { + plugin_id: "sample-plugin@debug".to_string(), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginUninstallResponse = to_response(response)?; + assert_eq!(response, PluginUninstallResponse {}); + + let payloads = timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = analytics_server.received_requests().await else { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + }; + if !requests.is_empty() { + break requests; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await?; + let payload: serde_json::Value = + serde_json::from_slice(&payloads[0].body).expect("analytics payload"); + assert_eq!( + payload, + json!({ + "events": [{ + "event_type": "codex_plugin_uninstalled", + "event_params": { + "plugin_id": "sample-plugin@debug", + "plugin_name": "sample-plugin", + "marketplace_name": "debug", + "has_skills": false, + "mcp_server_count": 0, + "connector_ids": [], + "product_client_id": DEFAULT_CLIENT_NAME, + } + }] + }) + ); + Ok(()) +} + fn write_installed_plugin( codex_home: &TempDir, marketplace_name: &str, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 62fecd7433..1b40fadc67 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -1,8 +1,10 @@ use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::create_fake_rollout; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; @@ -22,11 +24,19 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -212,6 +222,102 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/requirements")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("content-type", "text/html") + .set_body_string("nope"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_invalidated" } + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let model_server = create_mock_responses_server_repeating_assistant("Done").await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &model_server.uri(), + &chatgpt_base_url, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .refresh_token("stale-refresh-token") + .plan_type("business") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + None, + )?; + + let refresh_token_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_token_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let fork_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), + ) + .await??; + + assert!( + fork_err + .error + .message + .contains("failed to load configuration"), + "unexpected fork error: {}", + fork_err.error.message + ); + assert_eq!( + fork_err.error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.", + })) + ); + + Ok(()) +} + #[tokio::test] async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -398,3 +504,31 @@ stream_max_retries = 0 ), ) } + +fn create_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 12a74ed6e6..27803db42c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_fake_rollout_with_text_elements; @@ -8,6 +9,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::rollout_path; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use chrono::Utc; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::CommandExecutionApprovalDecision; @@ -36,6 +38,8 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; use codex_protocol::models::ContentItem; @@ -60,6 +64,11 @@ use std::process::Command; use tempfile::TempDir; use tokio::time::timeout; use uuid::Uuid; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."; @@ -1409,6 +1418,98 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R Ok(()) } +#[tokio::test] +async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/requirements")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("content-type", "text/html") + .set_body_string("nope"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_invalidated" } + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let model_server = create_mock_responses_server_repeating_assistant("Done").await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &model_server.uri(), + &chatgpt_base_url, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .refresh_token("stale-refresh-token") + .plan_type("business") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Vec::new(), + Some("mock_provider"), + None, + )?; + let refresh_token_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_token_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + + assert!( + err.error.message.contains("failed to load configuration"), + "unexpected error message: {}", + err.error.message + ); + assert_eq!( + err.error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.", + })) + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_prefers_path_over_thread_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -1734,6 +1835,37 @@ stream_max_retries = 0 ) } +fn create_config_toml_with_chatgpt_base_url( + codex_home: &std::path::Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "gpt-5.2-codex" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[features] +personality = true + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + fn create_config_toml_with_required_broken_mcp( codex_home: &std::path::Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index bc383a19d1..34431a48f5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -1,7 +1,9 @@ use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; @@ -11,15 +13,23 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -318,6 +328,88 @@ async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Re Ok(()) } +#[tokio::test] +async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/requirements")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("content-type", "text/html") + .set_body_string("nope"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_invalidated" } + }))) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let model_server = create_mock_responses_server_repeating_assistant("Done").await; + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + create_config_toml_with_chatgpt_base_url( + codex_home.path(), + &model_server.uri(), + &chatgpt_base_url, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .refresh_token("stale-refresh-token") + .plan_type("business") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123") + .account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let refresh_token_url = format!("{}/oauth/token", server.uri()); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ( + REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR, + Some(refresh_token_url.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(req_id)), + ) + .await??; + + assert!( + err.error.message.contains("failed to load configuration"), + "unexpected error message: {}", + err.error.message + ); + assert_eq!( + err.error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.", + })) + ); + + Ok(()) +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); @@ -342,6 +434,34 @@ stream_max_retries = 0 ) } +fn create_config_toml_with_chatgpt_base_url( + codex_home: &Path, + server_uri: &str, + chatgpt_base_url: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +chatgpt_base_url = "{chatgpt_base_url}" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + fn create_config_toml_with_required_broken_mcp( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 94f78edcc5..c6852200e0 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -19,6 +19,7 @@ use codex_core::AuthManager; use codex_core::auth::CodexAuth; use codex_core::auth::RefreshTokenError; use codex_core::config_loader::CloudRequirementsLoadError; +use codex_core::config_loader::CloudRequirementsLoadErrorCode; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::ConfigRequirementsToml; use codex_core::util::backoff; @@ -82,7 +83,7 @@ enum FetchAttemptError { Retryable(RetryableFailureKind), Unauthorized { status_code: Option, - error: CloudRequirementsLoadError, + message: String, }, } @@ -224,7 +225,7 @@ impl RequirementsFetcher for BackendRequirementsFetcher { if err.is_unauthorized() { FetchAttemptError::Unauthorized { status_code, - error: CloudRequirementsLoadError::new(err.to_string()), + message: err.to_string(), } } else { FetchAttemptError::Retryable(RetryableFailureKind::Request { status_code }) @@ -282,10 +283,14 @@ impl CloudRequirementsService { emit_load_metric("startup", "error"); }) .map_err(|_| { - CloudRequirementsLoadError::new(format!( - "timed out waiting for cloud requirements after {}s", - self.timeout.as_secs() - )) + CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Timeout, + None, + format!( + "timed out waiting for cloud requirements after {}s", + self.timeout.as_secs() + ), + ) })?; let result = match fetch_result { @@ -381,7 +386,10 @@ impl CloudRequirementsService { attempt += 1; continue; } - Err(FetchAttemptError::Unauthorized { status_code, error }) => { + Err(FetchAttemptError::Unauthorized { + status_code, + message, + }) => { last_status_code = status_code; emit_fetch_attempt_metric(trigger, attempt, "unauthorized", status_code); if auth_recovery.has_next() { @@ -404,6 +412,8 @@ impl CloudRequirementsService { status_code, ); return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); }; @@ -422,7 +432,11 @@ impl CloudRequirementsService { attempt, status_code, ); - return Err(CloudRequirementsLoadError::new(failed.message)); + return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + status_code, + failed.message, + )); } Err(RefreshTokenError::Transient(recovery_err)) => { if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS { @@ -441,7 +455,7 @@ impl CloudRequirementsService { } tracing::warn!( - error = %error, + error = %message, "Cloud requirements request was unauthorized and no auth recovery is available" ); emit_fetch_final_metric( @@ -452,6 +466,8 @@ impl CloudRequirementsService { status_code, ); return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + status_code, CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE, )); } @@ -470,6 +486,8 @@ impl CloudRequirementsService { last_status_code, ); return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Parse, + None, CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, )); } @@ -498,6 +516,8 @@ impl CloudRequirementsService { "{CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE}" ); Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::RequestFailed, + last_status_code, CLOUD_REQUIREMENTS_LOAD_FAILED_MESSAGE, )) } @@ -686,7 +706,11 @@ pub fn cloud_requirements_loader( CloudRequirementsLoader::new(async move { task.await.map_err(|err| { tracing::error!(error = %err, "Cloud requirements task failed"); - CloudRequirementsLoadError::new(format!("cloud requirements load failed: {err}")) + CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Internal, + None, + format!("cloud requirements load failed: {err}"), + ) })? }) } @@ -1009,7 +1033,7 @@ mod tests { } else { Err(FetchAttemptError::Unauthorized { status_code: Some(401), - error: CloudRequirementsLoadError::new("GET /config/requirements failed: 401"), + message: "GET /config/requirements failed: 401".to_string(), }) } } @@ -1029,7 +1053,7 @@ mod tests { self.request_count.fetch_add(1, Ordering::SeqCst); Err(FetchAttemptError::Unauthorized { status_code: Some(401), - error: CloudRequirementsLoadError::new(self.message.clone()), + message: self.message.clone(), }) } } @@ -1385,6 +1409,8 @@ mod tests { err.to_string(), CLOUD_REQUIREMENTS_AUTH_RECOVERY_FAILED_MESSAGE ); + assert_eq!(err.code(), CloudRequirementsLoadErrorCode::Auth); + assert_eq!(err.status_code(), Some(401)); assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1); } @@ -1767,6 +1793,7 @@ mod tests { err.to_string(), "failed to load your workspace-managed config" ); + assert_eq!(err.code(), CloudRequirementsLoadErrorCode::RequestFailed); assert_eq!( fetcher.request_count.load(Ordering::SeqCst), CLOUD_REQUIREMENTS_MAX_ATTEMPTS diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 141f57d9d8..cb710b30b3 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -1,15 +1,20 @@ -use crate::endpoint::realtime_websocket::protocol::ConversationItem; +use crate::endpoint::realtime_websocket::protocol::ConversationFunctionCallOutputItem; use crate::endpoint::realtime_websocket::protocol::ConversationItemContent; +use crate::endpoint::realtime_websocket::protocol::ConversationItemPayload; +use crate::endpoint::realtime_websocket::protocol::ConversationMessageItem; use crate::endpoint::realtime_websocket::protocol::RealtimeAudioFrame; use crate::endpoint::realtime_websocket::protocol::RealtimeEvent; +use crate::endpoint::realtime_websocket::protocol::RealtimeEventParser; use crate::endpoint::realtime_websocket::protocol::RealtimeOutboundMessage; use crate::endpoint::realtime_websocket::protocol::RealtimeSessionConfig; +use crate::endpoint::realtime_websocket::protocol::RealtimeSessionMode; use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptDelta; use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptEntry; use crate::endpoint::realtime_websocket::protocol::SessionAudio; use crate::endpoint::realtime_websocket::protocol::SessionAudioFormat; use crate::endpoint::realtime_websocket::protocol::SessionAudioInput; use crate::endpoint::realtime_websocket::protocol::SessionAudioOutput; +use crate::endpoint::realtime_websocket::protocol::SessionFunctionTool; use crate::endpoint::realtime_websocket::protocol::SessionUpdateSession; use crate::endpoint::realtime_websocket::protocol::parse_realtime_event; use crate::error::ApiError; @@ -20,6 +25,7 @@ use futures::SinkExt; use futures::StreamExt; use http::HeaderMap; use http::HeaderValue; +use serde_json::json; use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -40,6 +46,23 @@ use tracing::trace; use tungstenite::protocol::WebSocketConfig; use url::Url; +const REALTIME_AUDIO_SAMPLE_RATE: u32 = 24_000; +const REALTIME_AUDIO_VOICE: &str = "fathom"; +const REALTIME_V1_SESSION_TYPE: &str = "quicksilver"; +const REALTIME_V2_SESSION_TYPE: &str = "realtime"; +const REALTIME_V2_CODEX_TOOL_NAME: &str = "codex"; +const REALTIME_V2_CODEX_TOOL_DESCRIPTION: &str = "Delegate work to Codex and return the result."; + +fn normalized_session_mode( + event_parser: RealtimeEventParser, + session_mode: RealtimeSessionMode, +) -> RealtimeSessionMode { + match event_parser { + RealtimeEventParser::V1 => RealtimeSessionMode::Conversational, + RealtimeEventParser::RealtimeV2 => session_mode, + } +} + struct WsStream { tx_command: mpsc::Sender, pump_task: tokio::task::JoinHandle<()>, @@ -196,12 +219,14 @@ pub struct RealtimeWebsocketConnection { pub struct RealtimeWebsocketWriter { stream: Arc, is_closed: Arc, + event_parser: RealtimeEventParser, } #[derive(Clone)] pub struct RealtimeWebsocketEvents { rx_message: Arc>>>, active_transcript: Arc>, + event_parser: RealtimeEventParser, is_closed: Arc, } @@ -248,6 +273,7 @@ impl RealtimeWebsocketConnection { fn new( stream: WsStream, rx_message: mpsc::UnboundedReceiver>, + event_parser: RealtimeEventParser, ) -> Self { let stream = Arc::new(stream); let is_closed = Arc::new(AtomicBool::new(false)); @@ -255,10 +281,12 @@ impl RealtimeWebsocketConnection { writer: RealtimeWebsocketWriter { stream: Arc::clone(&stream), is_closed: Arc::clone(&is_closed), + event_parser, }, events: RealtimeWebsocketEvents { rx_message: Arc::new(Mutex::new(rx_message)), active_transcript: Arc::new(Mutex::new(ActiveTranscriptState::default())), + event_parser, is_closed, }, } @@ -272,15 +300,19 @@ impl RealtimeWebsocketWriter { } pub async fn send_conversation_item_create(&self, text: String) -> Result<(), ApiError> { + let content_kind = match self.event_parser { + RealtimeEventParser::V1 => "text", + RealtimeEventParser::RealtimeV2 => "input_text", + }; self.send_json(RealtimeOutboundMessage::ConversationItemCreate { - item: ConversationItem { + item: ConversationItemPayload::Message(ConversationMessageItem { kind: "message".to_string(), role: "user".to_string(), content: vec![ConversationItemContent { - kind: "text".to_string(), + kind: content_kind.to_string(), text, }], - }, + }), }) .await } @@ -290,29 +322,80 @@ impl RealtimeWebsocketWriter { handoff_id: String, output_text: String, ) -> Result<(), ApiError> { - self.send_json(RealtimeOutboundMessage::ConversationHandoffAppend { - handoff_id, - output_text, - }) - .await + let message = match self.event_parser { + RealtimeEventParser::V1 => RealtimeOutboundMessage::ConversationHandoffAppend { + handoff_id, + output_text, + }, + RealtimeEventParser::RealtimeV2 => RealtimeOutboundMessage::ConversationItemCreate { + item: ConversationItemPayload::FunctionCallOutput( + ConversationFunctionCallOutputItem { + kind: "function_call_output".to_string(), + call_id: handoff_id, + output: output_text, + }, + ), + }, + }; + + self.send_json(message).await } - pub async fn send_session_update(&self, instructions: String) -> Result<(), ApiError> { + pub async fn send_session_update( + &self, + instructions: String, + session_mode: RealtimeSessionMode, + ) -> Result<(), ApiError> { + let session_mode = normalized_session_mode(self.event_parser, session_mode); + let (session_kind, session_instructions, output_audio) = match session_mode { + RealtimeSessionMode::Conversational => { + let kind = match self.event_parser { + RealtimeEventParser::V1 => REALTIME_V1_SESSION_TYPE.to_string(), + RealtimeEventParser::RealtimeV2 => REALTIME_V2_SESSION_TYPE.to_string(), + }; + ( + kind, + Some(instructions), + Some(SessionAudioOutput { + voice: REALTIME_AUDIO_VOICE.to_string(), + }), + ) + } + RealtimeSessionMode::Transcription => ("transcription".to_string(), None, None), + }; + let tools = match self.event_parser { + RealtimeEventParser::RealtimeV2 => Some(vec![SessionFunctionTool { + kind: "function".to_string(), + name: REALTIME_V2_CODEX_TOOL_NAME.to_string(), + description: REALTIME_V2_CODEX_TOOL_DESCRIPTION.to_string(), + parameters: json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Prompt text for the delegated Codex task." + } + }, + "required": ["prompt"], + "additionalProperties": false + }), + }]), + RealtimeEventParser::V1 => None, + }; self.send_json(RealtimeOutboundMessage::SessionUpdate { session: SessionUpdateSession { - kind: "quicksilver".to_string(), - instructions, + kind: session_kind, + instructions: session_instructions, audio: SessionAudio { input: SessionAudioInput { format: SessionAudioFormat { kind: "audio/pcm".to_string(), - rate: 24_000, + rate: REALTIME_AUDIO_SAMPLE_RATE, }, }, - output: SessionAudioOutput { - voice: "fathom".to_string(), - }, + output: output_audio, }, + tools, }, }) .await @@ -376,7 +459,7 @@ impl RealtimeWebsocketEvents { match msg { Message::Text(text) => { - if let Some(mut event) = parse_realtime_event(&text) { + if let Some(mut event) = parse_realtime_event(&text, self.event_parser) { self.update_active_transcript(&mut event).await; debug!(?event, "realtime websocket parsed event"); return Ok(Some(event)); @@ -461,6 +544,8 @@ impl RealtimeWebsocketClient { self.provider.base_url.as_str(), self.provider.query_params.as_ref(), config.model.as_deref(), + config.event_parser, + config.session_mode, )?; let mut request = ws_url @@ -495,14 +580,14 @@ impl RealtimeWebsocketClient { ); let (stream, rx_message) = WsStream::new(stream); - let connection = RealtimeWebsocketConnection::new(stream, rx_message); + let connection = RealtimeWebsocketConnection::new(stream, rx_message, config.event_parser); debug!( session_id = config.session_id.as_deref().unwrap_or(""), "realtime websocket sending session.update" ); connection .writer - .send_session_update(config.instructions) + .send_session_update(config.instructions, config.session_mode) .await?; Ok(connection) } @@ -547,6 +632,8 @@ fn websocket_url_from_api_url( api_url: &str, query_params: Option<&HashMap>, model: Option<&str>, + event_parser: RealtimeEventParser, + _session_mode: RealtimeSessionMode, ) -> Result { let mut url = Url::parse(api_url) .map_err(|err| ApiError::Stream(format!("failed to parse realtime api_url: {err}")))?; @@ -566,9 +653,20 @@ fn websocket_url_from_api_url( } } - { + let intent = match event_parser { + RealtimeEventParser::V1 => Some("quicksilver"), + RealtimeEventParser::RealtimeV2 => None, + }; + let has_extra_query_params = query_params.is_some_and(|query_params| { + query_params + .iter() + .any(|(key, _)| key != "intent" && !(key == "model" && model.is_some())) + }); + if intent.is_some() || model.is_some() || has_extra_query_params { let mut query = url.query_pairs_mut(); - query.append_pair("intent", "quicksilver"); + if let Some(intent) = intent { + query.append_pair("intent", intent); + } if let Some(model) = model { query.append_pair("model", model); } @@ -636,7 +734,7 @@ mod tests { .to_string(); assert_eq!( - parse_realtime_event(payload.as_str()), + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), Some(RealtimeEvent::SessionUpdated { session_id: "sess_123".to_string(), instructions: Some("backend prompt".to_string()), @@ -655,7 +753,7 @@ mod tests { }) .to_string(); assert_eq!( - parse_realtime_event(payload.as_str()), + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { data: "AAA=".to_string(), sample_rate: 48000, @@ -673,7 +771,7 @@ mod tests { }) .to_string(); assert_eq!( - parse_realtime_event(payload.as_str()), + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), Some(RealtimeEvent::ConversationItemAdded( json!({"type": "message", "seq": 7}) )) @@ -688,7 +786,7 @@ mod tests { }) .to_string(); assert_eq!( - parse_realtime_event(payload.as_str()), + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), Some(RealtimeEvent::ConversationItemDone { item_id: "item_123".to_string(), }) @@ -706,7 +804,7 @@ mod tests { .to_string(); assert_eq!( - parse_realtime_event(payload.as_str()), + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { handoff_id: "handoff_123".to_string(), item_id: "item_123".to_string(), @@ -725,7 +823,7 @@ mod tests { .to_string(); assert_eq!( - parse_realtime_event(payload.as_str()), + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), Some(RealtimeEvent::InputTranscriptDelta( RealtimeTranscriptDelta { delta: "hello ".to_string(), @@ -743,7 +841,7 @@ mod tests { .to_string(); assert_eq!( - parse_realtime_event(payload.as_str()), + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), Some(RealtimeEvent::OutputTranscriptDelta( RealtimeTranscriptDelta { delta: "hi".to_string(), @@ -752,6 +850,68 @@ mod tests { ); } + #[test] + fn parse_realtime_v2_handoff_tool_call_event() { + let payload = json!({ + "type": "conversation.item.done", + "item": { + "id": "item_123", + "type": "function_call", + "name": "codex", + "call_id": "call_123", + "arguments": "{\"prompt\":\"delegate this\"}" + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "call_123".to_string(), + item_id: "item_123".to_string(), + input_transcript: "delegate this".to_string(), + active_transcript: Vec::new(), + })) + ); + } + + #[test] + fn parse_realtime_v2_input_audio_transcription_delta_event() { + let payload = json!({ + "type": "conversation.item.input_audio_transcription.delta", + "delta": "hello" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::InputTranscriptDelta( + RealtimeTranscriptDelta { + delta: "hello".to_string(), + } + )) + ); + } + + #[test] + fn parse_realtime_v2_output_audio_delta_defaults_audio_shape() { + let payload = json!({ + "type": "response.output_audio.delta", + "delta": "AQID" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::RealtimeV2), + Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: None, + })) + ); + } + #[test] fn merge_request_headers_matches_http_precedence() { let mut provider_headers = HeaderMap::new(); @@ -787,8 +947,14 @@ mod tests { #[test] fn websocket_url_from_http_base_defaults_to_ws_path() { - let url = - websocket_url_from_api_url("http://127.0.0.1:8011", None, None).expect("build ws url"); + let url = websocket_url_from_api_url( + "http://127.0.0.1:8011", + None, + None, + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); assert_eq!( url.as_str(), "ws://127.0.0.1:8011/v1/realtime?intent=quicksilver" @@ -797,9 +963,14 @@ mod tests { #[test] fn websocket_url_from_ws_base_defaults_to_ws_path() { - let url = - websocket_url_from_api_url("wss://example.com", None, Some("realtime-test-model")) - .expect("build ws url"); + let url = websocket_url_from_api_url( + "wss://example.com", + None, + Some("realtime-test-model"), + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); assert_eq!( url.as_str(), "wss://example.com/v1/realtime?intent=quicksilver&model=realtime-test-model" @@ -808,8 +979,14 @@ mod tests { #[test] fn websocket_url_from_v1_base_appends_realtime_path() { - let url = websocket_url_from_api_url("https://api.openai.com/v1", None, Some("snapshot")) - .expect("build ws url"); + let url = websocket_url_from_api_url( + "https://api.openai.com/v1", + None, + Some("snapshot"), + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); assert_eq!( url.as_str(), "wss://api.openai.com/v1/realtime?intent=quicksilver&model=snapshot" @@ -818,9 +995,14 @@ mod tests { #[test] fn websocket_url_from_nested_v1_base_appends_realtime_path() { - let url = - websocket_url_from_api_url("https://example.com/openai/v1", None, Some("snapshot")) - .expect("build ws url"); + let url = websocket_url_from_api_url( + "https://example.com/openai/v1", + None, + Some("snapshot"), + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); assert_eq!( url.as_str(), "wss://example.com/openai/v1/realtime?intent=quicksilver&model=snapshot" @@ -836,6 +1018,8 @@ mod tests { ("intent".to_string(), "ignored".to_string()), ])), Some("snapshot"), + RealtimeEventParser::V1, + RealtimeSessionMode::Conversational, ) .expect("build ws url"); assert_eq!( @@ -844,6 +1028,54 @@ mod tests { ); } + #[test] + fn websocket_url_v1_ignores_transcription_mode() { + let url = websocket_url_from_api_url( + "https://example.com", + None, + None, + RealtimeEventParser::V1, + RealtimeSessionMode::Transcription, + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/v1/realtime?intent=quicksilver" + ); + } + + #[test] + fn websocket_url_omits_intent_for_realtime_v2_conversational_mode() { + let url = websocket_url_from_api_url( + "https://example.com/v1/realtime?foo=bar", + Some(&HashMap::from([ + ("trace".to_string(), "1".to_string()), + ("intent".to_string(), "ignored".to_string()), + ])), + Some("snapshot"), + RealtimeEventParser::RealtimeV2, + RealtimeSessionMode::Conversational, + ) + .expect("build ws url"); + assert_eq!( + url.as_str(), + "wss://example.com/v1/realtime?foo=bar&model=snapshot&trace=1" + ); + } + + #[test] + fn websocket_url_omits_intent_for_realtime_v2_transcription_mode() { + let url = websocket_url_from_api_url( + "https://example.com", + None, + None, + RealtimeEventParser::RealtimeV2, + RealtimeSessionMode::Transcription, + ) + .expect("build ws url"); + assert_eq!(url.as_str(), "wss://example.com/v1/realtime"); + } + #[tokio::test] async fn e2e_connect_and_exchange_events_against_mock_ws_server() { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); @@ -1008,6 +1240,8 @@ mod tests { instructions: "backend prompt".to_string(), model: Some("realtime-test-model".to_string()), session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, }, HeaderMap::new(), HeaderMap::new(), @@ -1128,6 +1362,352 @@ mod tests { server.await.expect("server task"); } + #[tokio::test] + async fn realtime_v2_session_update_includes_codex_tool_and_handoff_output_item() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("accept ws"); + + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + assert_eq!( + first_json["session"]["type"], + Value::String("realtime".to_string()) + ); + assert_eq!( + first_json["session"]["tools"][0]["type"], + Value::String("function".to_string()) + ); + assert_eq!( + first_json["session"]["tools"][0]["name"], + Value::String("codex".to_string()) + ); + assert_eq!( + first_json["session"]["tools"][0]["parameters"]["required"], + json!(["prompt"]) + ); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_v2", "instructions": "backend prompt"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + + let second = ws + .next() + .await + .expect("second msg") + .expect("second msg ok") + .into_text() + .expect("text"); + let second_json: Value = serde_json::from_str(&second).expect("json"); + assert_eq!(second_json["type"], "conversation.item.create"); + assert_eq!( + second_json["item"]["type"], + Value::String("message".to_string()) + ); + assert_eq!( + second_json["item"]["content"][0]["type"], + Value::String("input_text".to_string()) + ); + assert_eq!( + second_json["item"]["content"][0]["text"], + Value::String("delegate this".to_string()) + ); + + let third = ws + .next() + .await + .expect("third msg") + .expect("third msg ok") + .into_text() + .expect("text"); + let third_json: Value = serde_json::from_str(&third).expect("json"); + assert_eq!(third_json["type"], "conversation.item.create"); + assert_eq!( + third_json["item"]["type"], + Value::String("function_call_output".to_string()) + ); + assert_eq!( + third_json["item"]["call_id"], + Value::String("call_1".to_string()) + ); + assert_eq!( + third_json["item"]["output"], + Value::String("delegated result".to_string()) + ); + }); + + let provider = Provider { + name: "test".to_string(), + base_url: format!("http://{addr}"), + query_params: Some(HashMap::new()), + headers: HeaderMap::new(), + retry: crate::provider::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + }; + let client = RealtimeWebsocketClient::new(provider); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::RealtimeV2, + session_mode: RealtimeSessionMode::Conversational, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let created = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + created, + RealtimeEvent::SessionUpdated { + session_id: "sess_v2".to_string(), + instructions: Some("backend prompt".to_string()), + } + ); + + connection + .send_conversation_item_create("delegate this".to_string()) + .await + .expect("send text item"); + connection + .send_conversation_handoff_append("call_1".to_string(), "delegated result".to_string()) + .await + .expect("send handoff output"); + + connection.close().await.expect("close"); + server.await.expect("server task"); + } + + #[tokio::test] + async fn transcription_mode_session_update_omits_output_audio_and_instructions() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("accept ws"); + + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + assert_eq!( + first_json["session"]["type"], + Value::String("transcription".to_string()) + ); + assert!(first_json["session"].get("instructions").is_none()); + assert!(first_json["session"]["audio"].get("output").is_none()); + assert_eq!( + first_json["session"]["tools"][0]["name"], + Value::String("codex".to_string()) + ); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_transcription"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + + let second = ws + .next() + .await + .expect("second msg") + .expect("second msg ok") + .into_text() + .expect("text"); + let second_json: Value = serde_json::from_str(&second).expect("json"); + assert_eq!(second_json["type"], "input_audio_buffer.append"); + }); + + let provider = Provider { + name: "test".to_string(), + base_url: format!("http://{addr}"), + query_params: Some(HashMap::new()), + headers: HeaderMap::new(), + retry: crate::provider::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + }; + let client = RealtimeWebsocketClient::new(provider); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::RealtimeV2, + session_mode: RealtimeSessionMode::Transcription, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let created = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + created, + RealtimeEvent::SessionUpdated { + session_id: "sess_transcription".to_string(), + instructions: None, + } + ); + + connection + .send_audio_frame(RealtimeAudioFrame { + data: "AQID".to_string(), + sample_rate: 24_000, + num_channels: 1, + samples_per_channel: Some(480), + }) + .await + .expect("send audio"); + + connection.close().await.expect("close"); + server.await.expect("server task"); + } + + #[tokio::test] + async fn v1_transcription_mode_is_treated_as_conversational() { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut ws = accept_async(stream).await.expect("accept ws"); + + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + assert_eq!( + first_json["session"]["type"], + Value::String("quicksilver".to_string()) + ); + assert_eq!( + first_json["session"]["instructions"], + Value::String("backend prompt".to_string()) + ); + assert_eq!( + first_json["session"]["audio"]["output"]["voice"], + Value::String("fathom".to_string()) + ); + assert!(first_json["session"].get("tools").is_none()); + + ws.send(Message::Text( + json!({ + "type": "session.updated", + "session": {"id": "sess_v1_mode"} + }) + .to_string() + .into(), + )) + .await + .expect("send session.updated"); + }); + + let provider = Provider { + name: "test".to_string(), + base_url: format!("http://{addr}"), + query_params: Some(HashMap::new()), + headers: HeaderMap::new(), + retry: crate::provider::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + }; + let client = RealtimeWebsocketClient::new(provider); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Transcription, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let created = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + created, + RealtimeEvent::SessionUpdated { + session_id: "sess_v1_mode".to_string(), + instructions: None, + } + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); + } + #[tokio::test] async fn send_does_not_block_while_next_event_waits_for_inbound_data() { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); @@ -1190,6 +1770,8 @@ mod tests { instructions: "backend prompt".to_string(), model: Some("realtime-test-model".to_string()), session_id: Some("conv_1".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, }, HeaderMap::new(), HeaderMap::new(), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs index a89dbd3e77..f307e60914 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/mod.rs @@ -1,5 +1,8 @@ pub mod methods; pub mod protocol; +mod protocol_common; +mod protocol_v1; +mod protocol_v2; pub use codex_protocol::protocol::RealtimeAudioFrame; pub use codex_protocol::protocol::RealtimeEvent; @@ -7,4 +10,6 @@ pub use methods::RealtimeWebsocketClient; pub use methods::RealtimeWebsocketConnection; pub use methods::RealtimeWebsocketEvents; pub use methods::RealtimeWebsocketWriter; +pub use protocol::RealtimeEventParser; pub use protocol::RealtimeSessionConfig; +pub use protocol::RealtimeSessionMode; diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index 7967d59991..028f51cf3d 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -1,3 +1,5 @@ +use crate::endpoint::realtime_websocket::protocol_v1::parse_realtime_event_v1; +use crate::endpoint::realtime_websocket::protocol_v2::parse_realtime_event_v2; pub use codex_protocol::protocol::RealtimeAudioFrame; pub use codex_protocol::protocol::RealtimeEvent; pub use codex_protocol::protocol::RealtimeHandoffRequested; @@ -5,13 +7,26 @@ pub use codex_protocol::protocol::RealtimeTranscriptDelta; pub use codex_protocol::protocol::RealtimeTranscriptEntry; use serde::Serialize; use serde_json::Value; -use tracing::debug; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RealtimeEventParser { + V1, + RealtimeV2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RealtimeSessionMode { + Conversational, + Transcription, +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct RealtimeSessionConfig { pub instructions: String, pub model: Option, pub session_id: Option, + pub event_parser: RealtimeEventParser, + pub session_mode: RealtimeSessionMode, } #[derive(Debug, Clone, Serialize)] @@ -27,21 +42,25 @@ pub(super) enum RealtimeOutboundMessage { #[serde(rename = "session.update")] SessionUpdate { session: SessionUpdateSession }, #[serde(rename = "conversation.item.create")] - ConversationItemCreate { item: ConversationItem }, + ConversationItemCreate { item: ConversationItemPayload }, } #[derive(Debug, Clone, Serialize)] pub(super) struct SessionUpdateSession { #[serde(rename = "type")] pub(super) kind: String, - pub(super) instructions: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) instructions: Option, pub(super) audio: SessionAudio, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) tools: Option>, } #[derive(Debug, Clone, Serialize)] pub(super) struct SessionAudio { pub(super) input: SessionAudioInput, - pub(super) output: SessionAudioOutput, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) output: Option, } #[derive(Debug, Clone, Serialize)] @@ -62,13 +81,28 @@ pub(super) struct SessionAudioOutput { } #[derive(Debug, Clone, Serialize)] -pub(super) struct ConversationItem { +pub(super) struct ConversationMessageItem { #[serde(rename = "type")] pub(super) kind: String, pub(super) role: String, pub(super) content: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub(super) enum ConversationItemPayload { + Message(ConversationMessageItem), + FunctionCallOutput(ConversationFunctionCallOutputItem), +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct ConversationFunctionCallOutputItem { + #[serde(rename = "type")] + pub(super) kind: String, + pub(super) call_id: String, + pub(super) output: String, +} + #[derive(Debug, Clone, Serialize)] pub(super) struct ConversationItemContent { #[serde(rename = "type")] @@ -76,124 +110,21 @@ pub(super) struct ConversationItemContent { pub(super) text: String, } -pub(super) fn parse_realtime_event(payload: &str) -> Option { - let parsed: Value = match serde_json::from_str(payload) { - Ok(msg) => msg, - Err(err) => { - debug!("failed to parse realtime event: {err}, data: {payload}"); - return None; - } - }; +#[derive(Debug, Clone, Serialize)] +pub(super) struct SessionFunctionTool { + #[serde(rename = "type")] + pub(super) kind: String, + pub(super) name: String, + pub(super) description: String, + pub(super) parameters: Value, +} - let message_type = match parsed.get("type").and_then(Value::as_str) { - Some(message_type) => message_type, - None => { - debug!("received realtime event without type field: {payload}"); - return None; - } - }; - match message_type { - "session.updated" => { - let session_id = parsed - .get("session") - .and_then(Value::as_object) - .and_then(|session| session.get("id")) - .and_then(Value::as_str) - .map(str::to_string); - let instructions = parsed - .get("session") - .and_then(Value::as_object) - .and_then(|session| session.get("instructions")) - .and_then(Value::as_str) - .map(str::to_string); - session_id.map(|session_id| RealtimeEvent::SessionUpdated { - session_id, - instructions, - }) - } - "conversation.output_audio.delta" => { - let data = parsed - .get("delta") - .and_then(Value::as_str) - .or_else(|| parsed.get("data").and_then(Value::as_str)) - .map(str::to_string)?; - let sample_rate = parsed - .get("sample_rate") - .and_then(Value::as_u64) - .and_then(|v| u32::try_from(v).ok())?; - let num_channels = parsed - .get("channels") - .or_else(|| parsed.get("num_channels")) - .and_then(Value::as_u64) - .and_then(|v| u16::try_from(v).ok())?; - Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { - data, - sample_rate, - num_channels, - samples_per_channel: parsed - .get("samples_per_channel") - .and_then(Value::as_u64) - .and_then(|v| u32::try_from(v).ok()), - })) - } - "conversation.input_transcript.delta" => parsed - .get("delta") - .and_then(Value::as_str) - .map(str::to_string) - .map(|delta| RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta { delta })), - "conversation.output_transcript.delta" => parsed - .get("delta") - .and_then(Value::as_str) - .map(str::to_string) - .map(|delta| RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { delta })), - "conversation.item.added" => parsed - .get("item") - .cloned() - .map(RealtimeEvent::ConversationItemAdded), - "conversation.item.done" => parsed - .get("item") - .and_then(Value::as_object) - .and_then(|item| item.get("id")) - .and_then(Value::as_str) - .map(str::to_string) - .map(|item_id| RealtimeEvent::ConversationItemDone { item_id }), - "conversation.handoff.requested" => { - let handoff_id = parsed - .get("handoff_id") - .and_then(Value::as_str) - .map(str::to_string)?; - let item_id = parsed - .get("item_id") - .and_then(Value::as_str) - .map(str::to_string)?; - let input_transcript = parsed - .get("input_transcript") - .and_then(Value::as_str) - .map(str::to_string)?; - Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { - handoff_id, - item_id, - input_transcript, - active_transcript: Vec::new(), - })) - } - "error" => parsed - .get("message") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - parsed - .get("error") - .and_then(Value::as_object) - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .map(str::to_string) - }) - .or_else(|| parsed.get("error").map(std::string::ToString::to_string)) - .map(RealtimeEvent::Error), - _ => { - debug!("received unsupported realtime event type: {message_type}, data: {payload}"); - None - } +pub(super) fn parse_realtime_event( + payload: &str, + event_parser: RealtimeEventParser, +) -> Option { + match event_parser { + RealtimeEventParser::V1 => parse_realtime_event_v1(payload), + RealtimeEventParser::RealtimeV2 => parse_realtime_event_v2(payload), } } diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_common.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_common.rs new file mode 100644 index 0000000000..dbd8544d94 --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_common.rs @@ -0,0 +1,71 @@ +use codex_protocol::protocol::RealtimeEvent; +use codex_protocol::protocol::RealtimeTranscriptDelta; +use serde_json::Value; +use tracing::debug; + +pub(super) fn parse_realtime_payload(payload: &str, parser_name: &str) -> Option<(Value, String)> { + let parsed: Value = match serde_json::from_str(payload) { + Ok(message) => message, + Err(err) => { + debug!("failed to parse {parser_name} event: {err}, data: {payload}"); + return None; + } + }; + + let message_type = match parsed.get("type").and_then(Value::as_str) { + Some(message_type) => message_type.to_string(), + None => { + debug!("received {parser_name} event without type field: {payload}"); + return None; + } + }; + + Some((parsed, message_type)) +} + +pub(super) fn parse_session_updated_event(parsed: &Value) -> Option { + let session_id = parsed + .get("session") + .and_then(Value::as_object) + .and_then(|session| session.get("id")) + .and_then(Value::as_str) + .map(str::to_string)?; + let instructions = parsed + .get("session") + .and_then(Value::as_object) + .and_then(|session| session.get("instructions")) + .and_then(Value::as_str) + .map(str::to_string); + Some(RealtimeEvent::SessionUpdated { + session_id, + instructions, + }) +} + +pub(super) fn parse_transcript_delta_event( + parsed: &Value, + field: &str, +) -> Option { + parsed + .get(field) + .and_then(Value::as_str) + .map(str::to_string) + .map(|delta| RealtimeTranscriptDelta { delta }) +} + +pub(super) fn parse_error_event(parsed: &Value) -> Option { + parsed + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + parsed + .get("error") + .and_then(Value::as_object) + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .or_else(|| parsed.get("error").map(ToString::to_string)) + .map(RealtimeEvent::Error) +} diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs new file mode 100644 index 0000000000..04e76fb447 --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs @@ -0,0 +1,83 @@ +use crate::endpoint::realtime_websocket::protocol_common::parse_error_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_realtime_payload; +use crate::endpoint::realtime_websocket::protocol_common::parse_session_updated_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_delta_event; +use codex_protocol::protocol::RealtimeAudioFrame; +use codex_protocol::protocol::RealtimeEvent; +use codex_protocol::protocol::RealtimeHandoffRequested; +use serde_json::Value; +use tracing::debug; + +pub(super) fn parse_realtime_event_v1(payload: &str) -> Option { + let (parsed, message_type) = parse_realtime_payload(payload, "realtime v1")?; + match message_type.as_str() { + "session.updated" => parse_session_updated_event(&parsed), + "conversation.output_audio.delta" => { + let data = parsed + .get("delta") + .and_then(Value::as_str) + .or_else(|| parsed.get("data").and_then(Value::as_str)) + .map(str::to_string)?; + let sample_rate = parsed + .get("sample_rate") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok())?; + let num_channels = parsed + .get("channels") + .or_else(|| parsed.get("num_channels")) + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok())?; + Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { + data, + sample_rate, + num_channels, + samples_per_channel: parsed + .get("samples_per_channel") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), + })) + } + "conversation.input_transcript.delta" => { + parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::InputTranscriptDelta) + } + "conversation.output_transcript.delta" => { + parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::OutputTranscriptDelta) + } + "conversation.item.added" => parsed + .get("item") + .cloned() + .map(RealtimeEvent::ConversationItemAdded), + "conversation.item.done" => parsed + .get("item") + .and_then(Value::as_object) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + .map(|item_id| RealtimeEvent::ConversationItemDone { item_id }), + "conversation.handoff.requested" => { + let handoff_id = parsed + .get("handoff_id") + .and_then(Value::as_str) + .map(str::to_string)?; + let item_id = parsed + .get("item_id") + .and_then(Value::as_str) + .map(str::to_string)?; + let input_transcript = parsed + .get("input_transcript") + .and_then(Value::as_str) + .map(str::to_string)?; + Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id, + item_id, + input_transcript, + active_transcript: Vec::new(), + })) + } + "error" => parse_error_event(&parsed), + _ => { + debug!("received unsupported realtime v1 event type: {message_type}, data: {payload}"); + None + } + } +} diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs new file mode 100644 index 0000000000..7ef318d3f9 --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v2.rs @@ -0,0 +1,130 @@ +use crate::endpoint::realtime_websocket::protocol_common::parse_error_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_realtime_payload; +use crate::endpoint::realtime_websocket::protocol_common::parse_session_updated_event; +use crate::endpoint::realtime_websocket::protocol_common::parse_transcript_delta_event; +use codex_protocol::protocol::RealtimeAudioFrame; +use codex_protocol::protocol::RealtimeEvent; +use codex_protocol::protocol::RealtimeHandoffRequested; +use serde_json::Map as JsonMap; +use serde_json::Value; +use tracing::debug; + +const CODEX_TOOL_NAME: &str = "codex"; +const DEFAULT_AUDIO_SAMPLE_RATE: u32 = 24_000; +const DEFAULT_AUDIO_CHANNELS: u16 = 1; +const TOOL_ARGUMENT_KEYS: [&str; 5] = ["input_transcript", "input", "text", "prompt", "query"]; + +pub(super) fn parse_realtime_event_v2(payload: &str) -> Option { + let (parsed, message_type) = parse_realtime_payload(payload, "realtime v2")?; + + match message_type.as_str() { + "session.updated" => parse_session_updated_event(&parsed), + "response.output_audio.delta" => parse_output_audio_delta_event(&parsed), + "conversation.item.input_audio_transcription.delta" => { + parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::InputTranscriptDelta) + } + "conversation.item.input_audio_transcription.completed" => { + parse_transcript_delta_event(&parsed, "transcript") + .map(RealtimeEvent::InputTranscriptDelta) + } + "response.output_text.delta" | "response.output_audio_transcript.delta" => { + parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::OutputTranscriptDelta) + } + "conversation.item.added" => parsed + .get("item") + .cloned() + .map(RealtimeEvent::ConversationItemAdded), + "conversation.item.done" => parse_conversation_item_done_event(&parsed), + "error" => parse_error_event(&parsed), + _ => { + debug!("received unsupported realtime v2 event type: {message_type}, data: {payload}"); + None + } + } +} + +fn parse_output_audio_delta_event(parsed: &Value) -> Option { + let data = parsed + .get("delta") + .and_then(Value::as_str) + .map(str::to_string)?; + let sample_rate = parsed + .get("sample_rate") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(DEFAULT_AUDIO_SAMPLE_RATE); + let num_channels = parsed + .get("channels") + .or_else(|| parsed.get("num_channels")) + .and_then(Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .unwrap_or(DEFAULT_AUDIO_CHANNELS); + Some(RealtimeEvent::AudioOut(RealtimeAudioFrame { + data, + sample_rate, + num_channels, + samples_per_channel: parsed + .get("samples_per_channel") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), + })) +} + +fn parse_conversation_item_done_event(parsed: &Value) -> Option { + let item = parsed.get("item")?.as_object()?; + if let Some(handoff) = parse_handoff_requested_event(item) { + return Some(handoff); + } + + item.get("id") + .and_then(Value::as_str) + .map(str::to_string) + .map(|item_id| RealtimeEvent::ConversationItemDone { item_id }) +} + +fn parse_handoff_requested_event(item: &JsonMap) -> Option { + let item_type = item.get("type").and_then(Value::as_str); + let item_name = item.get("name").and_then(Value::as_str); + if item_type != Some("function_call") || item_name != Some(CODEX_TOOL_NAME) { + return None; + } + + let call_id = item + .get("call_id") + .and_then(Value::as_str) + .or_else(|| item.get("id").and_then(Value::as_str))?; + let item_id = item + .get("id") + .and_then(Value::as_str) + .unwrap_or(call_id) + .to_string(); + let arguments = item.get("arguments").and_then(Value::as_str).unwrap_or(""); + + Some(RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: call_id.to_string(), + item_id, + input_transcript: extract_input_transcript(arguments), + active_transcript: Vec::new(), + })) +} + +fn extract_input_transcript(arguments: &str) -> String { + if arguments.is_empty() { + return String::new(); + } + + if let Ok(arguments_json) = serde_json::from_str::(arguments) + && let Some(arguments_object) = arguments_json.as_object() + { + for key in TOOL_ARGUMENT_KEYS { + if let Some(value) = arguments_object.get(key).and_then(Value::as_str) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + } + } + + arguments.to_string() +} diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 138929602b..a1588a983e 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -27,7 +27,9 @@ pub use crate::common::create_text_param_for_request; pub use crate::endpoint::compact::CompactClient; pub use crate::endpoint::memories::MemoriesClient; pub use crate::endpoint::models::ModelsClient; +pub use crate::endpoint::realtime_websocket::RealtimeEventParser; pub use crate::endpoint::realtime_websocket::RealtimeSessionConfig; +pub use crate::endpoint::realtime_websocket::RealtimeSessionMode; pub use crate::endpoint::realtime_websocket::RealtimeWebsocketClient; pub use crate::endpoint::realtime_websocket::RealtimeWebsocketConnection; pub use crate::endpoint::responses::ResponsesClient; diff --git a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs index aa11b79584..30786ad92d 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -4,10 +4,13 @@ use std::time::Duration; use codex_api::RealtimeAudioFrame; use codex_api::RealtimeEvent; +use codex_api::RealtimeEventParser; use codex_api::RealtimeSessionConfig; +use codex_api::RealtimeSessionMode; use codex_api::RealtimeWebsocketClient; use codex_api::provider::Provider; use codex_api::provider::RetryConfig; +use codex_protocol::protocol::RealtimeHandoffRequested; use futures::SinkExt; use futures::StreamExt; use http::HeaderMap; @@ -139,6 +142,8 @@ async fn realtime_ws_e2e_session_create_and_event_flow() { instructions: "backend prompt".to_string(), model: Some("realtime-test-model".to_string()), session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, }, HeaderMap::new(), HeaderMap::new(), @@ -231,6 +236,8 @@ async fn realtime_ws_e2e_send_while_next_event_waits() { instructions: "backend prompt".to_string(), model: Some("realtime-test-model".to_string()), session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, }, HeaderMap::new(), HeaderMap::new(), @@ -294,6 +301,8 @@ async fn realtime_ws_e2e_disconnected_emitted_once() { instructions: "backend prompt".to_string(), model: Some("realtime-test-model".to_string()), session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, }, HeaderMap::new(), HeaderMap::new(), @@ -354,6 +363,8 @@ async fn realtime_ws_e2e_ignores_unknown_text_events() { instructions: "backend prompt".to_string(), model: Some("realtime-test-model".to_string()), session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::V1, + session_mode: RealtimeSessionMode::Conversational, }, HeaderMap::new(), HeaderMap::new(), @@ -377,3 +388,70 @@ async fn realtime_ws_e2e_ignores_unknown_text_events() { connection.close().await.expect("close"); server.await.expect("server task"); } + +#[tokio::test] +async fn realtime_ws_e2e_realtime_v2_parser_emits_handoff_requested() { + let (addr, server) = spawn_realtime_ws_server(|mut ws: RealtimeWsStream| async move { + let first = ws + .next() + .await + .expect("first msg") + .expect("first msg ok") + .into_text() + .expect("text"); + let first_json: Value = serde_json::from_str(&first).expect("json"); + assert_eq!(first_json["type"], "session.update"); + + ws.send(Message::Text( + json!({ + "type": "conversation.item.done", + "item": { + "id": "item_123", + "type": "function_call", + "name": "codex", + "call_id": "call_123", + "arguments": "{\"prompt\":\"delegate now\"}" + } + }) + .to_string() + .into(), + )) + .await + .expect("send function call"); + }) + .await; + + let client = RealtimeWebsocketClient::new(test_provider(format!("http://{addr}"))); + let connection = client + .connect( + RealtimeSessionConfig { + instructions: "backend prompt".to_string(), + model: Some("realtime-test-model".to_string()), + session_id: Some("conv_123".to_string()), + event_parser: RealtimeEventParser::RealtimeV2, + session_mode: RealtimeSessionMode::Conversational, + }, + HeaderMap::new(), + HeaderMap::new(), + ) + .await + .expect("connect"); + + let event = connection + .next_event() + .await + .expect("next event") + .expect("event"); + assert_eq!( + event, + RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "call_123".to_string(), + item_id: "item_123".to_string(), + input_transcript: "delegate now".to_string(), + active_transcript: Vec::new(), + }) + ); + + connection.close().await.expect("close"); + server.await.expect("server task"); +} diff --git a/codex-rs/config/src/cloud_requirements.rs b/codex-rs/config/src/cloud_requirements.rs index 1cf58563b9..85b904824d 100644 --- a/codex-rs/config/src/cloud_requirements.rs +++ b/codex-rs/config/src/cloud_requirements.rs @@ -6,18 +6,43 @@ use std::fmt; use std::future::Future; use thiserror::Error; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CloudRequirementsLoadErrorCode { + Auth, + Timeout, + Parse, + RequestFailed, + Internal, +} + #[derive(Clone, Debug, Eq, Error, PartialEq)] #[error("{message}")] pub struct CloudRequirementsLoadError { + code: CloudRequirementsLoadErrorCode, message: String, + status_code: Option, } impl CloudRequirementsLoadError { - pub fn new(message: impl Into) -> Self { + pub fn new( + code: CloudRequirementsLoadErrorCode, + status_code: Option, + message: impl Into, + ) -> Self { Self { + code, message: message.into(), + status_code, } } + + pub fn code(&self) -> CloudRequirementsLoadErrorCode { + self.code + } + + pub fn status_code(&self) -> Option { + self.status_code + } } #[derive(Clone)] diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index c8e25e6e60..e6355c6cc3 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -11,6 +11,7 @@ mod state; pub const CONFIG_TOML_FILE: &str = "config.toml"; pub use cloud_requirements::CloudRequirementsLoadError; +pub use cloud_requirements::CloudRequirementsLoadErrorCode; pub use cloud_requirements::CloudRequirementsLoader; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5a0353b5df..40949dfa6b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -426,6 +426,9 @@ "realtime_conversation": { "type": "boolean" }, + "realtime_conversation_v2": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, @@ -1339,6 +1342,13 @@ }, "type": "object" }, + "RealtimeWsMode": { + "enum": [ + "conversational", + "transcription" + ], + "type": "string" + }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1813,6 +1823,14 @@ "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport base URL (the `Op::RealtimeConversation` `/v1/realtime` connection) without changing normal provider HTTP requests.", "type": "string" }, + "experimental_realtime_ws_mode": { + "allOf": [ + { + "$ref": "#/definitions/RealtimeWsMode" + } + ], + "description": "Experimental / do not use. Selects the realtime websocket intent mode. `conversational` is speech-to-speech while `transcription` is transcript-only." + }, "experimental_realtime_ws_model": { "description": "Experimental / do not use. Selects the realtime websocket model/snapshot used for the `Op::RealtimeConversation` connection.", "type": "string" @@ -1937,6 +1955,9 @@ "realtime_conversation": { "type": "boolean" }, + "realtime_conversation_v2": { + "type": "boolean" + }, "remote_models": { "type": "boolean" }, diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/core/src/analytics_client.rs index f2df7e010d..bfb90bc4da 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/core/src/analytics_client.rs @@ -3,6 +3,7 @@ use crate::config::Config; use crate::default_client::create_client; use crate::git_info::collect_git_info; use crate::git_info::get_git_repo_root; +use crate::plugins::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use serde::Serialize; use sha1::Digest; @@ -59,9 +60,11 @@ pub(crate) struct AppInvocation { pub(crate) struct AnalyticsEventsQueue { sender: mpsc::Sender, app_used_emitted_keys: Arc>>, + plugin_used_emitted_keys: Arc>>, } -pub(crate) struct AnalyticsEventsClient { +#[derive(Clone)] +pub struct AnalyticsEventsClient { queue: AnalyticsEventsQueue, config: Arc, } @@ -81,12 +84,28 @@ impl AnalyticsEventsQueue { TrackEventsJob::AppUsed(job) => { send_track_app_used(&auth_manager, job).await; } + TrackEventsJob::PluginUsed(job) => { + send_track_plugin_used(&auth_manager, job).await; + } + TrackEventsJob::PluginInstalled(job) => { + send_track_plugin_installed(&auth_manager, job).await; + } + TrackEventsJob::PluginUninstalled(job) => { + send_track_plugin_uninstalled(&auth_manager, job).await; + } + TrackEventsJob::PluginEnabled(job) => { + send_track_plugin_enabled(&auth_manager, job).await; + } + TrackEventsJob::PluginDisabled(job) => { + send_track_plugin_disabled(&auth_manager, job).await; + } } } }); Self { sender, app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), } } @@ -105,15 +124,30 @@ impl AnalyticsEventsQueue { .app_used_emitted_keys .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if emitted.len() >= ANALYTICS_APP_USED_DEDUPE_MAX_KEYS { + if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS { emitted.clear(); } emitted.insert((tracking.turn_id.clone(), connector_id.clone())) } + + fn should_enqueue_plugin_used( + &self, + tracking: &TrackEventsContext, + plugin: &PluginTelemetryMetadata, + ) -> bool { + let mut emitted = self + .plugin_used_emitted_keys + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS { + emitted.clear(); + } + emitted.insert((tracking.turn_id.clone(), plugin.plugin_id.as_key())) + } } impl AnalyticsEventsClient { - pub(crate) fn new(config: Arc, auth_manager: Arc) -> Self { + pub fn new(config: Arc, auth_manager: Arc) -> Self { Self { queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager)), config, @@ -149,12 +183,66 @@ impl AnalyticsEventsClient { pub(crate) fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { track_app_used(&self.queue, Arc::clone(&self.config), Some(tracking), app); } + + pub(crate) fn track_plugin_used( + &self, + tracking: TrackEventsContext, + plugin: PluginTelemetryMetadata, + ) { + track_plugin_used( + &self.queue, + Arc::clone(&self.config), + Some(tracking), + plugin, + ); + } + + pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { + track_plugin_management( + &self.queue, + Arc::clone(&self.config), + PluginManagementEventType::Installed, + plugin, + ); + } + + pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { + track_plugin_management( + &self.queue, + Arc::clone(&self.config), + PluginManagementEventType::Uninstalled, + plugin, + ); + } + + pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) { + track_plugin_management( + &self.queue, + Arc::clone(&self.config), + PluginManagementEventType::Enabled, + plugin, + ); + } + + pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) { + track_plugin_management( + &self.queue, + Arc::clone(&self.config), + PluginManagementEventType::Disabled, + plugin, + ); + } } enum TrackEventsJob { SkillInvocations(TrackSkillInvocationsJob), AppMentioned(TrackAppMentionedJob), AppUsed(TrackAppUsedJob), + PluginUsed(TrackPluginUsedJob), + PluginInstalled(TrackPluginManagementJob), + PluginUninstalled(TrackPluginManagementJob), + PluginEnabled(TrackPluginManagementJob), + PluginDisabled(TrackPluginManagementJob), } struct TrackSkillInvocationsJob { @@ -175,9 +263,28 @@ struct TrackAppUsedJob { app: AppInvocation, } +struct TrackPluginUsedJob { + config: Arc, + tracking: TrackEventsContext, + plugin: PluginTelemetryMetadata, +} + +struct TrackPluginManagementJob { + config: Arc, + plugin: PluginTelemetryMetadata, +} + +#[derive(Clone, Copy)] +enum PluginManagementEventType { + Installed, + Uninstalled, + Enabled, + Disabled, +} + const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256; const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10); -const ANALYTICS_APP_USED_DEDUPE_MAX_KEYS: usize = 4096; +const ANALYTICS_EVENT_DEDUPE_MAX_KEYS: usize = 4096; #[derive(Serialize)] struct TrackEventsRequest { @@ -190,6 +297,11 @@ enum TrackEventRequest { SkillInvocation(SkillInvocationEventRequest), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + PluginUsed(CodexPluginUsedEventRequest), + PluginInstalled(CodexPluginEventRequest), + PluginUninstalled(CodexPluginEventRequest), + PluginEnabled(CodexPluginEventRequest), + PluginDisabled(CodexPluginEventRequest), } #[derive(Serialize)] @@ -233,6 +345,38 @@ struct CodexAppUsedEventRequest { event_params: CodexAppMetadata, } +#[derive(Serialize)] +struct CodexPluginMetadata { + plugin_id: Option, + plugin_name: Option, + marketplace_name: Option, + has_skills: Option, + mcp_server_count: Option, + connector_ids: Option>, + product_client_id: Option, +} + +#[derive(Serialize)] +struct CodexPluginUsedMetadata { + #[serde(flatten)] + plugin: CodexPluginMetadata, + thread_id: Option, + turn_id: Option, + model_slug: Option, +} + +#[derive(Serialize)] +struct CodexPluginEventRequest { + event_type: &'static str, + event_params: CodexPluginMetadata, +} + +#[derive(Serialize)] +struct CodexPluginUsedEventRequest { + event_type: &'static str, + event_params: CodexPluginUsedMetadata, +} + pub(crate) fn track_skill_invocations( queue: &AnalyticsEventsQueue, config: Arc, @@ -302,6 +446,48 @@ pub(crate) fn track_app_used( queue.try_send(job); } +pub(crate) fn track_plugin_used( + queue: &AnalyticsEventsQueue, + config: Arc, + tracking: Option, + plugin: PluginTelemetryMetadata, +) { + if config.analytics_enabled == Some(false) { + return; + } + let Some(tracking) = tracking else { + return; + }; + if !queue.should_enqueue_plugin_used(&tracking, &plugin) { + return; + } + let job = TrackEventsJob::PluginUsed(TrackPluginUsedJob { + config, + tracking, + plugin, + }); + queue.try_send(job); +} + +fn track_plugin_management( + queue: &AnalyticsEventsQueue, + config: Arc, + event_type: PluginManagementEventType, + plugin: PluginTelemetryMetadata, +) { + if config.analytics_enabled == Some(false) { + return; + } + let job = TrackPluginManagementJob { config, plugin }; + let job = match event_type { + PluginManagementEventType::Installed => TrackEventsJob::PluginInstalled(job), + PluginManagementEventType::Uninstalled => TrackEventsJob::PluginUninstalled(job), + PluginManagementEventType::Enabled => TrackEventsJob::PluginEnabled(job), + PluginManagementEventType::Disabled => TrackEventsJob::PluginDisabled(job), + }; + queue.try_send(job); +} + async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkillInvocationsJob) { let TrackSkillInvocationsJob { config, @@ -385,6 +571,58 @@ async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) { send_track_events(auth_manager, config, events).await; } +async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsedJob) { + let TrackPluginUsedJob { + config, + tracking, + plugin, + } = job; + let events = vec![TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest { + event_type: "codex_plugin_used", + event_params: codex_plugin_used_metadata(&tracking, plugin), + })]; + + send_track_events(auth_manager, config, events).await; +} + +async fn send_track_plugin_installed(auth_manager: &AuthManager, job: TrackPluginManagementJob) { + send_track_plugin_management_event(auth_manager, job, "codex_plugin_installed").await; +} + +async fn send_track_plugin_uninstalled(auth_manager: &AuthManager, job: TrackPluginManagementJob) { + send_track_plugin_management_event(auth_manager, job, "codex_plugin_uninstalled").await; +} + +async fn send_track_plugin_enabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) { + send_track_plugin_management_event(auth_manager, job, "codex_plugin_enabled").await; +} + +async fn send_track_plugin_disabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) { + send_track_plugin_management_event(auth_manager, job, "codex_plugin_disabled").await; +} + +async fn send_track_plugin_management_event( + auth_manager: &AuthManager, + job: TrackPluginManagementJob, + event_type: &'static str, +) { + let TrackPluginManagementJob { config, plugin } = job; + let event_params = codex_plugin_metadata(plugin); + let event = CodexPluginEventRequest { + event_type, + event_params, + }; + let events = vec![match event_type { + "codex_plugin_installed" => TrackEventRequest::PluginInstalled(event), + "codex_plugin_uninstalled" => TrackEventRequest::PluginUninstalled(event), + "codex_plugin_enabled" => TrackEventRequest::PluginEnabled(event), + "codex_plugin_disabled" => TrackEventRequest::PluginDisabled(event), + _ => unreachable!("unknown plugin management event type"), + }]; + + send_track_events(auth_manager, config, events).await; +} + fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> CodexAppMetadata { CodexAppMetadata { connector_id: app.connector_id, @@ -397,6 +635,41 @@ fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> Code } } +fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata { + let capability_summary = plugin.capability_summary; + CodexPluginMetadata { + plugin_id: Some(plugin.plugin_id.as_key()), + plugin_name: Some(plugin.plugin_id.plugin_name), + marketplace_name: Some(plugin.plugin_id.marketplace_name), + has_skills: capability_summary + .as_ref() + .map(|summary| summary.has_skills), + mcp_server_count: capability_summary + .as_ref() + .map(|summary| summary.mcp_server_names.len()), + connector_ids: capability_summary.map(|summary| { + summary + .app_connector_ids + .into_iter() + .map(|connector_id| connector_id.0) + .collect() + }), + product_client_id: Some(crate::default_client::originator().value), + } +} + +fn codex_plugin_used_metadata( + tracking: &TrackEventsContext, + plugin: PluginTelemetryMetadata, +) -> CodexPluginUsedMetadata { + CodexPluginUsedMetadata { + plugin: codex_plugin_metadata(plugin), + thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), + model_slug: Some(tracking.model_slug.clone()), + } +} + async fn send_track_events( auth_manager: &AuthManager, config: Arc, diff --git a/codex-rs/core/src/analytics_client_tests.rs b/codex-rs/core/src/analytics_client_tests.rs index 66e9a1234b..a038aae047 100644 --- a/codex-rs/core/src/analytics_client_tests.rs +++ b/codex-rs/core/src/analytics_client_tests.rs @@ -2,11 +2,19 @@ use super::AnalyticsEventsQueue; use super::AppInvocation; use super::CodexAppMentionedEventRequest; use super::CodexAppUsedEventRequest; +use super::CodexPluginEventRequest; +use super::CodexPluginUsedEventRequest; use super::InvocationType; use super::TrackEventRequest; use super::TrackEventsContext; use super::codex_app_metadata; +use super::codex_plugin_metadata; +use super::codex_plugin_used_metadata; use super::normalize_path_for_skill_id; +use crate::plugins::AppConnectorId; +use crate::plugins::PluginCapabilitySummary; +use crate::plugins::PluginId; +use crate::plugins::PluginTelemetryMetadata; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -153,6 +161,7 @@ fn app_used_dedupe_is_keyed_by_turn_and_connector() { let queue = AnalyticsEventsQueue { sender, app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), }; let app = AppInvocation { connector_id: Some("calendar".to_string()), @@ -175,3 +184,106 @@ fn app_used_dedupe_is_keyed_by_turn_and_connector() { assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), false); assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true); } + +#[test] +fn plugin_used_event_serializes_expected_shape() { + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest { + event_type: "codex_plugin_used", + event_params: codex_plugin_used_metadata(&tracking, sample_plugin_metadata()), + }); + + let payload = serde_json::to_value(&event).expect("serialize plugin used event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_plugin_used", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": crate::default_client::originator().value, + "thread_id": "thread-3", + "turn_id": "turn-3", + "model_slug": "gpt-5" + } + }) + ); +} + +#[test] +fn plugin_management_event_serializes_expected_shape() { + let event = TrackEventRequest::PluginInstalled(CodexPluginEventRequest { + event_type: "codex_plugin_installed", + event_params: codex_plugin_metadata(sample_plugin_metadata()), + }); + + let payload = serde_json::to_value(&event).expect("serialize plugin installed event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_plugin_installed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": crate::default_client::originator().value + } + }) + ); +} + +#[test] +fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() { + let (sender, _receiver) = mpsc::channel(1); + let queue = AnalyticsEventsQueue { + sender, + app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())), + }; + let plugin = sample_plugin_metadata(); + + let turn_1 = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + let turn_2 = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-2".to_string(), + }; + + assert_eq!(queue.should_enqueue_plugin_used(&turn_1, &plugin), true); + assert_eq!(queue.should_enqueue_plugin_used(&turn_1, &plugin), false); + assert_eq!(queue.should_enqueue_plugin_used(&turn_2, &plugin), true); +} + +fn sample_plugin_metadata() -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + plugin_id: PluginId::parse("sample@test").expect("valid plugin id"), + capability_summary: Some(PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: vec!["mcp-1".to_string(), "mcp-2".to_string()], + app_connector_ids: vec![ + AppConnectorId("calendar".to_string()), + AppConnectorId("drive".to_string()), + ], + }), + } +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 0e5d6bc2aa..1c0f6e1f28 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -106,6 +106,7 @@ use codex_protocol::protocol::TurnContextNetworkItem; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsArgs; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -2908,7 +2909,7 @@ impl Session { match turn_context.approval_policy.value() { AskForApproval::Never => { return Some(RequestPermissionsResponse { - permissions: PermissionProfile::default(), + permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, }); } @@ -2916,7 +2917,7 @@ impl Session { if !granular_config.allows_request_permissions() => { return Some(RequestPermissionsResponse { - permissions: PermissionProfile::default(), + permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, }); } @@ -3102,7 +3103,7 @@ impl Session { if entry.is_some() && !response.permissions.is_empty() { match response.scope { PermissionGrantScope::Turn => { - ts.record_granted_permissions(response.permissions.clone()); + ts.record_granted_permissions(response.permissions.clone().into()); } PermissionGrantScope::Session => { granted_for_session = Some(response.permissions.clone()); @@ -3116,7 +3117,7 @@ impl Session { }; if let Some(permissions) = granted_for_session { let mut state = self.state.lock().await; - state.record_granted_permissions(permissions); + state.record_granted_permissions(permissions.into()); } match entry { Some(tx_response) => { @@ -5482,6 +5483,10 @@ pub(crate) async fn run_turn( let plugin_items = build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); + let mentioned_plugin_metadata = mentioned_plugins + .iter() + .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) + .collect::>(); let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input); explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items( @@ -5520,6 +5525,11 @@ pub(crate) async fn run_turn( sess.services .analytics_events_client .track_app_mentioned(tracking.clone(), mentioned_app_invocations); + for plugin in mentioned_plugin_metadata { + sess.services + .analytics_events_client + .track_plugin_used(tracking.clone(), plugin); + } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 3d4c49c9df..e2b7657e83 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -1,13 +1,13 @@ use super::*; use async_channel::bounded; use codex_protocol::models::NetworkPermissions; -use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; +use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsEvent; use codex_protocol::request_permissions::RequestPermissionsResponse; use pretty_assertions::assert_eq; @@ -150,11 +150,11 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { let call_id = "tool-call-1".to_string(); let expected_response = RequestPermissionsResponse { - permissions: PermissionProfile { + permissions: RequestPermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), - ..PermissionProfile::default() + ..RequestPermissionProfile::default() }, scope: PermissionGrantScope::Turn, }; @@ -175,11 +175,11 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { call_id: request_call_id, turn_id: "child-turn-1".to_string(), reason: Some("need access".to_string()), - permissions: PermissionProfile { + permissions: RequestPermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), - ..PermissionProfile::default() + ..RequestPermissionProfile::default() }, }, &cancel_token, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 5e397d69ac..0d3c69bb76 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -25,6 +25,7 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; use tracing::Span; use crate::protocol::CompactedItem; @@ -2216,11 +2217,11 @@ async fn notify_request_permissions_response_ignores_unmatched_call_id() { .notify_request_permissions_response( "missing", codex_protocol::request_permissions::RequestPermissionsResponse { - permissions: codex_protocol::models::PermissionProfile { + permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { enabled: Some(true), }), - ..Default::default() + ..RequestPermissionProfile::default() }, scope: PermissionGrantScope::Turn, }, @@ -2252,11 +2253,11 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() let turn_context = Arc::new(turn_context); let call_id = "call-1".to_string(); let expected_response = codex_protocol::request_permissions::RequestPermissionsResponse { - permissions: codex_protocol::models::PermissionProfile { + permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { enabled: Some(true), }), - ..Default::default() + ..RequestPermissionProfile::default() }, scope: PermissionGrantScope::Turn, }; @@ -2272,11 +2273,11 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() call_id, codex_protocol::request_permissions::RequestPermissionsArgs { reason: Some("need network".to_string()), - permissions: codex_protocol::models::PermissionProfile { + permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { enabled: Some(true), }), - ..Default::default() + ..RequestPermissionProfile::default() }, }, ) @@ -2332,11 +2333,11 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req call_id, codex_protocol::request_permissions::RequestPermissionsArgs { reason: Some("need network".to_string()), - permissions: codex_protocol::models::PermissionProfile { + permissions: RequestPermissionProfile { network: Some(codex_protocol::models::NetworkPermissions { enabled: Some(true), }), - ..Default::default() + ..RequestPermissionProfile::default() }, }, ) @@ -2346,7 +2347,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req response, Some( codex_protocol::request_permissions::RequestPermissionsResponse { - permissions: codex_protocol::models::PermissionProfile::default(), + permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, } ) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 00e5a17564..0fe4b58157 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4129,6 +4129,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, + experimental_realtime_ws_mode: RealtimeWsMode::Conversational, experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, base_instructions: None, @@ -4265,6 +4266,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, + experimental_realtime_ws_mode: RealtimeWsMode::Conversational, experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, base_instructions: None, @@ -4399,6 +4401,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, + experimental_realtime_ws_mode: RealtimeWsMode::Conversational, experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, base_instructions: None, @@ -4519,6 +4522,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, experimental_realtime_ws_model: None, + experimental_realtime_ws_mode: RealtimeWsMode::Conversational, experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, base_instructions: None, @@ -5566,6 +5570,34 @@ experimental_realtime_ws_model = "realtime-test-model" Ok(()) } +#[test] +fn experimental_realtime_ws_mode_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_mode = "transcription" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_mode, + Some(RealtimeWsMode::Transcription) + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.experimental_realtime_ws_mode, + RealtimeWsMode::Transcription + ); + Ok(()) +} + #[test] fn realtime_audio_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e4e90fca46..d239c91bb0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -463,6 +463,9 @@ pub struct Config { /// Experimental / do not use. Selects the realtime websocket model/snapshot /// used for the `Op::RealtimeConversation` connection. pub experimental_realtime_ws_model: Option, + /// Experimental / do not use. Selects the realtime websocket intent mode. + /// `conversational` is speech-to-speech while `transcription` is transcript-only. + pub experimental_realtime_ws_mode: RealtimeWsMode, /// Experimental / do not use. Overrides only the realtime conversation /// websocket transport instructions (the `Op::RealtimeConversation` /// `/ws` session.update instructions) without changing normal prompts. @@ -1238,6 +1241,9 @@ pub struct ConfigToml { /// Experimental / do not use. Selects the realtime websocket model/snapshot /// used for the `Op::RealtimeConversation` connection. pub experimental_realtime_ws_model: Option, + /// Experimental / do not use. Selects the realtime websocket intent mode. + /// `conversational` is speech-to-speech while `transcription` is transcript-only. + pub experimental_realtime_ws_mode: Option, /// Experimental / do not use. Overrides only the realtime conversation /// websocket transport instructions (the `Op::RealtimeConversation` /// `/ws` session.update instructions) without changing normal prompts. @@ -1383,6 +1389,14 @@ pub struct RealtimeAudioConfig { pub speaker: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RealtimeWsMode { + #[default] + Conversational, + Transcription, +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct RealtimeAudioToml { @@ -2462,6 +2476,7 @@ impl Config { }), experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, experimental_realtime_ws_model: cfg.experimental_realtime_ws_model, + experimental_realtime_ws_mode: cfg.experimental_realtime_ws_mode.unwrap_or_default(), experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index b94bf81d08..60f9d5d7e1 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -25,6 +25,7 @@ use std::path::PathBuf; use toml::Value as TomlValue; pub use codex_config::CloudRequirementsLoadError; +pub use codex_config::CloudRequirementsLoadErrorCode; pub use codex_config::CloudRequirementsLoader; pub use codex_config::ConfigError; pub use codex_config::ConfigLayerEntry; diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index fb8de08615..452c5da98c 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -741,7 +741,11 @@ async fn load_config_layers_fails_when_cloud_requirements_loader_fails() -> anyh &[] as &[(String, TomlValue)], LoaderOverrides::default(), CloudRequirementsLoader::new(async { - Err(CloudRequirementsLoadError::new("cloud requirements failed")) + Err(CloudRequirementsLoadError::new( + codex_config::CloudRequirementsLoadErrorCode::RequestFailed, + None, + "cloud requirements failed", + )) }), ) .await diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index d0e4e290b0..8072add4a0 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -180,6 +180,8 @@ pub enum Feature { VoiceTranscription, /// Enable experimental realtime voice conversation mode in the TUI. RealtimeConversation, + /// Route realtime conversations through the v2 event parser. + RealtimeConversationV2, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, /// Use the Responses API WebSocket transport for OpenAI by default. @@ -823,6 +825,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RealtimeConversationV2, + key: "realtime_conversation_v2", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::PreventIdleSleep, key: "prevent_idle_sleep", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index acf5b7a83a..e213d473bd 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -101,6 +101,7 @@ pub type NewConversation = NewThread; #[deprecated(note = "use CodexThread")] pub type CodexConversation = CodexThread; // Re-export common auth types for workspace consumers +pub use analytics_client::AnalyticsEventsClient; pub use auth::AuthManager; pub use auth::CodexAuth; pub use auth::from_auth_storage_with_core_client; diff --git a/codex-rs/core/src/mcp/skill_dependencies_tests.rs b/codex-rs/core/src/mcp/skill_dependencies_tests.rs index 68af0df984..49ba4e9f7f 100644 --- a/codex-rs/core/src/mcp/skill_dependencies_tests.rs +++ b/codex-rs/core/src/mcp/skill_dependencies_tests.rs @@ -13,6 +13,7 @@ fn skill_with_tools(tools: Vec) -> SkillMetadata { dependencies: Some(SkillDependencies { tools }), policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: PathBuf::from("skill"), scope: SkillScope::User, } diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 0c198c6a75..bedf78bda9 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -19,6 +19,7 @@ use super::store::PluginInstallResult as StorePluginInstallResult; use super::store::PluginStore; use super::store::PluginStoreError; use super::sync_openai_plugins_repo; +use crate::analytics_client::AnalyticsEventsClient; use crate::auth::CodexAuth; use crate::config::Config; use crate::config::ConfigService; @@ -160,6 +161,21 @@ pub struct PluginCapabilitySummary { pub app_connector_ids: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginTelemetryMetadata { + pub plugin_id: PluginId, + pub capability_summary: Option, +} + +impl PluginTelemetryMetadata { + pub fn from_plugin_id(plugin_id: &PluginId) -> Self { + Self { + plugin_id: plugin_id.clone(), + capability_summary: None, + } + } +} + impl PluginCapabilitySummary { fn from_plugin(plugin: &LoadedPlugin) -> Option { if !plugin.is_active() { @@ -186,6 +202,15 @@ impl PluginCapabilitySummary { || !summary.app_connector_ids.is_empty()) .then_some(summary) } + + pub fn telemetry_metadata(&self) -> Option { + PluginId::parse(&self.config_name) + .ok() + .map(|plugin_id| PluginTelemetryMetadata { + plugin_id, + capability_summary: Some(self.clone()), + }) + } } #[derive(Debug, Clone, PartialEq)] @@ -369,6 +394,7 @@ pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, cache_by_cwd: RwLock>, + analytics_events_client: RwLock>, } impl PluginsManager { @@ -377,9 +403,18 @@ impl PluginsManager { codex_home: codex_home.clone(), store: PluginStore::new(codex_home), cache_by_cwd: RwLock::new(HashMap::new()), + analytics_events_client: RwLock::new(None), } } + pub fn set_analytics_events_client(&self, analytics_events_client: AnalyticsEventsClient) { + let mut stored_client = match self.analytics_events_client.write() { + Ok(client_guard) => client_guard, + Err(err) => err.into_inner(), + }; + *stored_client = Some(analytics_events_client); + } + pub fn plugins_for_config(&self, config: &Config) -> PluginLoadOutcome { self.plugins_for_layer_stack(&config.cwd, &config.config_layer_stack, false) } @@ -466,6 +501,17 @@ impl PluginsManager { .map(|_| ()) .map_err(PluginInstallError::from)?; + let analytics_events_client = match self.analytics_events_client.read() { + Ok(client) => client.clone(), + Err(err) => err.into_inner().clone(), + }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_plugin_installed(plugin_telemetry_metadata_from_root( + &result.plugin_id, + result.installed_path.as_path(), + )); + } + Ok(PluginInstallOutcome { plugin_id: result.plugin_id, plugin_version: result.plugin_version, @@ -476,6 +522,10 @@ impl PluginsManager { pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> { let plugin_id = PluginId::parse(&plugin_id)?; + let plugin_telemetry = self + .store + .active_plugin_root(&plugin_id) + .map(|_| installed_plugin_telemetry_metadata(self.codex_home.as_path(), &plugin_id)); let store = self.store.clone(); let plugin_id_for_store = plugin_id.clone(); tokio::task::spawn_blocking(move || store.uninstall(&plugin_id_for_store)) @@ -489,6 +539,16 @@ impl PluginsManager { .apply() .await?; + let analytics_events_client = match self.analytics_events_client.read() { + Ok(client) => client.clone(), + Err(err) => err.into_inner().clone(), + }; + if let Some(plugin_telemetry) = plugin_telemetry + && let Some(analytics_events_client) = analytics_events_client + { + analytics_events_client.track_plugin_uninstalled(plugin_telemetry); + } + Ok(()) } @@ -1360,6 +1420,52 @@ fn load_apps_from_paths( connector_ids } +pub fn plugin_telemetry_metadata_from_root( + plugin_id: &PluginId, + plugin_root: &Path, +) -> PluginTelemetryMetadata { + let Some(manifest) = load_plugin_manifest(plugin_root) else { + return PluginTelemetryMetadata::from_plugin_id(plugin_id); + }; + + let manifest_paths = plugin_manifest_paths(&manifest, plugin_root); + let has_skills = !plugin_skill_roots(plugin_root, &manifest_paths).is_empty(); + let mut mcp_server_names = Vec::new(); + for path in plugin_mcp_config_paths(plugin_root, &manifest_paths) { + mcp_server_names.extend( + load_mcp_servers_from_file(plugin_root, &path) + .mcp_servers + .into_keys(), + ); + } + mcp_server_names.sort_unstable(); + mcp_server_names.dedup(); + + PluginTelemetryMetadata { + plugin_id: plugin_id.clone(), + capability_summary: Some(PluginCapabilitySummary { + config_name: plugin_id.as_key(), + display_name: plugin_id.plugin_name.clone(), + description: None, + has_skills, + mcp_server_names, + app_connector_ids: load_plugin_apps(plugin_root), + }), + } +} + +pub fn installed_plugin_telemetry_metadata( + codex_home: &Path, + plugin_id: &PluginId, +) -> PluginTelemetryMetadata { + let store = PluginStore::new(codex_home.to_path_buf()); + let Some(plugin_root) = store.active_plugin_root(plugin_id) else { + return PluginTelemetryMetadata::from_plugin_id(plugin_id); + }; + + plugin_telemetry_metadata_from_root(plugin_id, plugin_root.as_path()) +} + fn load_mcp_servers_from_file( plugin_root: &Path, mcp_config_path: &AbsolutePathBuf, diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 2f543aa679..b3b292c109 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -226,6 +226,50 @@ fn load_plugins_loads_default_skills_and_mcp_servers() { ); } +#[test] +fn plugin_telemetry_metadata_uses_default_mcp_config_path() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample" +}"#, + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + + let metadata = plugin_telemetry_metadata_from_root( + &PluginId::parse("sample@test").expect("plugin id should parse"), + &plugin_root, + ); + + assert_eq!( + metadata.capability_summary, + Some(PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: false, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }) + ); +} + #[test] fn load_plugins_uses_manifest_configured_component_paths() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 9aac379265..7eb76798ad 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -5,6 +5,7 @@ mod manifest; mod marketplace; mod render; mod store; +mod toggles; pub(crate) use curated_repo::curated_plugins_repo_path; pub(crate) use curated_repo::read_curated_plugins_sha; @@ -23,11 +24,14 @@ pub use manager::PluginLoadOutcome; pub use manager::PluginReadOutcome; pub use manager::PluginReadRequest; pub use manager::PluginRemoteSyncError; +pub use manager::PluginTelemetryMetadata; pub use manager::PluginUninstallError; pub use manager::PluginsManager; pub use manager::RemotePluginSyncResult; +pub use manager::installed_plugin_telemetry_metadata; pub use manager::load_plugin_apps; pub(crate) use manager::plugin_namespace_for_skill_path; +pub use manager::plugin_telemetry_metadata_from_root; pub use manifest::PluginManifestInterfaceSummary; pub(crate) use manifest::PluginManifestPaths; pub(crate) use manifest::load_plugin_manifest; @@ -41,3 +45,4 @@ pub use marketplace::MarketplacePluginSourceSummary; pub(crate) use render::render_explicit_plugin_instructions; pub(crate) use render::render_plugins_section; pub use store::PluginId; +pub use toggles::collect_plugin_enabled_candidates; diff --git a/codex-rs/core/src/plugins/toggles.rs b/codex-rs/core/src/plugins/toggles.rs new file mode 100644 index 0000000000..215943dc16 --- /dev/null +++ b/codex-rs/core/src/plugins/toggles.rs @@ -0,0 +1,100 @@ +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; + +pub fn collect_plugin_enabled_candidates<'a>( + edits: impl Iterator, +) -> BTreeMap { + let mut pending_changes = BTreeMap::new(); + for (key_path, value) in edits { + let segments = key_path + .split('.') + .map(str::to_string) + .collect::>(); + match segments.as_slice() { + [plugins, plugin_id, enabled] + if plugins == "plugins" && enabled == "enabled" && value.is_boolean() => + { + if let Some(enabled) = value.as_bool() { + pending_changes.insert(plugin_id.clone(), enabled); + } + } + [plugins, plugin_id] if plugins == "plugins" => { + if let Some(enabled) = value.get("enabled").and_then(JsonValue::as_bool) { + pending_changes.insert(plugin_id.clone(), enabled); + } + } + [plugins] if plugins == "plugins" => { + let Some(entries) = value.as_object() else { + continue; + }; + for (plugin_id, plugin_value) in entries { + let Some(enabled) = plugin_value.get("enabled").and_then(JsonValue::as_bool) + else { + continue; + }; + pending_changes.insert(plugin_id.clone(), enabled); + } + } + _ => {} + } + } + + pending_changes +} + +#[cfg(test)] +mod tests { + use super::collect_plugin_enabled_candidates; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::collections::BTreeMap; + + #[test] + fn collect_plugin_enabled_candidates_tracks_direct_and_table_writes() { + let candidates = collect_plugin_enabled_candidates( + [ + (&"plugins.sample@test.enabled".to_string(), &json!(true)), + ( + &"plugins.other@test".to_string(), + &json!({ "enabled": false, "ignored": true }), + ), + ( + &"plugins".to_string(), + &json!({ + "nested@test": { "enabled": true }, + "skip@test": { "name": "skip" }, + }), + ), + ] + .into_iter(), + ); + + assert_eq!( + candidates, + BTreeMap::from([ + ("nested@test".to_string(), true), + ("other@test".to_string(), false), + ("sample@test".to_string(), true), + ]) + ); + } + + #[test] + fn collect_plugin_enabled_candidates_uses_last_write_for_same_plugin() { + let candidates = collect_plugin_enabled_candidates( + [ + (&"plugins.sample@test.enabled".to_string(), &json!(true)), + ( + &"plugins.sample@test".to_string(), + &json!({ "enabled": false }), + ), + ] + .into_iter(), + ); + + assert_eq!( + candidates, + BTreeMap::from([("sample@test".to_string(), false)]) + ); + } +} diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index f1ce8398e3..03407a4274 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -5,6 +5,7 @@ use crate::codex::Session; use crate::default_client::default_headers; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::features::Feature; use crate::realtime_context::build_realtime_startup_context; use async_channel::Receiver; use async_channel::Sender; @@ -12,7 +13,9 @@ use async_channel::TrySendError; use codex_api::Provider as ApiProvider; use codex_api::RealtimeAudioFrame; use codex_api::RealtimeEvent; +use codex_api::RealtimeEventParser; use codex_api::RealtimeSessionConfig; +use codex_api::RealtimeSessionMode; use codex_api::RealtimeWebsocketClient; use codex_api::endpoint::realtime_websocket::RealtimeWebsocketEvents; use codex_api::endpoint::realtime_websocket::RealtimeWebsocketWriter; @@ -114,9 +117,7 @@ impl RealtimeConversationManager { &self, api_provider: ApiProvider, extra_headers: Option, - prompt: String, - model: Option, - session_id: Option, + session_config: RealtimeSessionConfig, ) -> CodexResult<(Receiver, Arc)> { let previous_state = { let mut guard = self.state.lock().await; @@ -128,11 +129,6 @@ impl RealtimeConversationManager { let _ = state.task.await; } - let session_config = RealtimeSessionConfig { - instructions: prompt, - model, - session_id, - }; let client = RealtimeWebsocketClient::new(api_provider); let connection = client .connect( @@ -298,22 +294,31 @@ pub(crate) async fn handle_start( format!("{prompt}\n\n{startup_context}") }; let model = config.experimental_realtime_ws_model.clone(); - + let event_parser = if config.features.enabled(Feature::RealtimeConversationV2) { + RealtimeEventParser::RealtimeV2 + } else { + RealtimeEventParser::V1 + }; + let session_mode = match config.experimental_realtime_ws_mode { + crate::config::RealtimeWsMode::Conversational => RealtimeSessionMode::Conversational, + crate::config::RealtimeWsMode::Transcription => RealtimeSessionMode::Transcription, + }; let requested_session_id = params .session_id .or_else(|| Some(sess.conversation_id.to_string())); + let session_config = RealtimeSessionConfig { + instructions: prompt, + model, + session_id: requested_session_id.clone(), + event_parser, + session_mode, + }; let extra_headers = realtime_request_headers(requested_session_id.as_deref(), realtime_api_key.as_str())?; info!("starting realtime conversation"); let (events_rx, realtime_active) = match sess .conversation - .start( - api_provider, - extra_headers, - prompt, - model, - requested_session_id.clone(), - ) + .start(api_provider, extra_headers, session_config) .await { Ok(events_rx) => events_rx, diff --git a/codex-rs/core/src/skills/injection_tests.rs b/codex-rs/core/src/skills/injection_tests.rs index 74ff315bb8..8d66a0af57 100644 --- a/codex-rs/core/src/skills/injection_tests.rs +++ b/codex-rs/core/src/skills/injection_tests.rs @@ -12,6 +12,7 @@ fn make_skill(name: &str, path: &str) -> SkillMetadata { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: PathBuf::from(path), scope: codex_protocol::protocol::SkillScope::User, } diff --git a/codex-rs/core/src/skills/invocation_utils_tests.rs b/codex-rs/core/src/skills/invocation_utils_tests.rs index bf244ce767..657582b742 100644 --- a/codex-rs/core/src/skills/invocation_utils_tests.rs +++ b/codex-rs/core/src/skills/invocation_utils_tests.rs @@ -19,6 +19,7 @@ fn test_skill_metadata(skill_doc_path: PathBuf) -> SkillMetadata { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: skill_doc_path, scope: codex_protocol::protocol::SkillScope::User, } diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index b2dd48b3ae..c1aa24c928 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -8,11 +8,15 @@ use crate::skills::model::SkillDependencies; use crate::skills::model::SkillError; use crate::skills::model::SkillInterface; use crate::skills::model::SkillLoadOutcome; +use crate::skills::model::SkillManagedNetworkOverride; use crate::skills::model::SkillMetadata; use crate::skills::model::SkillPolicy; use crate::skills::model::SkillToolDependency; use crate::skills::system::system_cache_root_dir; use codex_app_server_protocol::ConfigLayerSource; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::models::MacOsSeatbeltProfileExtensions; +use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBufGuard; @@ -32,8 +36,6 @@ use tracing::error; #[cfg(test)] use crate::config::Config; -#[cfg(test)] -use codex_protocol::models::NetworkPermissions; #[derive(Debug, Deserialize)] struct SkillFrontmatter { @@ -60,7 +62,7 @@ struct SkillMetadataFile { #[serde(default)] policy: Option, #[serde(default)] - permissions: Option, + permissions: Option, } #[derive(Default)] @@ -69,6 +71,27 @@ struct LoadedSkillMetadata { dependencies: Option, policy: Option, permission_profile: Option, + managed_network_override: Option, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +struct SkillPermissionProfile { + #[serde(default)] + network: Option, + #[serde(default)] + file_system: Option, + #[serde(default)] + macos: Option, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +struct SkillNetworkPermissions { + #[serde(default)] + enabled: Option, + #[serde(default)] + allowed_domains: Option>, + #[serde(default)] + denied_domains: Option>, } #[derive(Debug, Default, Deserialize)] @@ -527,6 +550,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result Result LoadedSkillMetadata { policy, permissions, } = parsed; + let (permission_profile, managed_network_override) = normalize_permissions(permissions); LoadedSkillMetadata { interface: resolve_interface(interface, skill_dir), dependencies: resolve_dependencies(dependencies), policy: resolve_policy(policy), - permission_profile: permissions.filter(|profile| !profile.is_empty()), + permission_profile, + managed_network_override, } } +fn normalize_permissions( + permissions: Option, +) -> ( + Option, + Option, +) { + let Some(permissions) = permissions else { + return (None, None); + }; + let managed_network_override = permissions + .network + .as_ref() + .map(|network| SkillManagedNetworkOverride { + allowed_domains: network.allowed_domains.clone(), + denied_domains: network.denied_domains.clone(), + }) + .filter(SkillManagedNetworkOverride::has_domain_overrides); + let permission_profile = PermissionProfile { + network: permissions.network.and_then(|network| { + let network = NetworkPermissions { + enabled: network.enabled, + }; + (!network.is_empty()).then_some(network) + }), + file_system: permissions + .file_system + .filter(|file_system| !file_system.is_empty()), + macos: permissions.macos, + }; + + ( + (!permission_profile.is_empty()).then_some(permission_profile), + managed_network_override, + ) +} + fn resolve_interface(interface: Option, skill_dir: &Path) -> Option { let interface = interface?; let interface = SkillInterface { diff --git a/codex-rs/core/src/skills/loader_tests.rs b/codex-rs/core/src/skills/loader_tests.rs index feee09a9ab..2da1f9cd20 100644 --- a/codex-rs/core/src/skills/loader_tests.rs +++ b/codex-rs/core/src/skills/loader_tests.rs @@ -239,6 +239,7 @@ fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -390,6 +391,7 @@ async fn loads_skill_dependencies_metadata_from_yaml() { }), policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -446,6 +448,7 @@ interface: dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(skill_path.as_path()), scope: SkillScope::User, }] @@ -568,6 +571,7 @@ permissions: macos: None, }) ); + assert_eq!(outcome.skills[0].managed_network_override, None); } #[tokio::test] @@ -595,6 +599,70 @@ permissions: {} assert_eq!(outcome.skills[0].permission_profile, None); } +#[test] +fn normalize_permissions_splits_managed_network_overrides() { + let (permission_profile, managed_network_override) = + normalize_permissions(Some(SkillPermissionProfile { + network: Some(SkillNetworkPermissions { + enabled: Some(true), + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: Some(vec!["blocked.skill.example.com".to_string()]), + }), + file_system: None, + macos: None, + })); + + assert_eq!( + permission_profile, + Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: None, + macos: None, + }) + ); + assert_eq!( + managed_network_override, + Some(SkillManagedNetworkOverride { + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: Some(vec!["blocked.skill.example.com".to_string()]), + }) + ); +} + +#[test] +fn normalize_permissions_preserves_network_gate_separately_from_overrides() { + let (permission_profile, managed_network_override) = + normalize_permissions(Some(SkillPermissionProfile { + network: Some(SkillNetworkPermissions { + enabled: Some(false), + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: None, + }), + file_system: None, + macos: None, + })); + + assert_eq!( + permission_profile, + Some(PermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(false), + }), + file_system: None, + macos: None, + }) + ); + assert_eq!( + managed_network_override, + Some(SkillManagedNetworkOverride { + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: None, + }) + ); +} + #[test] fn skill_metadata_parses_macos_permissions_yaml() { let parsed = serde_yaml::from_str::( @@ -613,7 +681,9 @@ permissions: assert_eq!( parsed.permissions, - Some(PermissionProfile { + Some(SkillPermissionProfile { + network: None, + file_system: None, macos: Some(MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadWrite, macos_automation: MacOsAutomationPermission::BundleIds(vec![ @@ -625,7 +695,6 @@ permissions: macos_reminders: false, macos_contacts: MacOsContactsPermission::None, }), - ..Default::default() }) ); } @@ -643,7 +712,9 @@ permissions: assert_eq!( parsed.permissions, - Some(PermissionProfile { + Some(SkillPermissionProfile { + network: None, + file_system: None, macos: Some(MacOsSeatbeltProfileExtensions { macos_preferences: MacOsPreferencesPermission::ReadOnly, macos_automation: MacOsAutomationPermission::None, @@ -653,7 +724,35 @@ permissions: macos_reminders: true, macos_contacts: MacOsContactsPermission::None, }), - ..Default::default() + }) + ); +} + +#[test] +fn skill_metadata_parses_network_domain_overrides_under_permissions() { + let parsed = serde_yaml::from_str::( + r#" +permissions: + network: + enabled: true + allowed_domains: + - "skill.example.com" + denied_domains: + - "blocked.skill.example.com" +"#, + ) + .expect("parse network skill metadata"); + + assert_eq!( + parsed.permissions, + Some(SkillPermissionProfile { + network: Some(SkillNetworkPermissions { + enabled: Some(true), + allowed_domains: Some(vec!["skill.example.com".to_string()]), + denied_domains: Some(vec!["blocked.skill.example.com".to_string()]), + }), + file_system: None, + macos: None, }) ); } @@ -801,6 +900,7 @@ async fn accepts_icon_paths_under_assets_dir() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -842,6 +942,7 @@ async fn ignores_invalid_brand_color() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -896,6 +997,7 @@ async fn ignores_default_prompt_over_max_length() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -938,6 +1040,7 @@ async fn drops_interface_when_icons_are_invalid() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -983,6 +1086,7 @@ async fn loads_skills_via_symlinked_subdir_for_user_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::User, }] @@ -1043,6 +1147,7 @@ async fn does_not_loop_on_symlink_cycle_for_user_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1080,6 +1185,7 @@ fn loads_skills_via_symlinked_subdir_for_admin_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::Admin, }] @@ -1120,6 +1226,7 @@ async fn loads_skills_via_symlinked_subdir_for_repo_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&linked_skill_path), scope: SkillScope::Repo, }] @@ -1188,6 +1295,7 @@ async fn respects_max_scan_depth_for_user_scope() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&within_depth_path), scope: SkillScope::User, }] @@ -1216,6 +1324,7 @@ async fn loads_valid_skill() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1249,6 +1358,7 @@ async fn falls_back_to_directory_name_when_skill_name_is_missing() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1291,6 +1401,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1323,6 +1434,7 @@ async fn loads_short_description_from_metadata() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, }] @@ -1436,6 +1548,7 @@ async fn loads_skills_from_repo_root() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1472,6 +1585,7 @@ async fn loads_skills_from_agents_dir_without_codex_dir() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1526,6 +1640,7 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&nested_skill_path), scope: SkillScope::Repo, }, @@ -1537,6 +1652,7 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&root_skill_path), scope: SkillScope::Repo, }, @@ -1577,6 +1693,7 @@ async fn loads_skills_from_codex_dir_when_not_git_repo() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1615,6 +1732,7 @@ async fn deduplicates_by_path_preferring_first_root() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1657,6 +1775,7 @@ async fn keeps_duplicate_names_from_repo_and_user() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&repo_skill_path), scope: SkillScope::Repo, }, @@ -1668,6 +1787,7 @@ async fn keeps_duplicate_names_from_repo_and_user() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&user_skill_path), scope: SkillScope::User, }, @@ -1732,6 +1852,7 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: first_path, scope: SkillScope::Repo, }, @@ -1743,6 +1864,7 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: second_path, scope: SkillScope::Repo, }, @@ -1815,6 +1937,7 @@ async fn loads_skills_when_cwd_is_file_in_repo() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, }] @@ -1874,6 +1997,7 @@ async fn loads_skills_from_system_cache_when_present() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::System, }] diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index 525ea28da3..f8a63f0b26 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -5,6 +5,19 @@ use std::sync::Arc; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SkillScope; +use serde::Deserialize; + +#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)] +pub struct SkillManagedNetworkOverride { + pub allowed_domains: Option>, + pub denied_domains: Option>, +} + +impl SkillManagedNetworkOverride { + pub fn has_domain_overrides(&self) -> bool { + self.allowed_domains.is_some() || self.denied_domains.is_some() + } +} #[derive(Debug, Clone, PartialEq)] pub struct SkillMetadata { @@ -15,6 +28,7 @@ pub struct SkillMetadata { pub dependencies: Option, pub policy: Option, pub permission_profile: Option, + pub managed_network_override: Option, /// Path to the SKILLS.md file that declares this skill. pub path_to_skills_md: PathBuf, pub scope: SkillScope, diff --git a/codex-rs/core/src/tools/code_mode/bridge.js b/codex-rs/core/src/tools/code_mode/bridge.js index 5989985f1a..ab353feede 100644 --- a/codex-rs/core/src/tools/code_mode/bridge.js +++ b/codex-rs/core/src/tools/code_mode/bridge.js @@ -29,11 +29,6 @@ Object.defineProperty(globalThis, '__codexContentItems', { defineGlobal('ALL_TOOLS', __codexRuntime.ALL_TOOLS); defineGlobal('image', __codexRuntime.image); defineGlobal('load', __codexRuntime.load); - defineGlobal( - 'set_max_output_tokens_per_exec_call', - __codexRuntime.set_max_output_tokens_per_exec_call - ); - defineGlobal('set_yield_time', __codexRuntime.set_yield_time); defineGlobal('store', __codexRuntime.store); defineGlobal('text', __codexRuntime.text); defineGlobal('tools', __codexRuntime.tools); diff --git a/codex-rs/core/src/tools/code_mode/description.md b/codex-rs/core/src/tools/code_mode/description.md index d5e5645452..6aa21c5dc1 100644 --- a/codex-rs/core/src/tools/code_mode/description.md +++ b/codex-rs/core/src/tools/code_mode/description.md @@ -1,6 +1,9 @@ ## exec - Runs raw JavaScript in an isolated context (no Node, no file system, or network access, no console). - Send raw JavaScript source text, not JSON, quoted strings, or markdown code fences. +- You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. +- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. +- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. - All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. - Tool methods take either string or object as parameter. - They return either a structured value or a string based on the description above. @@ -8,9 +11,7 @@ - Global helpers: - `text(value: string | number | boolean | undefined | null)`: Appends a text item and returns it. Non-string values are stringified with `JSON.stringify(...)` when possible. - `image(imageUrl: string)`: Appends an image item and returns it. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. -- `store(key: string, value: any)`: stores a serializeable value under a string key for later `exec` calls in the same session. +- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session. - `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing. - `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries. -- `set_max_output_tokens_per_exec_call(value)`: sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. -- `set_yield_time(value)`: asks `exec` to yield early after that many milliseconds if the script is still running. - `yield_control()`: yields the accumulated output to the model immediately while the script keeps running. diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 56a13ae47c..6c9009948a 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use serde::Deserialize; use crate::codex::Session; use crate::codex::TurnContext; @@ -9,6 +10,7 @@ use crate::tools::context::ToolPayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +use super::CODE_MODE_PRAGMA_PREFIX; use super::CodeModeSessionProgress; use super::ExecContext; use super::PUBLIC_TOOL_NAME; @@ -18,6 +20,23 @@ use super::protocol::HostToNodeMessage; use super::protocol::build_source; pub struct CodeModeExecuteHandler; +const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct CodeModeExecPragma { + #[serde(default)] + yield_time_ms: Option, + #[serde(default)] + max_output_tokens: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct CodeModeExecArgs { + code: String, + yield_time_ms: Option, + max_output_tokens: Option, +} impl CodeModeExecuteHandler { async fn execute( @@ -26,12 +45,13 @@ impl CodeModeExecuteHandler { turn: std::sync::Arc, code: String, ) -> Result { + let args = parse_freeform_args(&code)?; let exec = ExecContext { session, turn }; let enabled_tools = build_enabled_tools(&exec).await; let service = &exec.session.services.code_mode_service; let stored_values = service.stored_values().await; let source = - build_source(&code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?; + build_source(&args.code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?; let cell_id = service.allocate_cell_id().await; let request_id = service.allocate_request_id().await; let process_slot = service @@ -46,6 +66,8 @@ impl CodeModeExecuteHandler { enabled_tools, stored_values, source, + yield_time_ms: args.yield_time_ms, + max_output_tokens: args.max_output_tokens, }; let result = { let mut process_slot = process_slot; @@ -72,6 +94,91 @@ impl CodeModeExecuteHandler { } } +fn parse_freeform_args(input: &str) -> Result { + if input.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "exec expects raw JavaScript source text (non-empty). Provide JS only, optionally with first-line `// @exec: {\"yield_time_ms\": 10000, \"max_output_tokens\": 1000}`.".to_string(), + )); + } + + let mut args = CodeModeExecArgs { + code: input.to_string(), + yield_time_ms: None, + max_output_tokens: None, + }; + + let mut lines = input.splitn(2, '\n'); + let first_line = lines.next().unwrap_or_default(); + let rest = lines.next().unwrap_or_default(); + let trimmed = first_line.trim_start(); + let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { + return Ok(args); + }; + + if rest.trim().is_empty() { + return Err(FunctionCallError::RespondToModel( + "exec pragma must be followed by JavaScript source on subsequent lines".to_string(), + )); + } + + let directive = pragma.trim(); + if directive.is_empty() { + return Err(FunctionCallError::RespondToModel( + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string(), + )); + } + + let value: serde_json::Value = serde_json::from_str(directive).map_err(|err| { + FunctionCallError::RespondToModel(format!( + "exec pragma must be valid JSON with supported fields `yield_time_ms` and `max_output_tokens`: {err}" + )) + })?; + let object = value.as_object().ok_or_else(|| { + FunctionCallError::RespondToModel( + "exec pragma must be a JSON object with supported fields `yield_time_ms` and `max_output_tokens`" + .to_string(), + ) + })?; + for key in object.keys() { + match key.as_str() { + "yield_time_ms" | "max_output_tokens" => {} + _ => { + return Err(FunctionCallError::RespondToModel(format!( + "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `{key}`" + ))); + } + } + } + + let pragma: CodeModeExecPragma = serde_json::from_value(value).map_err(|err| { + FunctionCallError::RespondToModel(format!( + "exec pragma fields `yield_time_ms` and `max_output_tokens` must be non-negative safe integers: {err}" + )) + })?; + if pragma + .yield_time_ms + .is_some_and(|yield_time_ms| yield_time_ms > MAX_JS_SAFE_INTEGER) + { + return Err(FunctionCallError::RespondToModel( + "exec pragma field `yield_time_ms` must be a non-negative safe integer".to_string(), + )); + } + if pragma.max_output_tokens.is_some_and(|max_output_tokens| { + u64::try_from(max_output_tokens) + .map(|max_output_tokens| max_output_tokens > MAX_JS_SAFE_INTEGER) + .unwrap_or(true) + }) { + return Err(FunctionCallError::RespondToModel( + "exec pragma field `max_output_tokens` must be a non-negative safe integer".to_string(), + )); + } + args.code = rest.to_string(); + args.yield_time_ms = pragma.yield_time_ms; + args.max_output_tokens = pragma.max_output_tokens; + Ok(args) +} + #[async_trait] impl ToolHandler for CodeModeExecuteHandler { type Output = FunctionToolOutput; @@ -103,3 +210,7 @@ impl ToolHandler for CodeModeExecuteHandler { } } } + +#[cfg(test)] +#[path = "execute_handler_tests.rs"] +mod execute_handler_tests; diff --git a/codex-rs/core/src/tools/code_mode/execute_handler_tests.rs b/codex-rs/core/src/tools/code_mode/execute_handler_tests.rs new file mode 100644 index 0000000000..ed22b337b2 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/execute_handler_tests.rs @@ -0,0 +1,41 @@ +use super::parse_freeform_args; +use pretty_assertions::assert_eq; + +#[test] +fn parse_freeform_args_without_pragma() { + let args = parse_freeform_args("output_text('ok');").expect("parse args"); + assert_eq!(args.code, "output_text('ok');"); + assert_eq!(args.yield_time_ms, None); + assert_eq!(args.max_output_tokens, None); +} + +#[test] +fn parse_freeform_args_with_pragma() { + let input = concat!( + "// @exec: {\"yield_time_ms\": 15000, \"max_output_tokens\": 2000}\n", + "output_text('ok');", + ); + let args = parse_freeform_args(input).expect("parse args"); + assert_eq!(args.code, "output_text('ok');"); + assert_eq!(args.yield_time_ms, Some(15_000)); + assert_eq!(args.max_output_tokens, Some(2_000)); +} + +#[test] +fn parse_freeform_args_rejects_unknown_key() { + let err = parse_freeform_args("// @exec: {\"nope\": 1}\noutput_text('ok');") + .expect_err("expected error"); + assert_eq!( + err.to_string(), + "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `nope`" + ); +} + +#[test] +fn parse_freeform_args_rejects_missing_source() { + let err = parse_freeform_args("// @exec: {\"yield_time_ms\": 10}").expect_err("expected error"); + assert_eq!( + err.to_string(), + "exec pragma must be followed by JavaScript source on subsequent lines" + ); +} diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index ab25fd1ed3..ae362693e0 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -33,6 +33,7 @@ const CODE_MODE_RUNNER_SOURCE: &str = include_str!("runner.cjs"); const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("bridge.js"); const CODE_MODE_DESCRIPTION_TEMPLATE: &str = include_str!("description.md"); const CODE_MODE_WAIT_DESCRIPTION_TEMPLATE: &str = include_str!("wait_description.md"); +const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:"; pub(crate) const PUBLIC_TOOL_NAME: &str = "exec"; pub(crate) const WAIT_TOOL_NAME: &str = "exec_wait"; @@ -222,6 +223,7 @@ fn enabled_tool_from_spec(spec: ToolSpec) -> Option { } let reference = code_mode_tool_reference(&tool_name); + let global_name = normalize_code_mode_identifier(&tool_name); let (description, kind) = match spec { ToolSpec::Function(tool) => (tool.description, protocol::CodeModeToolKind::Function), ToolSpec::Freeform(tool) => (tool.description, protocol::CodeModeToolKind::Freeform), @@ -234,8 +236,8 @@ fn enabled_tool_from_spec(spec: ToolSpec) -> Option { }; Some(protocol::EnabledTool { - global_name: normalize_code_mode_identifier(&tool_name), tool_name, + global_name, module_path: reference.module_path, namespace: reference.namespace, name: normalize_code_mode_identifier(&reference.tool_key), diff --git a/codex-rs/core/src/tools/code_mode/protocol.rs b/codex-rs/core/src/tools/code_mode/protocol.rs index 44757f858d..fc0a497eac 100644 --- a/codex-rs/core/src/tools/code_mode/protocol.rs +++ b/codex-rs/core/src/tools/code_mode/protocol.rs @@ -46,6 +46,8 @@ pub(super) enum HostToNodeMessage { enabled_tools: Vec, stored_values: HashMap, source: String, + yield_time_ms: Option, + max_output_tokens: Option, }, Poll { request_id: String, diff --git a/codex-rs/core/src/tools/code_mode/runner.cjs b/codex-rs/core/src/tools/code_mode/runner.cjs index b498650a85..a5d457e5bc 100644 --- a/codex-rs/core/src/tools/code_mode/runner.cjs +++ b/codex-rs/core/src/tools/code_mode/runner.cjs @@ -47,22 +47,6 @@ function codeModeWorkerMain() { const vm = require('node:vm'); const { SourceTextModule, SyntheticModule } = vm; - const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL = 10000; - - function normalizeMaxOutputTokensPerExecCall(value) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('max_output_tokens_per_exec_call must be a non-negative safe integer'); - } - return value; - } - - function normalizeYieldTime(value) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError('yield_time must be a non-negative safe integer'); - } - return value; - } - function formatErrorText(error) { return String(error && error.stack ? error.stack : error); } @@ -270,23 +254,6 @@ function codeModeWorkerMain() { ensureContentItems(context).push(item); return item; }; - const setMaxOutputTokensPerExecCall = (value) => { - const normalized = normalizeMaxOutputTokensPerExecCall(value); - state.maxOutputTokensPerExecCall = normalized; - parentPort.postMessage({ - type: 'set_max_output_tokens_per_exec_call', - value: normalized, - }); - return normalized; - }; - const setYieldTime = (value) => { - const normalized = normalizeYieldTime(value); - parentPort.postMessage({ - type: 'set_yield_time', - value: normalized, - }); - return normalized; - }; const yieldControl = () => { parentPort.postMessage({ type: 'yield' }); }; @@ -296,8 +263,6 @@ function codeModeWorkerMain() { load, output_image: image, output_text: text, - set_max_output_tokens_per_exec_call: setMaxOutputTokensPerExecCall, - set_yield_time: setYieldTime, store, text, yield_control: yieldControl, @@ -306,27 +271,12 @@ function codeModeWorkerMain() { function createCodeModeModule(context, helpers) { return new SyntheticModule( - [ - 'image', - 'load', - 'output_text', - 'output_image', - 'set_max_output_tokens_per_exec_call', - 'set_yield_time', - 'store', - 'text', - 'yield_control', - ], + ['image', 'load', 'output_text', 'output_image', 'store', 'text', 'yield_control'], function initCodeModeModule() { this.setExport('image', helpers.image); this.setExport('load', helpers.load); this.setExport('output_text', helpers.output_text); this.setExport('output_image', helpers.output_image); - this.setExport( - 'set_max_output_tokens_per_exec_call', - helpers.set_max_output_tokens_per_exec_call - ); - this.setExport('set_yield_time', helpers.set_yield_time); this.setExport('store', helpers.store); this.setExport('text', helpers.text); this.setExport('yield_control', helpers.yield_control); @@ -340,8 +290,6 @@ function codeModeWorkerMain() { ALL_TOOLS: createAllToolsMetadata(enabledTools), image: helpers.image, load: helpers.load, - set_max_output_tokens_per_exec_call: helpers.set_max_output_tokens_per_exec_call, - set_yield_time: helpers.set_yield_time, store: helpers.store, text: helpers.text, tools: createGlobalToolsNamespace(callTool, enabledTools), @@ -475,7 +423,6 @@ function codeModeWorkerMain() { async function main() { const start = workerData ?? {}; const state = { - maxOutputTokensPerExecCall: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, storedValues: cloneJsonValue(start.stored_values ?? {}), }; const callTool = createToolCaller(); @@ -650,6 +597,10 @@ function sessionWorkerSource() { } function startSession(protocol, sessions, start) { + const maxOutputTokensPerExecCall = + start.max_output_tokens == null + ? DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL + : normalizeMaxOutputTokensPerExecCall(start.max_output_tokens); const session = { completed: false, content_items: [], @@ -657,7 +608,7 @@ function startSession(protocol, sessions, start) { id: start.cell_id, initial_yield_timer: null, initial_yield_triggered: false, - max_output_tokens_per_exec_call: DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL, + max_output_tokens_per_exec_call: maxOutputTokensPerExecCall, pending_result: null, poll_yield_timer: null, request_id: String(start.request_id), @@ -667,7 +618,11 @@ function startSession(protocol, sessions, start) { }), }; sessions.set(session.id, session); - scheduleInitialYield(protocol, session, session.default_yield_time_ms); + const initialYieldTime = + start.yield_time_ms == null + ? session.default_yield_time_ms + : normalizeYieldTime(start.yield_time_ms); + scheduleInitialYield(protocol, session, initialYieldTime); session.worker.on('message', (message) => { void handleWorkerMessage(protocol, sessions, session, message).catch((error) => { @@ -706,16 +661,6 @@ async function handleWorkerMessage(protocol, sessions, session, message) { return; } - if (message.type === 'set_yield_time') { - scheduleInitialYield(protocol, session, normalizeYieldTime(message.value ?? 0)); - return; - } - - if (message.type === 'set_max_output_tokens_per_exec_call') { - session.max_output_tokens_per_exec_call = normalizeMaxOutputTokensPerExecCall(message.value); - return; - } - if (message.type === 'yield') { void sendYielded(protocol, session); return; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 6f699b20dc..1ccb9fc6c6 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -105,842 +105,11 @@ where }) } -mod spawn { - use super::*; - use crate::agent::control::SpawnAgentOptions; - use crate::agent::role::DEFAULT_ROLE_NAME; - use crate::agent::role::apply_role_to_config; - - use crate::agent::exceeds_thread_spawn_depth_limit; - use crate::agent::next_thread_spawn_depth; - - pub(crate) struct Handler; - - #[async_trait] - impl ToolHandler for Handler { - type Output = SpawnAgentResult; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let role_name = args - .agent_type - .as_deref() - .map(str::trim) - .filter(|role| !role.is_empty()); - let input_items = parse_collab_input(args.message, args.items)?; - let prompt = input_preview(&input_items); - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } - session - .send_event( - &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - sender_thread_id: session.conversation_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - } - .into(), - ) - .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; - apply_role_to_config(&mut config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); - - let result = session - .services - .agent_control - .spawn_agent_with_options( - config, - input_items, - Some(thread_spawn_source( - session.conversation_id, - child_depth, - role_name, - )), - SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), - }, - ) - .await - .map_err(collab_spawn_error); - let (new_thread_id, status) = match &result { - Ok(thread_id) => ( - Some(*thread_id), - session.services.agent_control.get_status(*thread_id).await, - ), - Err(_) => (None, AgentStatus::NotFound), - }; - let (new_agent_nickname, new_agent_role) = match new_thread_id { - Some(thread_id) => session - .services - .agent_control - .get_agent_nickname_and_role(thread_id) - .await - .unwrap_or((None, None)), - None => (None, None), - }; - let nickname = new_agent_nickname.clone(); - session - .send_event( - &turn, - CollabAgentSpawnEndEvent { - call_id, - sender_thread_id: session.conversation_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - status, - } - .into(), - ) - .await; - let new_thread_id = result?; - let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.session_telemetry - .counter("codex.multi_agent.spawn", 1, &[("role", role_tag)]); - - Ok(SpawnAgentResult { - agent_id: new_thread_id.to_string(), - nickname, - }) - } - } - - #[derive(Debug, Deserialize)] - struct SpawnAgentArgs { - message: Option, - items: Option>, - agent_type: Option, - model: Option, - reasoning_effort: Option, - #[serde(default)] - fork_context: bool, - } - - #[derive(Debug, Serialize)] - pub(crate) struct SpawnAgentResult { - agent_id: String, - nickname: Option, - } - - impl ToolOutput for SpawnAgentResult { - fn log_preview(&self) -> String { - tool_output_json_text(self, "spawn_agent") - } - - fn success_for_logging(&self) -> bool { - true - } - - fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { - tool_output_response_item(call_id, payload, self, Some(true), "spawn_agent") - } - - fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { - tool_output_code_mode_result(self, "spawn_agent") - } - } -} - -mod send_input { - use super::*; - - pub(crate) struct Handler; - - #[async_trait] - impl ToolHandler for Handler { - type Output = SendInputResult; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SendInputArgs = parse_arguments(&arguments)?; - let receiver_thread_id = agent_id(&args.id)?; - let input_items = parse_collab_input(args.message, args.items)?; - let prompt = input_preview(&input_items); - let (receiver_agent_nickname, receiver_agent_role) = session - .services - .agent_control - .get_agent_nickname_and_role(receiver_thread_id) - .await - .unwrap_or((None, None)); - if args.interrupt { - session - .services - .agent_control - .interrupt_agent(receiver_thread_id) - .await - .map_err(|err| collab_agent_error(receiver_thread_id, err))?; - } - session - .send_event( - &turn, - CollabAgentInteractionBeginEvent { - call_id: call_id.clone(), - sender_thread_id: session.conversation_id, - receiver_thread_id, - prompt: prompt.clone(), - } - .into(), - ) - .await; - let result = session - .services - .agent_control - .send_input(receiver_thread_id, input_items) - .await - .map_err(|err| collab_agent_error(receiver_thread_id, err)); - let status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - session - .send_event( - &turn, - CollabAgentInteractionEndEvent { - call_id, - sender_thread_id: session.conversation_id, - receiver_thread_id, - receiver_agent_nickname, - receiver_agent_role, - prompt, - status, - } - .into(), - ) - .await; - let submission_id = result?; - - Ok(SendInputResult { submission_id }) - } - } - - #[derive(Debug, Deserialize)] - struct SendInputArgs { - id: String, - message: Option, - items: Option>, - #[serde(default)] - interrupt: bool, - } - - #[derive(Debug, Serialize)] - pub(crate) struct SendInputResult { - submission_id: String, - } - - impl ToolOutput for SendInputResult { - fn log_preview(&self) -> String { - tool_output_json_text(self, "send_input") - } - - fn success_for_logging(&self) -> bool { - true - } - - fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { - tool_output_response_item(call_id, payload, self, Some(true), "send_input") - } - - fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { - tool_output_code_mode_result(self, "send_input") - } - } -} - -mod resume_agent { - use super::*; - use crate::agent::next_thread_spawn_depth; - - pub(crate) struct Handler; - - #[async_trait] - impl ToolHandler for Handler { - type Output = ResumeAgentResult; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: ResumeAgentArgs = parse_arguments(&arguments)?; - let receiver_thread_id = agent_id(&args.id)?; - let (receiver_agent_nickname, receiver_agent_role) = session - .services - .agent_control - .get_agent_nickname_and_role(receiver_thread_id) - .await - .unwrap_or((None, None)); - let child_depth = next_thread_spawn_depth(&turn.session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } - - session - .send_event( - &turn, - CollabResumeBeginEvent { - call_id: call_id.clone(), - sender_thread_id: session.conversation_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent_nickname.clone(), - receiver_agent_role: receiver_agent_role.clone(), - } - .into(), - ) - .await; - - let mut status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - let error = if matches!(status, AgentStatus::NotFound) { - match try_resume_closed_agent(&session, &turn, receiver_thread_id, child_depth) - .await - { - Ok(resumed_status) => { - status = resumed_status; - None - } - Err(err) => { - status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - Some(err) - } - } - } else { - None - }; - - let (receiver_agent_nickname, receiver_agent_role) = session - .services - .agent_control - .get_agent_nickname_and_role(receiver_thread_id) - .await - .unwrap_or((receiver_agent_nickname, receiver_agent_role)); - session - .send_event( - &turn, - CollabResumeEndEvent { - call_id, - sender_thread_id: session.conversation_id, - receiver_thread_id, - receiver_agent_nickname, - receiver_agent_role, - status: status.clone(), - } - .into(), - ) - .await; - - if let Some(err) = error { - return Err(err); - } - turn.session_telemetry - .counter("codex.multi_agent.resume", 1, &[]); - - Ok(ResumeAgentResult { status }) - } - } - - #[derive(Debug, Deserialize)] - struct ResumeAgentArgs { - id: String, - } - - #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] - pub(crate) struct ResumeAgentResult { - pub(crate) status: AgentStatus, - } - - impl ToolOutput for ResumeAgentResult { - fn log_preview(&self) -> String { - tool_output_json_text(self, "resume_agent") - } - - fn success_for_logging(&self) -> bool { - true - } - - fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { - tool_output_response_item(call_id, payload, self, Some(true), "resume_agent") - } - - fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { - tool_output_code_mode_result(self, "resume_agent") - } - } - - async fn try_resume_closed_agent( - session: &Arc, - turn: &Arc, - receiver_thread_id: ThreadId, - child_depth: i32, - ) -> Result { - let config = build_agent_resume_config(turn.as_ref(), child_depth)?; - let resumed_thread_id = session - .services - .agent_control - .resume_agent_from_rollout( - config, - receiver_thread_id, - thread_spawn_source(session.conversation_id, child_depth, None), - ) - .await - .map_err(|err| collab_agent_error(receiver_thread_id, err))?; - - Ok(session - .services - .agent_control - .get_status(resumed_thread_id) - .await) - } -} - -pub(crate) mod wait { - use super::*; - use crate::agent::status::is_final; - use futures::FutureExt; - use futures::StreamExt; - use futures::stream::FuturesUnordered; - use std::collections::HashMap; - use std::time::Duration; - use tokio::sync::watch::Receiver; - use tokio::time::Instant; - - use tokio::time::timeout_at; - - pub(crate) struct Handler; - - #[async_trait] - impl ToolHandler for Handler { - type Output = WaitResult; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: WaitArgs = parse_arguments(&arguments)?; - if args.ids.is_empty() { - return Err(FunctionCallError::RespondToModel( - "ids must be non-empty".to_owned(), - )); - } - let receiver_thread_ids = args - .ids - .iter() - .map(|id| agent_id(id)) - .collect::, _>>()?; - let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); - for receiver_thread_id in &receiver_thread_ids { - let (agent_nickname, agent_role) = session - .services - .agent_control - .get_agent_nickname_and_role(*receiver_thread_id) - .await - .unwrap_or((None, None)); - receiver_agents.push(CollabAgentRef { - thread_id: *receiver_thread_id, - agent_nickname, - agent_role, - }); - } - - let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); - let timeout_ms = match timeout_ms { - ms if ms <= 0 => { - return Err(FunctionCallError::RespondToModel( - "timeout_ms must be greater than zero".to_owned(), - )); - } - ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), - }; - - session - .send_event( - &turn, - CollabWaitingBeginEvent { - sender_thread_id: session.conversation_id, - receiver_thread_ids: receiver_thread_ids.clone(), - receiver_agents: receiver_agents.clone(), - call_id: call_id.clone(), - } - .into(), - ) - .await; - - let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len()); - let mut initial_final_statuses = Vec::new(); - for id in &receiver_thread_ids { - match session.services.agent_control.subscribe_status(*id).await { - Ok(rx) => { - let status = rx.borrow().clone(); - if is_final(&status) { - initial_final_statuses.push((*id, status)); - } - status_rxs.push((*id, rx)); - } - Err(CodexErr::ThreadNotFound(_)) => { - initial_final_statuses.push((*id, AgentStatus::NotFound)); - } - Err(err) => { - let mut statuses = HashMap::with_capacity(1); - statuses.insert(*id, session.services.agent_control.get_status(*id).await); - session - .send_event( - &turn, - CollabWaitingEndEvent { - sender_thread_id: session.conversation_id, - call_id: call_id.clone(), - agent_statuses: build_wait_agent_statuses( - &statuses, - &receiver_agents, - ), - statuses, - } - .into(), - ) - .await; - return Err(collab_agent_error(*id, err)); - } - } - } - - let statuses = if !initial_final_statuses.is_empty() { - initial_final_statuses - } else { - let mut futures = FuturesUnordered::new(); - for (id, rx) in status_rxs.into_iter() { - let session = session.clone(); - futures.push(wait_for_final_status(session, id, rx)); - } - let mut results = Vec::new(); - let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); - loop { - match timeout_at(deadline, futures.next()).await { - Ok(Some(Some(result))) => { - results.push(result); - break; - } - Ok(Some(None)) => continue, - Ok(None) | Err(_) => break, - } - } - if !results.is_empty() { - loop { - match futures.next().now_or_never() { - Some(Some(Some(result))) => results.push(result), - Some(Some(None)) => continue, - Some(None) | None => break, - } - } - } - results - }; - - let statuses_map = statuses.clone().into_iter().collect::>(); - let agent_statuses = build_wait_agent_statuses(&statuses_map, &receiver_agents); - let result = WaitResult { - status: statuses_map.clone(), - timed_out: statuses.is_empty(), - }; - - session - .send_event( - &turn, - CollabWaitingEndEvent { - sender_thread_id: session.conversation_id, - call_id, - agent_statuses, - statuses: statuses_map, - } - .into(), - ) - .await; - - Ok(result) - } - } - - #[derive(Debug, Deserialize)] - struct WaitArgs { - ids: Vec, - timeout_ms: Option, - } - - #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] - pub(crate) struct WaitResult { - pub(crate) status: HashMap, - pub(crate) timed_out: bool, - } - - impl ToolOutput for WaitResult { - fn log_preview(&self) -> String { - tool_output_json_text(self, "wait") - } - - fn success_for_logging(&self) -> bool { - true - } - - fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { - tool_output_response_item(call_id, payload, self, None, "wait") - } - - fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { - tool_output_code_mode_result(self, "wait") - } - } - - async fn wait_for_final_status( - session: Arc, - thread_id: ThreadId, - mut status_rx: Receiver, - ) -> Option<(ThreadId, AgentStatus)> { - let mut status = status_rx.borrow().clone(); - if is_final(&status) { - return Some((thread_id, status)); - } - - loop { - if status_rx.changed().await.is_err() { - let latest = session.services.agent_control.get_status(thread_id).await; - return is_final(&latest).then_some((thread_id, latest)); - } - status = status_rx.borrow().clone(); - if is_final(&status) { - return Some((thread_id, status)); - } - } - } -} - -pub mod close_agent { - use super::*; - - pub(crate) struct Handler; - - #[async_trait] - impl ToolHandler for Handler { - type Output = CloseAgentResult; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = agent_id(&args.id)?; - let (receiver_agent_nickname, receiver_agent_role) = session - .services - .agent_control - .get_agent_nickname_and_role(agent_id) - .await - .unwrap_or((None, None)); - session - .send_event( - &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - } - .into(), - ) - .await; - let status = match session - .services - .agent_control - .subscribe_status(agent_id) - .await - { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent_nickname.clone(), - receiver_agent_role: receiver_agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); - } - }; - let result = if !matches!(status, AgentStatus::Shutdown) { - session - .services - .agent_control - .shutdown_agent(agent_id) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()) - } else { - Ok(()) - }; - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id, - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname, - receiver_agent_role, - status: status.clone(), - } - .into(), - ) - .await; - result?; - - Ok(CloseAgentResult { status }) - } - } - - #[derive(Debug, Deserialize, Serialize)] - pub(crate) struct CloseAgentResult { - pub(crate) status: AgentStatus, - } - - impl ToolOutput for CloseAgentResult { - fn log_preview(&self) -> String { - tool_output_json_text(self, "close_agent") - } - - fn success_for_logging(&self) -> bool { - true - } - - fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { - tool_output_response_item(call_id, payload, self, Some(true), "close_agent") - } - - fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { - tool_output_code_mode_result(self, "close_agent") - } - } -} +pub mod close_agent; +mod resume_agent; +mod send_input; +mod spawn; +pub(crate) mod wait; fn agent_id(id: &str) -> Result { ThreadId::from_string(id) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs new file mode 100644 index 0000000000..7351299940 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -0,0 +1,123 @@ +use super::*; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = CloseAgentResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: CloseAgentArgs = parse_arguments(&arguments)?; + let agent_id = agent_id(&args.id)?; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(agent_id) + .await + .unwrap_or((None, None)); + session + .send_event( + &turn, + CollabCloseBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + } + .into(), + ) + .await; + let status = match session + .services + .agent_control + .subscribe_status(agent_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session.services.agent_control.get_status(agent_id).await; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent_nickname.clone(), + receiver_agent_role: receiver_agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(agent_id, err)); + } + }; + let result = if !matches!(status, AgentStatus::Shutdown) { + session + .services + .agent_control + .shutdown_agent(agent_id) + .await + .map_err(|err| collab_agent_error(agent_id, err)) + .map(|_| ()) + } else { + Ok(()) + }; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id, + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname, + receiver_agent_role, + status: status.clone(), + } + .into(), + ) + .await; + result?; + + Ok(CloseAgentResult { status }) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct CloseAgentResult { + pub(crate) status: AgentStatus, +} + +impl ToolOutput for CloseAgentResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "close_agent") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "close_agent") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "close_agent") + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs new file mode 100644 index 0000000000..bc8b78a449 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -0,0 +1,163 @@ +use super::*; +use crate::agent::next_thread_spawn_depth; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = ResumeAgentResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: ResumeAgentArgs = parse_arguments(&arguments)?; + let receiver_thread_id = agent_id(&args.id)?; + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((None, None)); + let child_depth = next_thread_spawn_depth(&turn.session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); + } + + session + .send_event( + &turn, + CollabResumeBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent_nickname.clone(), + receiver_agent_role: receiver_agent_role.clone(), + } + .into(), + ) + .await; + + let mut status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + let error = if matches!(status, AgentStatus::NotFound) { + match try_resume_closed_agent(&session, &turn, receiver_thread_id, child_depth).await { + Ok(resumed_status) => { + status = resumed_status; + None + } + Err(err) => { + status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + Some(err) + } + } + } else { + None + }; + + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((receiver_agent_nickname, receiver_agent_role)); + session + .send_event( + &turn, + CollabResumeEndEvent { + call_id, + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, + status: status.clone(), + } + .into(), + ) + .await; + + if let Some(err) = error { + return Err(err); + } + turn.session_telemetry + .counter("codex.multi_agent.resume", 1, &[]); + + Ok(ResumeAgentResult { status }) + } +} + +#[derive(Debug, Deserialize)] +struct ResumeAgentArgs { + id: String, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct ResumeAgentResult { + pub(crate) status: AgentStatus, +} + +impl ToolOutput for ResumeAgentResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "resume_agent") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "resume_agent") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "resume_agent") + } +} + +async fn try_resume_closed_agent( + session: &Arc, + turn: &Arc, + receiver_thread_id: ThreadId, + child_depth: i32, +) -> Result { + let config = build_agent_resume_config(turn.as_ref(), child_depth)?; + let resumed_thread_id = session + .services + .agent_control + .resume_agent_from_rollout( + config, + receiver_thread_id, + thread_spawn_source(session.conversation_id, child_depth, None), + ) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; + + Ok(session + .services + .agent_control + .get_status(resumed_thread_id) + .await) +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs new file mode 100644 index 0000000000..0b6b06f21b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -0,0 +1,118 @@ +use super::*; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = SendInputResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SendInputArgs = parse_arguments(&arguments)?; + let receiver_thread_id = agent_id(&args.id)?; + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = input_preview(&input_items); + let (receiver_agent_nickname, receiver_agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(receiver_thread_id) + .await + .unwrap_or((None, None)); + if args.interrupt { + session + .services + .agent_control + .interrupt_agent(receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; + } + session + .send_event( + &turn, + CollabAgentInteractionBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + prompt: prompt.clone(), + } + .into(), + ) + .await; + let result = session + .services + .agent_control + .send_input(receiver_thread_id, input_items) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err)); + let status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + session + .send_event( + &turn, + CollabAgentInteractionEndEvent { + call_id, + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, + prompt, + status, + } + .into(), + ) + .await; + let submission_id = result?; + + Ok(SendInputResult { submission_id }) + } +} + +#[derive(Debug, Deserialize)] +struct SendInputArgs { + id: String, + message: Option, + items: Option>, + #[serde(default)] + interrupt: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct SendInputResult { + submission_id: String, +} + +impl ToolOutput for SendInputResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "send_input") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "send_input") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "send_input") + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs new file mode 100644 index 0000000000..1c1a0dae91 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -0,0 +1,173 @@ +use super::*; +use crate::agent::control::SpawnAgentOptions; +use crate::agent::role::DEFAULT_ROLE_NAME; +use crate::agent::role::apply_role_to_config; + +use crate::agent::exceeds_thread_spawn_depth_limit; +use crate::agent::next_thread_spawn_depth; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = SpawnAgentResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = input_preview(&input_items); + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); + } + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + } + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); + + let result = session + .services + .agent_control + .spawn_agent_with_options( + config, + input_items, + Some(thread_spawn_source( + session.conversation_id, + child_depth, + role_name, + )), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + }, + ) + .await + .map_err(collab_spawn_error); + let (new_thread_id, status) = match &result { + Ok(thread_id) => ( + Some(*thread_id), + session.services.agent_control.get_status(*thread_id).await, + ), + Err(_) => (None, AgentStatus::NotFound), + }; + let (new_agent_nickname, new_agent_role) = match new_thread_id { + Some(thread_id) => session + .services + .agent_control + .get_agent_nickname_and_role(thread_id) + .await + .unwrap_or((None, None)), + None => (None, None), + }; + let nickname = new_agent_nickname.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + status, + } + .into(), + ) + .await; + let new_thread_id = result?; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry + .counter("codex.multi_agent.spawn", 1, &[("role", role_tag)]); + + Ok(SpawnAgentResult { + agent_id: new_thread_id.to_string(), + nickname, + }) + } +} + +#[derive(Debug, Deserialize)] +struct SpawnAgentArgs { + message: Option, + items: Option>, + agent_type: Option, + model: Option, + reasoning_effort: Option, + #[serde(default)] + fork_context: bool, +} + +#[derive(Debug, Serialize)] +pub(crate) struct SpawnAgentResult { + agent_id: String, + nickname: Option, +} + +impl ToolOutput for SpawnAgentResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "spawn_agent") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "spawn_agent") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "spawn_agent") + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs new file mode 100644 index 0000000000..ef33ac9324 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents/wait.rs @@ -0,0 +1,228 @@ +use super::*; +use crate::agent::status::is_final; +use futures::FutureExt; +use futures::StreamExt; +use futures::stream::FuturesUnordered; +use std::collections::HashMap; +use std::time::Duration; +use tokio::sync::watch::Receiver; +use tokio::time::Instant; + +use tokio::time::timeout_at; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = WaitResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: WaitArgs = parse_arguments(&arguments)?; + if args.ids.is_empty() { + return Err(FunctionCallError::RespondToModel( + "ids must be non-empty".to_owned(), + )); + } + let receiver_thread_ids = args + .ids + .iter() + .map(|id| agent_id(id)) + .collect::, _>>()?; + let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len()); + for receiver_thread_id in &receiver_thread_ids { + let (agent_nickname, agent_role) = session + .services + .agent_control + .get_agent_nickname_and_role(*receiver_thread_id) + .await + .unwrap_or((None, None)); + receiver_agents.push(CollabAgentRef { + thread_id: *receiver_thread_id, + agent_nickname, + agent_role, + }); + } + + let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); + let timeout_ms = match timeout_ms { + ms if ms <= 0 => { + return Err(FunctionCallError::RespondToModel( + "timeout_ms must be greater than zero".to_owned(), + )); + } + ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), + }; + + session + .send_event( + &turn, + CollabWaitingBeginEvent { + sender_thread_id: session.conversation_id, + receiver_thread_ids: receiver_thread_ids.clone(), + receiver_agents: receiver_agents.clone(), + call_id: call_id.clone(), + } + .into(), + ) + .await; + + let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len()); + let mut initial_final_statuses = Vec::new(); + for id in &receiver_thread_ids { + match session.services.agent_control.subscribe_status(*id).await { + Ok(rx) => { + let status = rx.borrow().clone(); + if is_final(&status) { + initial_final_statuses.push((*id, status)); + } + status_rxs.push((*id, rx)); + } + Err(CodexErr::ThreadNotFound(_)) => { + initial_final_statuses.push((*id, AgentStatus::NotFound)); + } + Err(err) => { + let mut statuses = HashMap::with_capacity(1); + statuses.insert(*id, session.services.agent_control.get_status(*id).await); + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id: call_id.clone(), + agent_statuses: build_wait_agent_statuses( + &statuses, + &receiver_agents, + ), + statuses, + } + .into(), + ) + .await; + return Err(collab_agent_error(*id, err)); + } + } + } + + let statuses = if !initial_final_statuses.is_empty() { + initial_final_statuses + } else { + let mut futures = FuturesUnordered::new(); + for (id, rx) in status_rxs.into_iter() { + let session = session.clone(); + futures.push(wait_for_final_status(session, id, rx)); + } + let mut results = Vec::new(); + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); + loop { + match timeout_at(deadline, futures.next()).await { + Ok(Some(Some(result))) => { + results.push(result); + break; + } + Ok(Some(None)) => continue, + Ok(None) | Err(_) => break, + } + } + if !results.is_empty() { + loop { + match futures.next().now_or_never() { + Some(Some(Some(result))) => results.push(result), + Some(Some(None)) => continue, + Some(None) | None => break, + } + } + } + results + }; + + let statuses_map = statuses.clone().into_iter().collect::>(); + let agent_statuses = build_wait_agent_statuses(&statuses_map, &receiver_agents); + let result = WaitResult { + status: statuses_map.clone(), + timed_out: statuses.is_empty(), + }; + + session + .send_event( + &turn, + CollabWaitingEndEvent { + sender_thread_id: session.conversation_id, + call_id, + agent_statuses, + statuses: statuses_map, + } + .into(), + ) + .await; + + Ok(result) + } +} + +#[derive(Debug, Deserialize)] +struct WaitArgs { + ids: Vec, + timeout_ms: Option, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct WaitResult { + pub(crate) status: HashMap, + pub(crate) timed_out: bool, +} + +impl ToolOutput for WaitResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "wait") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, None, "wait") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "wait") + } +} + +async fn wait_for_final_status( + session: Arc, + thread_id: ThreadId, + mut status_rx: Receiver, +) -> Option<(ThreadId, AgentStatus)> { + let mut status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } + + loop { + if status_rx.changed().await.is_err() { + let latest = session.services.agent_control.get_status(thread_id).await; + return is_final(&latest).then_some((thread_id, latest)); + } + status = status_rx.borrow().clone(); + if is_final(&status) { + return Some((thread_id, status)); + } + } +} diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index f14a8bd71f..2ba41ed9aa 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -11,7 +11,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; pub(crate) fn request_permissions_tool_description() -> String { - "Request additional permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." + "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." .to_string() } @@ -45,7 +45,8 @@ impl ToolHandler for RequestPermissionsHandler { let mut args: RequestPermissionsArgs = parse_arguments_with_base_path(&arguments, turn.cwd.as_path())?; - args.permissions = normalize_additional_permissions(args.permissions) + args.permissions = normalize_additional_permissions(args.permissions.into()) + .map(codex_protocol::request_permissions::RequestPermissionProfile::from) .map_err(FunctionCallError::RespondToModel)?; if args.permissions.is_empty() { return Err(FunctionCallError::RespondToModel( diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index a8020c0fbc..94d419798b 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -92,6 +92,7 @@ fn test_skill_metadata(permission_profile: Option) -> SkillMe dependencies: None, policy: None, permission_profile, + managed_network_override: None, path_to_skills_md: PathBuf::from("/tmp/skill/SKILL.md"), scope: SkillScope::User, } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 28f522847d..a931940fe7 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -494,44 +494,21 @@ fn create_file_system_permissions_schema() -> JsonSchema { } } -fn create_macos_permissions_schema() -> JsonSchema { - JsonSchema::Object { - properties: BTreeMap::from([ - ( - "preferences".to_string(), - JsonSchema::String { - description: Some( - "macOS preferences access. Supported values: `none`, `read_only`, or `read_write`." - .to_string(), - ), - }, - ), - ( - "automations".to_string(), - JsonSchema::Array { - items: Box::new(JsonSchema::String { description: None }), - description: Some("macOS automation access as app bundle identifiers.".to_string()), - }, - ), - ( - "accessibility".to_string(), - JsonSchema::Boolean { - description: Some("Whether to request macOS accessibility access.".to_string()), - }, - ), - ( - "calendar".to_string(), - JsonSchema::Boolean { - description: Some("Whether to request macOS calendar access.".to_string()), - }, - ), - ]), - required: None, - additional_properties: Some(false.into()), - } -} - -fn create_permissions_schema() -> JsonSchema { +fn create_additional_permissions_schema() -> JsonSchema { + JsonSchema::Object { + properties: BTreeMap::from([ + ("network".to_string(), create_network_permissions_schema()), + ( + "file_system".to_string(), + create_file_system_permissions_schema(), + ), + ]), + required: None, + additional_properties: Some(false.into()), + } +} + +fn create_request_permissions_schema() -> JsonSchema { JsonSchema::Object { properties: BTreeMap::from([ ("network".to_string(), create_network_permissions_schema()), @@ -539,7 +516,6 @@ fn create_permissions_schema() -> JsonSchema { "file_system".to_string(), create_file_system_permissions_schema(), ), - ("macos".to_string(), create_macos_permissions_schema()), ]), required: None, additional_properties: Some(false.into()), @@ -555,7 +531,7 @@ fn create_approval_parameters( JsonSchema::String { description: Some( if exec_permission_approvals_enabled { - "Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem, network, or macOS permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." + "Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." } else { "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." } @@ -592,7 +568,7 @@ fn create_approval_parameters( if exec_permission_approvals_enabled { properties.insert( "additional_permissions".to_string(), - create_permissions_schema(), + create_additional_permissions_schema(), ); } @@ -1455,7 +1431,10 @@ fn create_request_permissions_tool() -> ToolSpec { ), }, ); - properties.insert("permissions".to_string(), create_permissions_schema()); + properties.insert( + "permissions".to_string(), + create_request_permissions_schema(), + ); ToolSpec::Function(ResponsesApiTool { name: "request_permissions".to_string(), @@ -2018,8 +1997,13 @@ fn create_js_repl_reset_tool() -> ToolSpec { fn create_code_mode_tool(enabled_tool_names: &[String]) -> ToolSpec { const CODE_MODE_FREEFORM_GRAMMAR: &str = r#" -start: source -source: /[\s\S]+/ +start: pragma_source | plain_source +pragma_source: PRAGMA_LINE NEWLINE SOURCE +plain_source: SOURCE + +PRAGMA_LINE: /[ \t]*\/\/ @exec:[^\r\n]*/ +NEWLINE: /\r?\n/ +SOURCE: /[\s\S]+/ "#; ToolSpec::Freeform(FreeformTool { diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 30147ffc90..e56abbd296 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2205,7 +2205,7 @@ fn shell_tool_with_request_permission_includes_additional_permissions() { panic!("expected sandbox_permissions description"); }; assert!(description.contains("with_additional_permissions")); - assert!(description.contains("macOS permissions")); + assert!(description.contains("filesystem or network permissions")); let Some(JsonSchema::Object { properties: additional_properties, @@ -2216,7 +2216,7 @@ fn shell_tool_with_request_permission_includes_additional_permissions() { }; assert!(additional_properties.contains_key("network")); assert!(additional_properties.contains_key("file_system")); - assert!(additional_properties.contains_key("macos")); + assert!(!additional_properties.contains_key("macos")); } #[test] @@ -2240,7 +2240,7 @@ fn request_permissions_tool_includes_full_permission_schema() { assert_eq!(additional_properties, &Some(false.into())); assert!(permission_properties.contains_key("network")); assert!(permission_properties.contains_key("file_system")); - assert!(permission_properties.contains_key("macos")); + assert!(!permission_properties.contains_key("macos")); let Some(JsonSchema::Object { properties: network_properties, @@ -2264,20 +2264,6 @@ fn request_permissions_tool_includes_full_permission_schema() { assert_eq!(additional_properties, &Some(false.into())); assert!(file_system_properties.contains_key("read")); assert!(file_system_properties.contains_key("write")); - - let Some(JsonSchema::Object { - properties: macos_properties, - additional_properties, - .. - }) = permission_properties.get("macos") - else { - panic!("expected macos object"); - }; - assert_eq!(additional_properties, &Some(false.into())); - assert!(macos_properties.contains_key("preferences")); - assert!(macos_properties.contains_key("automations")); - assert!(macos_properties.contains_key("accessibility")); - assert!(macos_properties.contains_key("calendar")); } #[test] diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index dc4f1b0902..7a9dba038e 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -123,7 +123,7 @@ impl ActionKind { let (path, _) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); let command = format!("printf {content:?} > {path:?} && cat {path:?}"); - let event = shell_event(call_id, &command, 1_000, sandbox_permissions)?; + let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?; Ok((event, Some(command))) } ActionKind::FetchUrl { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index c6fc3dea96..5b2fe6376b 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -28,11 +28,13 @@ use std::time::Instant; use wiremock::MockServer; fn custom_tool_output_items(req: &ResponsesRequest, call_id: &str) -> Vec { - req.custom_tool_call_output(call_id) - .get("output") - .and_then(Value::as_array) - .expect("custom tool output should be serialized as content items") - .clone() + match req.custom_tool_call_output(call_id).get("output") { + Some(Value::Array(items)) => items.clone(), + Some(Value::String(text)) => { + vec![serde_json::json!({ "type": "input_text", "text": text })] + } + _ => panic!("custom tool output should be serialized as text or content items"), + } } fn function_tool_output_items(req: &ResponsesRequest, call_id: &str) -> Vec { @@ -332,9 +334,7 @@ async fn code_mode_can_truncate_final_result_with_configured_budget() -> Result< let (_test, second_mock) = run_code_mode_turn( &server, "use exec to truncate the final result", - r#" -set_max_output_tokens_per_exec_call(6); - + r#"// @exec: {"max_output_tokens": 6} text(JSON.stringify(await tools.exec_command({ cmd: "printf 'token one token two token three token four token five token six token seven'", max_output_tokens: 100 @@ -427,7 +427,7 @@ async fn code_mode_can_yield_and_resume_with_exec_wait() -> Result<()> { let code = format!( r#" text("phase 1"); -set_yield_time(10); +yield_control(); {phase_2_wait} text("phase 2"); {phase_3_wait} @@ -566,9 +566,8 @@ async fn code_mode_yield_timeout_works_for_busy_loop() -> Result<()> { }); let test = builder.build(&server).await?; - let code = r#" + let code = r#"// @exec: {"yield_time_ms": 100} text("phase 1"); -set_yield_time(10); while (true) {} "#; @@ -668,7 +667,7 @@ async fn code_mode_can_run_multiple_yielded_sessions() -> Result<()> { let session_a_code = format!( r#" text("session a start"); -set_yield_time(10); +yield_control(); {session_a_wait} text("session a done"); "# @@ -676,7 +675,7 @@ text("session a done"); let session_b_code = format!( r#" text("session b start"); -set_yield_time(10); +yield_control(); {session_b_wait} text("session b done"); "# @@ -834,7 +833,7 @@ async fn code_mode_exec_wait_can_terminate_and_continue() -> Result<()> { let code = format!( r#" text("phase 1"); -set_yield_time(10); +yield_control(); {termination_wait} text("phase 2"); "# @@ -1028,7 +1027,7 @@ async fn code_mode_exec_wait_terminate_returns_completed_session_if_it_finished_ let session_a_code = format!( r#" text("session a start"); -set_yield_time(10); +yield_control(); {session_a_wait} text("session a done"); await tools.exec_command({{ cmd: {session_a_done_command:?} }}); @@ -1037,7 +1036,7 @@ await tools.exec_command({{ cmd: {session_a_done_command:?} }}); let session_b_code = format!( r#" text("session b start"); -set_yield_time(10); +yield_control(); {session_b_wait} text("session b done"); "# @@ -1308,10 +1307,9 @@ async fn code_mode_exec_wait_uses_its_own_max_tokens_budget() -> Result<()> { let completion_wait = wait_for_file_source(&completion_gate)?; let code = format!( - r#" + r#"// @exec: {{"max_output_tokens": 100}} text("phase 1"); -set_max_output_tokens_per_exec_call(100); -set_yield_time(10); +yield_control(); {completion_wait} text("token one token two token three token four token five token six token seven"); "# @@ -1630,6 +1628,42 @@ contentLength=0" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exposes_namespaced_mcp_tools_on_global_tools_object() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let code = r#" +text(JSON.stringify({ + hasExecCommand: typeof tools.exec_command === "function", + hasNamespacedEcho: typeof tools.mcp__rmcp__echo === "function", +})); +"#; + + let (_test, second_mock) = + run_code_mode_turn_with_rmcp(&server, "use exec to inspect the global tools object", code) + .await?; + + let req = second_mock.single_request(); + let (output, success) = custom_tool_output_body_and_success(&req, "call-1"); + assert_ne!( + success, + Some(false), + "exec global tools inspection failed unexpectedly: {output}" + ); + + let parsed: Value = serde_json::from_str(&output)?; + assert_eq!( + parsed, + serde_json::json!({ + "hasExecCommand": !cfg!(windows), + "hasNamespacedEcho": true, + }) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_exposes_normalized_illegal_mcp_tool_names() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1736,6 +1770,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "WeakSet", "WebAssembly", "__codexContentItems", + "add_content", "console", "decodeURI", "decodeURIComponent", @@ -1750,8 +1785,6 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "load", "parseFloat", "parseInt", - "set_max_output_tokens_per_exec_call", - "set_yield_time", "store", "text", "tools", @@ -1918,6 +1951,7 @@ structuredContent=null" Ok(()) } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_can_store_and_load_values_across_turns() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 802e4d68d4..4f69c45a15 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -107,6 +107,25 @@ async fn build_plugin_test_codex( .codex) } +async fn build_analytics_plugin_test_codex( + server: &MockServer, + codex_home: Arc, +) -> Result> { + let chatgpt_base_url = server.uri(); + let mut builder = test_codex() + .with_home(codex_home) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model("gpt-5") + .with_config(move |config| { + config.chatgpt_base_url = chatgpt_base_url; + }); + Ok(builder + .build(server) + .await + .expect("create new conversation") + .codex) +} + async fn build_apps_enabled_plugin_test_codex( server: &MockServer, codex_home: Arc, @@ -299,6 +318,70 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let _resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + + let codex_home = Arc::new(TempDir::new()?); + write_plugin_skill_plugin(codex_home.as_ref()); + let codex = build_analytics_plugin_test_codex(&server, codex_home).await?; + + codex + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Mention { + name: "sample".into(), + path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let deadline = Instant::now() + Duration::from_secs(10); + let analytics_request = loop { + let requests = server.received_requests().await.unwrap_or_default(); + if let Some(request) = requests + .into_iter() + .find(|request| request.url.path() == "/codex/analytics-events/events") + { + break request; + } + if Instant::now() >= deadline { + panic!("timed out waiting for plugin analytics request"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + }; + + let payload: serde_json::Value = + serde_json::from_slice(&analytics_request.body).expect("analytics payload"); + let event = &payload["events"][0]; + assert_eq!(event["event_type"], "codex_plugin_used"); + assert_eq!(event["event_params"]["plugin_id"], "sample@test"); + assert_eq!(event["event_params"]["plugin_name"], "sample"); + assert_eq!(event["event_params"]["marketplace_name"], "test"); + assert_eq!(event["event_params"]["has_skills"], true); + assert_eq!(event["event_params"]["mcp_server_count"], 0); + assert_eq!( + event["event_params"]["connector_ids"], + serde_json::json!([]) + ); + assert_eq!( + event["event_params"]["product_client_id"], + serde_json::json!(codex_core::default_client::originator().value) + ); + assert_eq!(event["event_params"]["model_slug"], "gpt-5"); + assert!(event["event_params"]["thread_id"].as_str().is_some()); + assert!(event["event_params"]["turn_id"].as_str().is_some()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn plugin_mcp_tools_are_listed() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2e5c6c2648..9373a938ac 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -14,6 +14,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -85,10 +86,10 @@ fn parse_result(item: &Value) -> CommandResult { } } -fn shell_event_with_request_permissions( +fn shell_event_with_request_permissions( call_id: &str, command: &str, - additional_permissions: &PermissionProfile, + additional_permissions: &S, ) -> Result { let args = json!({ "command": command, @@ -103,7 +104,7 @@ fn shell_event_with_request_permissions( fn request_permissions_tool_event( call_id: &str, reason: &str, - permissions: &PermissionProfile, + permissions: &RequestPermissionProfile, ) -> Result { let args = json!({ "reason": reason, @@ -131,10 +132,10 @@ fn exec_command_event(call_id: &str, command: &str) -> Result { Ok(ev_function_call(call_id, "exec_command", &args_str)) } -fn exec_command_event_with_request_permissions( +fn exec_command_event_with_request_permissions( call_id: &str, command: &str, - additional_permissions: &PermissionProfile, + additional_permissions: &S, ) -> Result { let args = json!({ "cmd": command, @@ -259,7 +260,7 @@ async fn wait_for_exec_approval_or_completion( async fn expect_request_permissions_event( test: &TestCodex, expected_call_id: &str, -) -> PermissionProfile { +) -> RequestPermissionProfile { let event = wait_for_event(&test.codex, |event| { matches!( event, @@ -288,23 +289,23 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy { } } -fn requested_directory_write_permissions(path: &Path) -> PermissionProfile { - PermissionProfile { +fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfile { + RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![absolute_path(path)]), }), - ..Default::default() + ..RequestPermissionProfile::default() } } -fn normalized_directory_write_permissions(path: &Path) -> Result { - Ok(PermissionProfile { +fn normalized_directory_write_permissions(path: &Path) -> Result { + Ok(RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), }), - ..Default::default() + ..RequestPermissionProfile::default() }) } @@ -477,7 +478,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio assert_eq!( result, RequestPermissionsResponse { - permissions: PermissionProfile::default(), + permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, } ); @@ -820,21 +821,21 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> "printf {:?} > {:?} && cat {:?}", "outside-cwd-ok", outside_write, outside_write ); - let requested_permissions = PermissionProfile { + let requested_permissions = RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![absolute_path(outside_dir.path())]), }), - ..Default::default() + ..RequestPermissionProfile::default() }; - let normalized_requested_permissions = PermissionProfile { + let normalized_requested_permissions = RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![AbsolutePathBuf::try_from( outside_dir.path().canonicalize()?, )?]), }), - ..Default::default() + ..RequestPermissionProfile::default() }; let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; @@ -861,7 +862,7 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> let approval = expect_exec_approval(&test, &command).await; assert_eq!( approval.additional_permissions, - Some(normalized_requested_permissions) + Some(normalized_requested_permissions.into()) ); test.codex .submit(Op::ExecApproval { @@ -1024,14 +1025,14 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul "printf {:?} > {:?} && cat {:?}", "sticky-grant-ok", outside_write, outside_write ); - let requested_permissions = PermissionProfile { + let requested_permissions = RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![absolute_path(outside_dir.path())]), }), ..Default::default() }; - let normalized_requested_permissions = PermissionProfile { + let normalized_requested_permissions = RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![AbsolutePathBuf::try_from( @@ -1092,7 +1093,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul if let Some(approval) = wait_for_exec_approval_or_completion(&test).await { assert_eq!( approval.additional_permissions, - Some(normalized_requested_permissions.clone()) + Some(normalized_requested_permissions.clone().into()) ); test.codex .submit(Op::ExecApproval { @@ -1488,7 +1489,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() "partial-grant-ok", second_write, second_write ); - let requested_permissions = PermissionProfile { + let requested_permissions = RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![ @@ -1496,9 +1497,9 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() absolute_path(second_dir.path()), ]), }), - ..Default::default() + ..RequestPermissionProfile::default() }; - let normalized_requested_permissions = PermissionProfile { + let normalized_requested_permissions = RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![ @@ -1506,7 +1507,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() AbsolutePathBuf::try_from(second_dir.path().canonicalize()?)?, ]), }), - ..Default::default() + ..RequestPermissionProfile::default() }; let granted_permissions = normalized_directory_write_permissions(first_dir.path())?; let second_dir_permissions = requested_directory_write_permissions(second_dir.path()); diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 9aff62d94e..8a092f69f0 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -5,13 +5,13 @@ use anyhow::Result; use codex_core::config::Constrained; use codex_core::features::Feature; use codex_protocol::models::FileSystemPermissions; -use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -42,7 +42,7 @@ fn absolute_path(path: &Path) -> AbsolutePathBuf { fn request_permissions_tool_event( call_id: &str, reason: &str, - permissions: &PermissionProfile, + permissions: &RequestPermissionProfile, ) -> Result { let args = json!({ "reason": reason, @@ -79,23 +79,23 @@ fn workspace_write_excluding_tmp() -> SandboxPolicy { } } -fn requested_directory_write_permissions(path: &Path) -> PermissionProfile { - PermissionProfile { +fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfile { + RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![absolute_path(path)]), }), - ..Default::default() + ..RequestPermissionProfile::default() } } -fn normalized_directory_write_permissions(path: &Path) -> Result { - Ok(PermissionProfile { +fn normalized_directory_write_permissions(path: &Path) -> Result { + Ok(RequestPermissionProfile { file_system: Some(FileSystemPermissions { read: Some(vec![]), write: Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), }), - ..Default::default() + ..RequestPermissionProfile::default() }) } @@ -160,7 +160,7 @@ async fn submit_turn( async fn expect_request_permissions_event( test: &TestCodex, expected_call_id: &str, -) -> PermissionProfile { +) -> RequestPermissionProfile { let event = wait_for_event(&test.codex, |event| { matches!( event, diff --git a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md index 68a342bf38..16d4101cca 100644 --- a/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md +++ b/codex-rs/protocol/src/prompts/permissions/approval_policy/on_request_rule_request_permission.md @@ -11,10 +11,8 @@ When you need extra sandboxed permissions for one command, use: - `network.enabled`: set to `true` to enable network access - `file_system.read`: list of paths that need read access - `file_system.write`: list of paths that need write access - - `macos.preferences`: `readonly` or `readwrite` - - `macos.automations`: list of bundle IDs that need Apple Events access - - `macos.accessibility`: set to `true` to allow accessibility APIs - - `macos.calendar`: set to `true` to allow Calendar access + +When using the `request_permissions` tool directly, only request `network` and `file_system` permissions. This keeps execution inside the current sandbox policy, while adding only the requested permissions for that command, unless an exec-policy allow rule applies and authorizes running the command outside the sandbox. diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index fc66e9b3db..db5396c5e4 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -1,3 +1,5 @@ +use crate::models::FileSystemPermissions; +use crate::models::NetworkPermissions; use crate::models::PermissionProfile; use schemars::JsonSchema; use serde::Deserialize; @@ -12,16 +14,48 @@ pub enum PermissionGrantScope { Session, } +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct RequestPermissionProfile { + pub network: Option, + pub file_system: Option, +} + +impl RequestPermissionProfile { + pub fn is_empty(&self) -> bool { + self.network.is_none() && self.file_system.is_none() + } +} + +impl From for PermissionProfile { + fn from(value: RequestPermissionProfile) -> Self { + Self { + network: value.network, + file_system: value.file_system, + macos: None, + } + } +} + +impl From for RequestPermissionProfile { + fn from(value: PermissionProfile) -> Self { + Self { + network: value.network, + file_system: value.file_system, + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestPermissionsArgs { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, - pub permissions: PermissionProfile, + pub permissions: RequestPermissionProfile, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestPermissionsResponse { - pub permissions: PermissionProfile, + pub permissions: RequestPermissionProfile, #[serde(default)] pub scope: PermissionGrantScope, } @@ -36,5 +70,5 @@ pub struct RequestPermissionsEvent { pub turn_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, - pub permissions: PermissionProfile, + pub permissions: RequestPermissionProfile, } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 87b2dc795c..a244965d5b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3306,7 +3306,7 @@ impl App { lines.push(Line::from("")); } if let Some(rule_line) = - crate::bottom_pane::format_additional_permissions_rule(&permissions) + crate::bottom_pane::format_requested_permissions_rule(&permissions) { lines.push(Line::from(vec![ "Permission rule: ".into(), diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 2420fb3235..a0ddbc350a 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -30,6 +30,7 @@ use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -60,7 +61,7 @@ pub(crate) enum ApprovalRequest { thread_label: Option, call_id: String, reason: Option, - permissions: PermissionProfile, + permissions: RequestPermissionProfile, }, ApplyPatch { thread_id: ThreadId, @@ -272,7 +273,7 @@ impl ApprovalOverlay { fn handle_permissions_decision( &self, call_id: &str, - permissions: &PermissionProfile, + permissions: &RequestPermissionProfile, decision: ReviewDecision, ) { let Some(request) = self.current_request.as_ref() else { @@ -567,7 +568,7 @@ fn build_header(request: &ApprovalRequest) -> Box { header.push(Line::from(vec!["Reason: ".into(), reason.clone().italic()])); header.push(Line::from("")); } - if let Some(rule_line) = format_additional_permissions_rule(permissions) { + if let Some(rule_line) = format_requested_permissions_rule(permissions) { header.push(Line::from(vec![ "Permission rule: ".into(), rule_line.cyan(), @@ -821,6 +822,12 @@ pub(crate) fn format_additional_permissions_rule( } } +pub(crate) fn format_requested_permissions_rule( + permissions: &RequestPermissionProfile, +) -> Option { + format_additional_permissions_rule(&permissions.clone().into()) +} + fn patch_options() -> Vec { vec![ ApprovalOption { @@ -957,7 +964,7 @@ mod tests { thread_label: None, call_id: "test".to_string(), reason: Some("need workspace access".to_string()), - permissions: PermissionProfile { + permissions: RequestPermissionProfile { network: Some(NetworkPermissions { enabled: Some(true), }), @@ -965,7 +972,6 @@ mod tests { read: Some(vec![absolute_path("/tmp/readme.txt")]), write: Some(vec![absolute_path("/tmp/out.txt")]), }), - ..Default::default() }, } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f314c5e471..1e16eccf6f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -5366,6 +5366,7 @@ mod tests { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"), scope: codex_protocol::protocol::SkillScope::Repo, }])); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2d66002418..d15b03021e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -53,7 +53,7 @@ pub(crate) use app_link_view::AppLinkView; pub(crate) use app_link_view::AppLinkViewParams; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; -pub(crate) use approval_overlay::format_additional_permissions_rule; +pub(crate) use approval_overlay::format_requested_permissions_rule; pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest; pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay; pub(crate) use request_user_input::RequestUserInputOverlay; @@ -1675,6 +1675,7 @@ mod tests { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: PathBuf::from("test-skill"), scope: SkillScope::User, }]), diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index 6228efe4eb..a2a5e73e6c 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -192,6 +192,7 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { }), policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: skill.path.clone(), scope: skill.scope, } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e7a0ac1442..fec6278855 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -998,6 +998,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: repo_skill_path, scope: SkillScope::Repo, }, @@ -1009,6 +1010,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { dependencies: None, policy: None, permission_profile: None, + managed_network_override: None, path_to_skills_md: user_skill_path.clone(), scope: SkillScope::User, },