mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Merge ea364629e2 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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([(¶ms.key_path, ¶ms.value)].into_iter());
|
||||
let response = self
|
||||
.config_service()
|
||||
.write_value(params)
|
||||
.await
|
||||
.map_err(map_error)
|
||||
.map_err(map_error)?;
|
||||
self.emit_plugin_toggle_events(pending_changes);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub(crate) async fn batch_write(
|
||||
@@ -124,16 +136,38 @@ impl ConfigApi {
|
||||
params: ConfigBatchWriteParams,
|
||||
) -> Result<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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-4
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}]));
|
||||
|
||||
@@ -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,
|
||||
}]),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user