Merge ea364629e2 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-03-13 09:14:22 -07:00
committed by GitHub
75 changed files with 4146 additions and 1416 deletions
+69 -133
View File
@@ -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<ClientRequestResult>,
conversation: Arc<CodexThread>,
@@ -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<ClientRequestResult, oneshot::error::RecvError>,
) -> Option<CoreRequestPermissionsResponse> {
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,
}
);
@@ -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::<CloudRequirementsLoadError>() {
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 {
+49 -2
View File
@@ -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<RwLock<CloudRequirementsLoader>>,
user_config_reloader: Arc<dyn UserConfigReloader>,
analytics_events_client: AnalyticsEventsClient,
}
impl ConfigApi {
@@ -65,6 +70,7 @@ impl ConfigApi {
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
user_config_reloader: Arc<dyn UserConfigReloader>,
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<ConfigWriteResponse, JSONRPCErrorError> {
self.config_service()
let pending_changes =
collect_plugin_enabled_candidates([(&params.key_path, &params.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<ConfigWriteResponse, JSONRPCErrorError> {
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<String, bool>) {
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<String>) ->
#[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
@@ -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());
@@ -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<MockServer> {
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)
}
+2
View File
@@ -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;
@@ -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::<AppSummary>::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,
@@ -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,
@@ -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("<html>nope</html>"),
)
.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
"#
),
)
}
@@ -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("<html>nope</html>"),
)
.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,
@@ -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("<html>nope</html>"),
)
.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,
+39 -12
View File
@@ -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<u16>,
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
@@ -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<WsCommand>,
pump_task: tokio::task::JoinHandle<()>,
@@ -196,12 +219,14 @@ pub struct RealtimeWebsocketConnection {
pub struct RealtimeWebsocketWriter {
stream: Arc<WsStream>,
is_closed: Arc<AtomicBool>,
event_parser: RealtimeEventParser,
}
#[derive(Clone)]
pub struct RealtimeWebsocketEvents {
rx_message: Arc<Mutex<mpsc::UnboundedReceiver<Result<Message, WsError>>>>,
active_transcript: Arc<Mutex<ActiveTranscriptState>>,
event_parser: RealtimeEventParser,
is_closed: Arc<AtomicBool>,
}
@@ -248,6 +273,7 @@ impl RealtimeWebsocketConnection {
fn new(
stream: WsStream,
rx_message: mpsc::UnboundedReceiver<Result<Message, WsError>>,
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("<none>"),
"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<String, String>>,
model: Option<&str>,
event_parser: RealtimeEventParser,
_session_mode: RealtimeSessionMode,
) -> Result<Url, ApiError> {
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(),
@@ -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;
@@ -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<String>,
pub session_id: Option<String>,
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<String>,
pub(super) audio: SessionAudio,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) tools: Option<Vec<SessionFunctionTool>>,
}
#[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<SessionAudioOutput>,
}
#[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<ConversationItemContent>,
}
#[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<RealtimeEvent> {
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<RealtimeEvent> {
match event_parser {
RealtimeEventParser::V1 => parse_realtime_event_v1(payload),
RealtimeEventParser::RealtimeV2 => parse_realtime_event_v2(payload),
}
}
@@ -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<RealtimeEvent> {
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<RealtimeTranscriptDelta> {
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<RealtimeEvent> {
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)
}
@@ -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<RealtimeEvent> {
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
}
}
}
@@ -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<RealtimeEvent> {
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<RealtimeEvent> {
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<RealtimeEvent> {
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<String, Value>) -> Option<RealtimeEvent> {
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::<Value>(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()
}
+2
View File
@@ -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;
@@ -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");
}
+26 -1
View File
@@ -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<u16>,
}
impl CloudRequirementsLoadError {
pub fn new(message: impl Into<String>) -> Self {
pub fn new(
code: CloudRequirementsLoadErrorCode,
status_code: Option<u16>,
message: impl Into<String>,
) -> Self {
Self {
code,
message: message.into(),
status_code,
}
}
pub fn code(&self) -> CloudRequirementsLoadErrorCode {
self.code
}
pub fn status_code(&self) -> Option<u16> {
self.status_code
}
}
#[derive(Clone)]
+1
View File
@@ -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;
+21
View File
@@ -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"
},
+277 -4
View File
@@ -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<TrackEventsJob>,
app_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
plugin_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
}
pub(crate) struct AnalyticsEventsClient {
#[derive(Clone)]
pub struct AnalyticsEventsClient {
queue: AnalyticsEventsQueue,
config: Arc<Config>,
}
@@ -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<Config>, auth_manager: Arc<AuthManager>) -> Self {
pub fn new(config: Arc<Config>, auth_manager: Arc<AuthManager>) -> 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<Config>,
tracking: TrackEventsContext,
plugin: PluginTelemetryMetadata,
}
struct TrackPluginManagementJob {
config: Arc<Config>,
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<String>,
plugin_name: Option<String>,
marketplace_name: Option<String>,
has_skills: Option<bool>,
mcp_server_count: Option<usize>,
connector_ids: Option<Vec<String>>,
product_client_id: Option<String>,
}
#[derive(Serialize)]
struct CodexPluginUsedMetadata {
#[serde(flatten)]
plugin: CodexPluginMetadata,
thread_id: Option<String>,
turn_id: Option<String>,
model_slug: Option<String>,
}
#[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<Config>,
@@ -302,6 +446,48 @@ pub(crate) fn track_app_used(
queue.try_send(job);
}
pub(crate) fn track_plugin_used(
queue: &AnalyticsEventsQueue,
config: Arc<Config>,
tracking: Option<TrackEventsContext>,
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<Config>,
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<Config>,
+112
View File
@@ -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()),
],
}),
}
}
+14 -4
View File
@@ -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::<Vec<_>>();
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;
+5 -5
View File
@@ -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,
+10 -9
View File
@@ -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,
}
)
+32
View File
@@ -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(
+15
View File
@@ -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<String>,
/// 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<String>,
/// 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<RealtimeWsMode>,
/// 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<String>,
}
#[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,
+1
View File
@@ -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;
+5 -1
View File
@@ -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
+8
View File
@@ -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",
+1
View File
@@ -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;
@@ -13,6 +13,7 @@ fn skill_with_tools(tools: Vec<SkillToolDependency>) -> SkillMetadata {
dependencies: Some(SkillDependencies { tools }),
policy: None,
permission_profile: None,
managed_network_override: None,
path_to_skills_md: PathBuf::from("skill"),
scope: SkillScope::User,
}
+106
View File
@@ -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<AppConnectorId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginTelemetryMetadata {
pub plugin_id: PluginId,
pub capability_summary: Option<PluginCapabilitySummary>,
}
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<Self> {
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<PluginTelemetryMetadata> {
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<HashMap<PathBuf, PluginLoadOutcome>>,
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
}
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,
@@ -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();
+5
View File
@@ -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;
+100
View File
@@ -0,0 +1,100 @@
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
pub fn collect_plugin_enabled_candidates<'a>(
edits: impl Iterator<Item = (&'a String, &'a JsonValue)>,
) -> BTreeMap<String, bool> {
let mut pending_changes = BTreeMap::new();
for (key_path, value) in edits {
let segments = key_path
.split('.')
.map(str::to_string)
.collect::<Vec<String>>();
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)])
);
}
}
+21 -16
View File
@@ -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<HeaderMap>,
prompt: String,
model: Option<String>,
session_id: Option<String>,
session_config: RealtimeSessionConfig,
) -> CodexResult<(Receiver<RealtimeEvent>, Arc<AtomicBool>)> {
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,
@@ -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,
}
@@ -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,
}
+67 -4
View File
@@ -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<Policy>,
#[serde(default)]
permissions: Option<PermissionProfile>,
permissions: Option<SkillPermissionProfile>,
}
#[derive(Default)]
@@ -69,6 +71,27 @@ struct LoadedSkillMetadata {
dependencies: Option<SkillDependencies>,
policy: Option<SkillPolicy>,
permission_profile: Option<PermissionProfile>,
managed_network_override: Option<SkillManagedNetworkOverride>,
}
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
struct SkillPermissionProfile {
#[serde(default)]
network: Option<SkillNetworkPermissions>,
#[serde(default)]
file_system: Option<FileSystemPermissions>,
#[serde(default)]
macos: Option<MacOsSeatbeltProfileExtensions>,
}
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
struct SkillNetworkPermissions {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
allowed_domains: Option<Vec<String>>,
#[serde(default)]
denied_domains: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
@@ -527,6 +550,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
dependencies,
policy,
permission_profile,
managed_network_override,
} = load_skill_metadata(path);
validate_len(&name, MAX_NAME_LEN, "name")?;
@@ -549,6 +573,7 @@ fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, Ski
dependencies,
policy,
permission_profile,
managed_network_override,
path_to_skills_md: resolved_path,
scope,
})
@@ -614,14 +639,52 @@ fn load_skill_metadata(skill_path: &Path) -> 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<SkillPermissionProfile>,
) -> (
Option<PermissionProfile>,
Option<SkillManagedNetworkOverride>,
) {
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<Interface>, skill_dir: &Path) -> Option<SkillInterface> {
let interface = interface?;
let interface = SkillInterface {
+128 -4
View File
@@ -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::<SkillMetadataFile>(
@@ -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::<SkillMetadataFile>(
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,
}]
+14
View File
@@ -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<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
}
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<SkillDependencies>,
pub policy: Option<SkillPolicy>,
pub permission_profile: Option<PermissionProfile>,
pub managed_network_override: Option<SkillManagedNetworkOverride>,
/// Path to the SKILLS.md file that declares this skill.
pub path_to_skills_md: PathBuf,
pub scope: SkillScope,
@@ -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);
@@ -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.
@@ -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<u64>,
#[serde(default)]
max_output_tokens: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
struct CodeModeExecArgs {
code: String,
yield_time_ms: Option<u64>,
max_output_tokens: Option<usize>,
}
impl CodeModeExecuteHandler {
async fn execute(
@@ -26,12 +45,13 @@ impl CodeModeExecuteHandler {
turn: std::sync::Arc<TurnContext>,
code: String,
) -> Result<FunctionToolOutput, FunctionCallError> {
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<CodeModeExecArgs, FunctionCallError> {
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;
@@ -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"
);
}
+3 -1
View File
@@ -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<protocol::EnabledTool> {
}
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<protocol::EnabledTool> {
};
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),
@@ -46,6 +46,8 @@ pub(super) enum HostToNodeMessage {
enabled_tools: Vec<EnabledTool>,
stored_values: HashMap<String, JsonValue>,
source: String,
yield_time_ms: Option<u64>,
max_output_tokens: Option<usize>,
},
Poll {
request_id: String,
+11 -66
View File
@@ -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;
@@ -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<Self::Output, FunctionCallError> {
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<String>,
items: Option<Vec<UserInput>>,
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
fork_context: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct SpawnAgentResult {
agent_id: String,
nickname: Option<String>,
}
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<Self::Output, FunctionCallError> {
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<String>,
items: Option<Vec<UserInput>>,
#[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<Self::Output, FunctionCallError> {
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<Session>,
turn: &Arc<TurnContext>,
receiver_thread_id: ThreadId,
child_depth: i32,
) -> Result<AgentStatus, FunctionCallError> {
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<Self::Output, FunctionCallError> {
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::<Result<Vec<_>, _>>()?;
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::<HashMap<_, _>>();
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<String>,
timeout_ms: Option<i64>,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct WaitResult {
pub(crate) status: HashMap<ThreadId, AgentStatus>,
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<Session>,
thread_id: ThreadId,
mut status_rx: Receiver<AgentStatus>,
) -> 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<Self::Output, FunctionCallError> {
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, FunctionCallError> {
ThreadId::from_string(id)
@@ -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<Self::Output, FunctionCallError> {
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")
}
}
@@ -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<Self::Output, FunctionCallError> {
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<Session>,
turn: &Arc<TurnContext>,
receiver_thread_id: ThreadId,
child_depth: i32,
) -> Result<AgentStatus, FunctionCallError> {
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)
}
@@ -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<Self::Output, FunctionCallError> {
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<String>,
items: Option<Vec<UserInput>>,
#[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")
}
}
@@ -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<Self::Output, FunctionCallError> {
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<String>,
items: Option<Vec<UserInput>>,
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
fork_context: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct SpawnAgentResult {
agent_id: String,
nickname: Option<String>,
}
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")
}
}
@@ -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<Self::Output, FunctionCallError> {
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::<Result<Vec<_>, _>>()?;
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::<HashMap<_, _>>();
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<String>,
timeout_ms: Option<i64>,
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct WaitResult {
pub(crate) status: HashMap<ThreadId, AgentStatus>,
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<Session>,
thread_id: ThreadId,
mut status_rx: Receiver<AgentStatus>,
) -> 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));
}
}
}
@@ -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(
@@ -92,6 +92,7 @@ fn test_skill_metadata(permission_profile: Option<PermissionProfile>) -> SkillMe
dependencies: None,
policy: None,
permission_profile,
managed_network_override: None,
path_to_skills_md: PathBuf::from("/tmp/skill/SKILL.md"),
scope: SkillScope::User,
}
+28 -44
View File
@@ -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 {
+3 -17
View File
@@ -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]
+1 -1
View File
@@ -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 {
+55 -21
View File
@@ -28,11 +28,13 @@ use std::time::Instant;
use wiremock::MockServer;
fn custom_tool_output_items(req: &ResponsesRequest, call_id: &str) -> Vec<Value> {
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<Value> {
@@ -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(()));
+83
View File
@@ -107,6 +107,25 @@ async fn build_plugin_test_codex(
.codex)
}
async fn build_analytics_plugin_test_codex(
server: &MockServer,
codex_home: Arc<TempDir>,
) -> Result<Arc<codex_core::CodexThread>> {
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<TempDir>,
@@ -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(()));
@@ -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<S: serde::Serialize>(
call_id: &str,
command: &str,
additional_permissions: &PermissionProfile,
additional_permissions: &S,
) -> Result<Value> {
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<Value> {
let args = json!({
"reason": reason,
@@ -131,10 +132,10 @@ fn exec_command_event(call_id: &str, command: &str) -> Result<Value> {
Ok(ev_function_call(call_id, "exec_command", &args_str))
}
fn exec_command_event_with_request_permissions(
fn exec_command_event_with_request_permissions<S: serde::Serialize>(
call_id: &str,
command: &str,
additional_permissions: &PermissionProfile,
additional_permissions: &S,
) -> Result<Value> {
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<PermissionProfile> {
Ok(PermissionProfile {
fn normalized_directory_write_permissions(path: &Path) -> Result<RequestPermissionProfile> {
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());
@@ -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<Value> {
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<PermissionProfile> {
Ok(PermissionProfile {
fn normalized_directory_write_permissions(path: &Path) -> Result<RequestPermissionProfile> {
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,
@@ -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.
+37 -3
View File
@@ -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<NetworkPermissions>,
pub file_system: Option<FileSystemPermissions>,
}
impl RequestPermissionProfile {
pub fn is_empty(&self) -> bool {
self.network.is_none() && self.file_system.is_none()
}
}
impl From<RequestPermissionProfile> for PermissionProfile {
fn from(value: RequestPermissionProfile) -> Self {
Self {
network: value.network,
file_system: value.file_system,
macos: None,
}
}
}
impl From<PermissionProfile> 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<String>,
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<String>,
pub permissions: PermissionProfile,
pub permissions: RequestPermissionProfile,
}
+1 -1
View File
@@ -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(),
@@ -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<String>,
call_id: String,
reason: Option<String>,
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<dyn Renderable> {
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<String> {
format_additional_permissions_rule(&permissions.clone().into())
}
fn patch_options() -> Vec<ApprovalOption> {
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()
},
}
}
@@ -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,
}]));
+2 -1
View File
@@ -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,
}]),
+1
View File
@@ -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,
}
+2
View File
@@ -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,
},