| use super::thread_input::ensure_direct_input_allowed; |
| use super::*; |
| use codex_agent_extension::AgentInvocation; |
| use codex_agent_extension::AgentRun; |
| use codex_agent_extension::AgentRunner; |
| use codex_app_server_protocol::ImageReference as V2ImageReference; |
| use codex_protocol::error::CodexErrorDetails; |
| use codex_protocol::models::ContentItem; |
| use codex_protocol::models::FunctionCallOutputBody; |
| use codex_protocol::models::FunctionCallOutputContentItem; |
| use codex_protocol::models::FunctionCallOutputPayload; |
| use codex_protocol::models::ImageReference as CoreImageReference; |
| use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; |
| use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; |
| use codex_protocol::protocol::TurnSettingsUpdate; |
| use codex_protocol::protocol::TurnSettingsUpdateOutcome; |
| use codex_skills::system_cache_root_dir; |
|
|
| use crate::image_url::REMOTE_IMAGE_URL_ERROR; |
| use crate::image_url::is_remote_image_url; |
|
|
| pub(super) fn validate_user_input_image_urls( |
| input: &[V2UserInput], |
| ) -> Result<(), JSONRPCErrorError> { |
| if input.iter().any(|item| { |
| matches!( |
| item, |
| V2UserInput::Image { |
| image: V2ImageReference::Inline { url }, |
| .. |
| } if is_remote_image_url(url) |
| ) |
| }) { |
| return Err(invalid_request(REMOTE_IMAGE_URL_ERROR)); |
| } |
| Ok(()) |
| } |
|
|
| fn validate_response_item_image_urls(items: &[ResponseItem]) -> Result<(), JSONRPCErrorError> { |
| if items.iter().any(|item| match item { |
| ResponseItem::Message { content, .. } => content.iter().any(|item| { |
| matches!( |
| item, |
| ContentItem::InputImage { image: CoreImageReference::Inline { image_url }, .. } if is_remote_image_url(image_url) |
| ) |
| }), |
| ResponseItem::FunctionCallOutput { output, .. } |
| | ResponseItem::CustomToolCallOutput { output, .. } => { |
| output.content_items().is_some_and(|content| { |
| content.iter().any(|item| { |
| matches!( |
| item, |
| FunctionCallOutputContentItem::InputImage { image: CoreImageReference::Inline { image_url }, .. } |
| if is_remote_image_url(image_url) |
| ) |
| }) |
| }) |
| } |
| ResponseItem::Reasoning { .. } |
| | ResponseItem::AgentMessage { .. } |
| | ResponseItem::LocalShellCall { .. } |
| | ResponseItem::FunctionCall { .. } |
| | ResponseItem::ToolSearchCall { .. } |
| | ResponseItem::CustomToolCall { .. } |
| | ResponseItem::ToolSearchOutput { .. } |
| | ResponseItem::WebSearchCall { .. } |
| | ResponseItem::ImageGenerationCall { .. } |
| | ResponseItem::Compaction { .. } |
| | ResponseItem::ConfigurationUpdate { .. } |
| | ResponseItem::CompactionTrigger { .. } |
| | ResponseItem::ContextCompaction { .. } |
| | ResponseItem::AdditionalTools { .. } |
| | ResponseItem::Other => false, |
| }) { |
| return Err(invalid_request(REMOTE_IMAGE_URL_ERROR)); |
| } |
| Ok(()) |
| } |
|
|
| #[derive(Clone)] |
| pub(crate) struct TurnRequestProcessor { |
| agent_runner: AgentRunner, |
| auth_manager: Arc<AuthManager>, |
| thread_manager: Arc<ThreadManager>, |
| outgoing: Arc<OutgoingMessageSender>, |
| analytics_events_client: AnalyticsEventsClient, |
| arg0_paths: Arg0DispatchPaths, |
| config: Arc<Config>, |
| config_manager: ConfigManager, |
| pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>, |
| thread_state_manager: ThreadStateManager, |
| thread_watch_manager: ThreadWatchManager, |
| skills_watcher: Arc<SkillsWatcher>, |
| turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>, |
| } |
|
|
| fn map_additional_context( |
| additional_context: Option<HashMap<String, AdditionalContextEntry>>, |
| ) -> BTreeMap<String, CoreAdditionalContextEntry> { |
| additional_context |
| .unwrap_or_default() |
| .into_iter() |
| .map(|(key, entry)| { |
| ( |
| key, |
| CoreAdditionalContextEntry { |
| value: entry.value, |
| kind: match entry.kind { |
| AdditionalContextKind::Untrusted => CoreAdditionalContextKind::Untrusted, |
| AdditionalContextKind::Application => { |
| CoreAdditionalContextKind::Application |
| } |
| }, |
| }, |
| ) |
| }) |
| .collect() |
| } |
|
|
| #[derive(Default)] |
| struct ThreadEnvironmentOverride { |
| environments: Option<TurnEnvironmentSelections>, |
| |
| runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>, |
| } |
|
|
| struct ThreadSettingsBuildParams { |
| method: &'static str, |
| disabled_plugin_ids: Option<Vec<String>>, |
| environment_override: ThreadEnvironmentOverride, |
| approval_policy: Option<codex_app_server_protocol::AskForApproval>, |
| approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>, |
| sandbox_policy: Option<codex_app_server_protocol::SandboxPolicy>, |
| permissions: Option<String>, |
| model: Option<String>, |
| service_tier: Option<Option<String>>, |
| effort: Option<ReasoningEffort>, |
| summary: Option<ReasoningSummary>, |
| collaboration_mode: Option<CollaborationMode>, |
| personality: Option<Personality>, |
| } |
|
|
| impl TurnRequestProcessor { |
| #[allow(clippy::too_many_arguments)] |
| pub(crate) fn new( |
| auth_manager: Arc<AuthManager>, |
| thread_manager: Arc<ThreadManager>, |
| outgoing: Arc<OutgoingMessageSender>, |
| analytics_events_client: AnalyticsEventsClient, |
| arg0_paths: Arg0DispatchPaths, |
| config: Arc<Config>, |
| config_manager: ConfigManager, |
| pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>, |
| thread_state_manager: ThreadStateManager, |
| thread_watch_manager: ThreadWatchManager, |
| skills_watcher: Arc<SkillsWatcher>, |
| turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>, |
| ) -> Self { |
| let agent_runner = AgentRunner::new(Arc::downgrade(&thread_manager)); |
| Self { |
| agent_runner, |
| auth_manager, |
| thread_manager, |
| outgoing, |
| analytics_events_client, |
| arg0_paths, |
| config, |
| config_manager, |
| pending_thread_unloads, |
| thread_state_manager, |
| thread_watch_manager, |
| skills_watcher, |
| turn_cost_worker, |
| } |
| } |
|
|
| pub(crate) async fn turn_start( |
| &self, |
| request_id: ConnectionRequestId, |
| params: TurnStartParams, |
| app_server_client_name: Option<String>, |
| app_server_client_version: Option<String>, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| validate_user_input_image_urls(¶ms.input)?; |
| self.turn_start_inner( |
| request_id, |
| params, |
| app_server_client_name, |
| app_server_client_version, |
| ) |
| .await |
| .map(|response| Some(response.into())) |
| } |
|
|
| pub(crate) async fn thread_inject_items( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadInjectItemsParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| self.thread_inject_items_response_inner(request_id, params) |
| .await |
| .map(|response| Some(response.into())) |
| } |
|
|
| pub(crate) async fn thread_settings_update( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadSettingsUpdateParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| self.thread_settings_update_inner(request_id, params) |
| .await |
| .map(|response| Some(response.into())) |
| } |
|
|
| pub(crate) async fn turn_settings_update( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: TurnSettingsUpdateParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| let (_, thread) = self.load_thread(¶ms.thread_id).await?; |
| self.ensure_direct_input_allowed(request_id, thread.as_ref()) |
| .await?; |
| let (reply, outcome) = oneshot::channel(); |
| self.submit_core_op( |
| request_id, |
| &thread, |
| Op::TurnSettings { |
| turn_id: params.turn_id, |
| update: TurnSettingsUpdate { |
| approvals_reviewer: params |
| .approvals_reviewer |
| .map(codex_app_server_protocol::ApprovalsReviewer::to_core), |
| model: params.model, |
| |
| effort: params.effort.map(Some), |
| summary: params.summary, |
| service_tier: params.service_tier, |
| }, |
| reply, |
| }, |
| ) |
| .await |
| .map_err(|err| internal_error(format!("failed to submit turn settings: {err}")))?; |
| let outcome = outcome |
| .await |
| .map_err(|_| internal_error("turn settings operation ended before replying"))?; |
| let status = match outcome { |
| TurnSettingsUpdateOutcome::Applied => TurnSettingsUpdateStatus::Applied, |
| TurnSettingsUpdateOutcome::TargetUnavailable => { |
| TurnSettingsUpdateStatus::TargetUnavailable |
| } |
| TurnSettingsUpdateOutcome::Rejected { reason } => return Err(invalid_request(reason)), |
| }; |
| Ok(Some(TurnSettingsUpdateResponse { status }.into())) |
| } |
|
|
| pub(crate) async fn turn_steer( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: TurnSteerParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| validate_user_input_image_urls(¶ms.input)?; |
| self.turn_steer_inner(request_id, params) |
| .await |
| .map(|response| Some(response.into())) |
| } |
|
|
| pub(crate) async fn turn_interrupt( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: TurnInterruptParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| let result = self.turn_interrupt_inner(request_id, params).await; |
| if let Err(error) = &result { |
| self.track_error_response(request_id, error, None); |
| } |
| result.map(|response| response.map(Into::into)) |
| } |
|
|
| pub(crate) async fn thread_realtime_start( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeStartParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| self.thread_realtime_start_inner(request_id, params) |
| .await |
| .map(|response| response.map(Into::into)) |
| } |
|
|
| pub(crate) async fn thread_realtime_append_audio( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeAppendAudioParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| self.thread_realtime_append_audio_inner(request_id, params) |
| .await |
| .map(|response| response.map(Into::into)) |
| } |
|
|
| pub(crate) async fn thread_realtime_append_text( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeAppendTextParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| self.thread_realtime_append_text_inner(request_id, params) |
| .await |
| .map(|response| response.map(Into::into)) |
| } |
|
|
| pub(crate) async fn thread_realtime_append_speech( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeAppendSpeechParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| self.thread_realtime_append_speech_inner(request_id, params) |
| .await |
| .map(|response| response.map(Into::into)) |
| } |
|
|
| pub(crate) async fn thread_realtime_stop( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeStopParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| self.thread_realtime_stop_inner(request_id, params) |
| .await |
| .map(|response| response.map(Into::into)) |
| } |
|
|
| pub(crate) async fn thread_realtime_list_voices( |
| &self, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| Ok(Some( |
| ThreadRealtimeListVoicesResponse { |
| voices: RealtimeVoicesList::builtin(), |
| } |
| .into(), |
| )) |
| } |
|
|
| pub(crate) async fn review_start( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ReviewStartParams, |
| ) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> { |
| if matches!(params.delivery, Some(ApiReviewDelivery::Detached)) { |
| self.outgoing |
| .send_server_notification_to_connections( |
| &[request_id.connection_id], |
| ServerNotification::DeprecationNotice(DeprecationNoticeNotification { |
| summary: "review/start with delivery \"detached\" is deprecated and will be removed in a future release.".to_string(), |
| details: Some("Use thread/start followed by review/start with delivery \"inline\" for a separate review thread, or thread/fork followed by turn/start with your own review instructions.".to_string()), |
| }), |
| ) |
| .await; |
| } |
| self.review_start_inner(request_id, params) |
| .await |
| .map(|()| None) |
| } |
|
|
| fn track_error_response( |
| &self, |
| request_id: &ConnectionRequestId, |
| error: &JSONRPCErrorError, |
| error_type: Option<AnalyticsJsonRpcError>, |
| ) { |
| self.analytics_events_client.track_error_response( |
| request_id.connection_id.0, |
| request_id.request_id.clone(), |
| error.clone(), |
| error_type, |
| ); |
| } |
|
|
| async fn load_thread( |
| &self, |
| thread_id: &str, |
| ) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> { |
| |
| let thread_id = ThreadId::from_string(thread_id) |
| .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; |
|
|
| let thread = self |
| .thread_manager |
| .get_thread(thread_id) |
| .await |
| .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; |
|
|
| Ok((thread_id, thread)) |
| } |
|
|
| async fn ensure_direct_input_allowed( |
| &self, |
| request_id: &ConnectionRequestId, |
| thread: &CodexThread, |
| ) -> Result<(), JSONRPCErrorError> { |
| ensure_direct_input_allowed(thread) |
| .await |
| .inspect_err(|error| { |
| self.track_error_response(request_id, error, None); |
| }) |
| } |
|
|
| fn normalize_collaboration_mode( |
| &self, |
| mut collaboration_mode: CollaborationMode, |
| ) -> CollaborationMode { |
| if collaboration_mode.settings.developer_instructions.is_none() |
| && let Some(instructions) = builtin_collaboration_mode_presets() |
| .into_iter() |
| .find(|preset| preset.mode == Some(collaboration_mode.mode)) |
| .and_then(|preset| preset.developer_instructions.flatten()) |
| .filter(|instructions| !instructions.is_empty()) |
| { |
| collaboration_mode.settings.developer_instructions = Some(instructions); |
| } |
|
|
| collaboration_mode |
| } |
|
|
| fn review_request_from_target( |
| target: ApiReviewTarget, |
| ) -> Result<(ReviewRequest, String, String), JSONRPCErrorError> { |
| let cleaned_target = match target { |
| ApiReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, |
| ApiReviewTarget::BaseBranch { branch } => { |
| let branch = branch.trim().to_string(); |
| if branch.is_empty() { |
| return Err(invalid_request("branch must not be empty".to_string())); |
| } |
| ApiReviewTarget::BaseBranch { branch } |
| } |
| ApiReviewTarget::Commit { sha, title } => { |
| let sha = sha.trim().to_string(); |
| if sha.is_empty() { |
| return Err(invalid_request("sha must not be empty".to_string())); |
| } |
| let title = title |
| .map(|t| t.trim().to_string()) |
| .filter(|t| !t.is_empty()); |
| ApiReviewTarget::Commit { sha, title } |
| } |
| ApiReviewTarget::Custom { instructions } => { |
| let trimmed = instructions.trim().to_string(); |
| if trimmed.is_empty() { |
| return Err(invalid_request( |
| "instructions must not be empty".to_string(), |
| )); |
| } |
| ApiReviewTarget::Custom { |
| instructions: trimmed, |
| } |
| } |
| }; |
|
|
| let core_target = match cleaned_target { |
| ApiReviewTarget::UncommittedChanges => CoreReviewTarget::UncommittedChanges, |
| ApiReviewTarget::BaseBranch { branch } => CoreReviewTarget::BaseBranch { branch }, |
| ApiReviewTarget::Commit { sha, title } => CoreReviewTarget::Commit { sha, title }, |
| ApiReviewTarget::Custom { instructions } => CoreReviewTarget::Custom { instructions }, |
| }; |
| let target_prompt = match &core_target { |
| CoreReviewTarget::UncommittedChanges => { |
| "Review the current code changes (staged, unstaged, and untracked files)." |
| .to_string() |
| } |
| CoreReviewTarget::BaseBranch { branch } => { |
| format!("Review the code changes against the base branch {branch:?}.") |
| } |
| CoreReviewTarget::Commit { sha, .. } => { |
| format!("Review the changes introduced by commit {sha:?}.") |
| } |
| CoreReviewTarget::Custom { instructions } => instructions.clone(), |
| }; |
|
|
| let hint = codex_core::review_prompts::user_facing_hint(&core_target); |
| let review_request = ReviewRequest { |
| target: core_target, |
| user_facing_hint: Some(hint.clone()), |
| }; |
|
|
| Ok((review_request, hint, target_prompt)) |
| } |
|
|
| async fn request_trace_context( |
| &self, |
| request_id: &ConnectionRequestId, |
| ) -> Option<codex_protocol::protocol::W3cTraceContext> { |
| self.outgoing.request_trace_context(request_id).await |
| } |
|
|
| async fn submit_core_op( |
| &self, |
| request_id: &ConnectionRequestId, |
| thread: &CodexThread, |
| op: Op, |
| ) -> CodexResult<String> { |
| thread |
| .submit_with_trace(op, self.request_trace_context(request_id).await) |
| .await |
| } |
|
|
| pub(super) fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError { |
| let mut error = invalid_params(format!( |
| "Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters." |
| )); |
| error.data = Some(serde_json::json!({ |
| "input_error_code": INPUT_TOO_LARGE_ERROR_CODE, |
| "max_chars": MAX_USER_INPUT_TEXT_CHARS, |
| "actual_chars": actual_chars, |
| })); |
| error |
| } |
|
|
| pub(super) fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { |
| let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum(); |
| if actual_chars > MAX_USER_INPUT_TEXT_CHARS { |
| return Err(Self::input_too_large_error(actual_chars)); |
| } |
| Ok(()) |
| } |
|
|
| async fn turn_start_inner( |
| &self, |
| request_id: ConnectionRequestId, |
| params: TurnStartParams, |
| app_server_client_name: Option<String>, |
| app_server_client_version: Option<String>, |
| ) -> Result<TurnStartResponse, JSONRPCErrorError> { |
| let (thread_id, thread) = |
| self.load_thread(¶ms.thread_id) |
| .await |
| .inspect_err(|error| { |
| self.track_error_response(&request_id, error, None); |
| })?; |
| self.ensure_direct_input_allowed(&request_id, thread.as_ref()) |
| .await?; |
| self.config_manager |
| .check_thread_model_provider(thread.config().await.as_ref()) |
| .await |
| .map_err(|error| config_load_error(&error))?; |
| if let Some(tool_output) = ¶ms.tool_output { |
| if !params.input.is_empty() { |
| return Err(invalid_request( |
| "`toolOutput` cannot be combined with nonempty `input`", |
| )); |
| } |
| if tool_output.name.is_empty() { |
| return Err(invalid_request("`toolOutput.name` must not be empty")); |
| } |
| } |
| let actual_chars = params |
| .input |
| .iter() |
| .map(V2UserInput::text_char_count) |
| .sum::<usize>() |
| + params |
| .tool_output |
| .as_ref() |
| .map_or(0, |output| match &output.output { |
| FunctionCallOutputBody::Text(text) => text.chars().count(), |
| FunctionCallOutputBody::ContentItems(items) => items |
| .iter() |
| .map(|item| match item { |
| FunctionCallOutputContentItem::InputText { text } => { |
| text.chars().count() |
| } |
| _ => 0, |
| }) |
| .sum(), |
| }); |
| if actual_chars > MAX_USER_INPUT_TEXT_CHARS { |
| let error = Self::input_too_large_error(actual_chars); |
| self.track_error_response( |
| &request_id, |
| &error, |
| Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), |
| ); |
| return Err(error); |
| } |
| Self::set_app_server_client_info( |
| thread.as_ref(), |
| app_server_client_name, |
| app_server_client_version, |
| ) |
| .await |
| .inspect_err(|error| { |
| self.track_error_response(&request_id, error, None); |
| })?; |
| let runtime_workspace_roots = params |
| .runtime_workspace_roots |
| .map(resolve_runtime_workspace_roots); |
| let environment_selections = |
| resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?; |
|
|
| let additional_context = map_additional_context(params.additional_context); |
| let turn_has_input = !params.input.is_empty(); |
| let input = if let Some(tool_output) = params.tool_output { |
| let item = ResponseItem::FunctionCallOutput { |
| id: None, |
| call_id: None, |
| name: Some(tool_output.name), |
| namespace: tool_output.namespace, |
| output: FunctionCallOutputPayload { |
| body: tool_output.output, |
| success: None, |
| }, |
| internal_chat_message_metadata_passthrough: None, |
| }; |
| validate_response_item_image_urls(std::slice::from_ref(&item))?; |
| TurnInput::ResponseItem(item) |
| } else { |
| TurnInput::UserInput { |
| content: params |
| .input |
| .into_iter() |
| .map(V2UserInput::into_core) |
| .collect(), |
| client_id: params.client_user_message_id, |
| } |
| }; |
| let cwd = resolve_request_cwd(params.cwd)?; |
| let environment_override = self |
| .build_environment_override( |
| thread.as_ref(), |
| cwd, |
| runtime_workspace_roots, |
| environment_selections, |
| ) |
| .await; |
| let thread_settings = self |
| .build_thread_settings_overrides( |
| thread.as_ref(), |
| ThreadSettingsBuildParams { |
| method: "turn/start", |
| disabled_plugin_ids: params.disabled_plugin_ids, |
| environment_override, |
| approval_policy: params.approval_policy, |
| approvals_reviewer: params.approvals_reviewer, |
| sandbox_policy: params.sandbox_policy, |
| permissions: params.permissions, |
| model: params.model, |
| service_tier: params.service_tier, |
| effort: params.effort, |
| summary: params.summary, |
| collaboration_mode: params.collaboration_mode, |
| personality: params.personality, |
| }, |
| ) |
| .await?; |
|
|
| let submission = thread |
| .start_or_steer_turn( |
| TurnInputRequest::new(input) |
| .with_thread_settings(thread_settings) |
| .on_start(TurnStartOptions { |
| turn_trigger: params.turn_trigger, |
| final_output_json_schema: params.output_schema, |
| service_tier: params.service_tier_for_turn, |
| cyber_access_program: params.cyber_access_program.map(Into::into), |
| ..Default::default() |
| }) |
| .with_additional_context(additional_context) |
| .with_responses_metadata(params.responsesapi_client_metadata) |
| .with_trace(self.request_trace_context(&request_id).await), |
| ) |
| .await |
| .map_err(|err| { |
| let error = internal_error(format!("failed to submit turn input: {err}")); |
| self.track_error_response(&request_id, &error, None); |
| error |
| })?; |
| let (turn_id, started) = match submission { |
| TurnInputSubmission::Started { turn_id } => (turn_id, true), |
| TurnInputSubmission::Steered { turn_id } => (turn_id, false), |
| TurnInputSubmission::NotSubmitted { reason } => { |
| let error = if reason == NotSubmittedReason::ServerDraining { |
| crate::error_code::server_draining_error() |
| } else { |
| internal_error(format!("failed to submit turn input: {reason:?}")) |
| }; |
| self.track_error_response(&request_id, &error, None); |
| return Err(error); |
| } |
| }; |
|
|
| if turn_has_input && started { |
| let config_snapshot = thread.config_snapshot().await; |
| if config_snapshot.is_primary_environment_configured() { |
| codex_memories_write::start_memories_startup_task( |
| Arc::clone(&self.thread_manager), |
| Arc::clone(&self.auth_manager), |
| thread_id, |
| Arc::clone(&thread), |
| thread.config().await, |
| config_snapshot.permission_profile, |
| &config_snapshot.session_source, |
| ); |
| } |
| } |
|
|
| self.outgoing |
| .record_request_turn_id(&request_id, &turn_id) |
| .await; |
| let turn = Turn { |
| id: turn_id, |
| items: vec![], |
| items_view: TurnItemsView::NotLoaded, |
| error: None, |
| status: TurnStatus::InProgress, |
| started_at: None, |
| completed_at: None, |
| duration_ms: None, |
| }; |
|
|
| Ok(TurnStartResponse { turn }) |
| } |
|
|
| async fn build_environment_override( |
| &self, |
| thread: &CodexThread, |
| cwd: Option<AbsolutePathBuf>, |
| workspace_roots: Option<Vec<AbsolutePathBuf>>, |
| environment_selections: Option<Vec<TurnEnvironmentSelection>>, |
| ) -> ThreadEnvironmentOverride { |
| if cwd.is_none() && workspace_roots.is_none() && environment_selections.is_none() { |
| return ThreadEnvironmentOverride::default(); |
| } |
|
|
| |
| |
| if let Some(environment_selections) = environment_selections { |
| let legacy_fallback_cwd = match cwd { |
| Some(cwd) => cwd, |
| None => match environment_selections |
| .iter() |
| .find(|selection| selection.environment_id == LOCAL_ENVIRONMENT_ID) |
| .and_then(|selection| selection.cwd.to_abs_path().ok()) |
| { |
| Some(cwd) => cwd, |
| None => thread.config_snapshot().await.cwd().clone(), |
| }, |
| }; |
| return ThreadEnvironmentOverride { |
| environments: Some(TurnEnvironmentSelections::new( |
| legacy_fallback_cwd, |
| environment_selections, |
| )), |
| ..Default::default() |
| }; |
| } |
|
|
| |
| let snapshot = thread.thread_settings_snapshot().await; |
| let current_cwd = snapshot.cwd; |
| let legacy_fallback_cwd = cwd.unwrap_or_else(|| current_cwd.clone()); |
| let workspace_roots = match workspace_roots { |
| Some(workspace_roots) => workspace_roots, |
| None => path_utils::replace_path_and_deduplicate( |
| snapshot.runtime_workspace_roots.unwrap_or_default(), |
| current_cwd.as_path(), |
| legacy_fallback_cwd.clone(), |
| ), |
| }; |
| let environment_selections = self |
| .thread_manager |
| .default_environment_selections(&legacy_fallback_cwd, &workspace_roots); |
| ThreadEnvironmentOverride { |
| environments: Some(TurnEnvironmentSelections::new( |
| legacy_fallback_cwd, |
| environment_selections, |
| )), |
| runtime_workspace_roots: Some(workspace_roots), |
| } |
| } |
|
|
| async fn build_thread_settings_overrides( |
| &self, |
| thread: &CodexThread, |
| params: ThreadSettingsBuildParams, |
| ) -> Result<codex_protocol::protocol::ThreadSettingsOverrides, JSONRPCErrorError> { |
| let ThreadSettingsBuildParams { |
| method, |
| disabled_plugin_ids, |
| environment_override: |
| ThreadEnvironmentOverride { |
| environments, |
| runtime_workspace_roots, |
| }, |
| approval_policy, |
| approvals_reviewer, |
| sandbox_policy, |
| permissions, |
| model, |
| service_tier, |
| effort, |
| summary, |
| collaboration_mode, |
| personality, |
| } = params; |
|
|
| if sandbox_policy.is_some() && permissions.is_some() { |
| return Err(invalid_request( |
| "`permissions` cannot be combined with `sandboxPolicy`", |
| )); |
| } |
|
|
| let collaboration_mode = |
| collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode)); |
| let has_environment_override = environments.is_some(); |
| |
| |
| |
| let snapshot = if permissions.is_some() { |
| Some(thread.config_snapshot().await) |
| } else { |
| None |
| }; |
|
|
| let has_any_overrides = has_environment_override |
| || disabled_plugin_ids.is_some() |
| || approval_policy.is_some() |
| || approvals_reviewer.is_some() |
| || sandbox_policy.is_some() |
| || permissions.is_some() |
| || model.is_some() |
| || service_tier.is_some() |
| || effort.is_some() |
| || summary.is_some() |
| || collaboration_mode.is_some() |
| || personality.is_some(); |
|
|
| let approval_policy = |
| approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); |
| let approvals_reviewer = |
| approvals_reviewer.map(codex_app_server_protocol::ApprovalsReviewer::to_core); |
| let sandbox_policy = sandbox_policy.map(|policy| policy.to_core()); |
| let (permission_profile, active_permission_profile, profile_workspace_roots) = |
| if let Some(permissions) = permissions { |
| let Some(snapshot) = snapshot.as_ref() else { |
| return Err(internal_error(format!( |
| "{method} permission selection missing thread snapshot" |
| ))); |
| }; |
| let overrides = ConfigOverrides { |
| cwd: environments |
| .as_ref() |
| .map(|environments| environments.legacy_fallback_cwd.to_path_buf()), |
| default_permissions: Some(permissions), |
| codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), |
| main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), |
| ..Default::default() |
| }; |
| let config = self |
| .config_manager |
| .load_for_cwd( |
| None, |
| overrides, |
| Some(snapshot.cwd().to_path_buf()), |
| ) |
| .await |
| .map_err(|err| config_load_error(&err))?; |
| |
| |
| |
| if let Some(warning) = config.startup_warnings.iter().find(|warning| { |
| warning.contains("Configured value for `permission_profile` is disallowed") |
| }) { |
| return Err(invalid_request(format!( |
| "invalid thread settings override: {warning}" |
| ))); |
| } |
| ( |
| Some(config.permissions.permission_profile().clone()), |
| config.permissions.active_permission_profile(), |
| Some(config.permissions.profile_workspace_roots().to_vec()), |
| ) |
| } else { |
| (None, None, None) |
| }; |
| let effort = effort.map(Some); |
|
|
| if has_any_overrides { |
| thread |
| .preview_thread_settings_overrides(CodexThreadSettingsOverrides { |
| disabled_plugin_ids: disabled_plugin_ids.clone(), |
| environments: environments.clone(), |
| runtime_workspace_roots: runtime_workspace_roots.clone(), |
| approval_policy, |
| approvals_reviewer, |
| sandbox_policy: sandbox_policy.clone(), |
| permission_profile: permission_profile.clone(), |
| active_permission_profile: active_permission_profile.clone(), |
| profile_workspace_roots: profile_workspace_roots.clone(), |
| windows_sandbox_level: None, |
| model: model.clone(), |
| effort: effort.clone(), |
| summary, |
| service_tier: service_tier.clone(), |
| collaboration_mode: collaboration_mode.clone(), |
| personality, |
| }) |
| .await |
| .map_err(|err| { |
| invalid_request(format!("invalid thread settings override: {err}")) |
| })?; |
| } |
|
|
| Ok(codex_protocol::protocol::ThreadSettingsOverrides { |
| disabled_plugin_ids, |
| environments, |
| runtime_workspace_roots, |
| profile_workspace_roots, |
| approval_policy, |
| approvals_reviewer, |
| sandbox_policy, |
| permission_profile, |
| active_permission_profile, |
| windows_sandbox_level: None, |
| model, |
| effort, |
| summary, |
| service_tier, |
| collaboration_mode, |
| personality, |
| }) |
| } |
|
|
| async fn thread_settings_update_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadSettingsUpdateParams, |
| ) -> Result<ThreadSettingsUpdateResponse, JSONRPCErrorError> { |
| let (_, thread) = self.load_thread(¶ms.thread_id).await?; |
| self.ensure_direct_input_allowed(request_id, thread.as_ref()) |
| .await?; |
| let cwd = resolve_request_cwd(params.cwd)?; |
| let environment_override = self |
| .build_environment_override( |
| thread.as_ref(), |
| cwd, |
| None, |
| None, |
| ) |
| .await; |
| let thread_settings = self |
| .build_thread_settings_overrides( |
| thread.as_ref(), |
| ThreadSettingsBuildParams { |
| method: "thread/settings/update", |
| disabled_plugin_ids: params.disabled_plugin_ids, |
| environment_override, |
| approval_policy: params.approval_policy, |
| approvals_reviewer: params.approvals_reviewer, |
| sandbox_policy: params.sandbox_policy, |
| permissions: params.permissions, |
| model: params.model, |
| service_tier: params.service_tier, |
| effort: params.effort, |
| summary: params.summary, |
| collaboration_mode: params.collaboration_mode, |
| personality: params.personality, |
| }, |
| ) |
| .await?; |
|
|
| if thread_settings != codex_protocol::protocol::ThreadSettingsOverrides::default() { |
| self.submit_core_op( |
| request_id, |
| thread.as_ref(), |
| Op::ThreadSettings { thread_settings }, |
| ) |
| .await |
| .map_err(|err| internal_error(format!("failed to update thread settings: {err}")))?; |
| } |
|
|
| Ok(ThreadSettingsUpdateResponse {}) |
| } |
|
|
| async fn thread_inject_items_response_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadInjectItemsParams, |
| ) -> Result<ThreadInjectItemsResponse, JSONRPCErrorError> { |
| let (_, thread) = self.load_thread(¶ms.thread_id).await?; |
| self.ensure_direct_input_allowed(request_id, thread.as_ref()) |
| .await?; |
|
|
| let items = params |
| .items |
| .into_iter() |
| .enumerate() |
| .map(|(index, value)| { |
| serde_json::from_value::<ResponseItem>(value) |
| .map_err(|err| format!("items[{index}] is not a valid response item: {err}")) |
| }) |
| .collect::<std::result::Result<Vec<_>, _>>() |
| .map_err(invalid_request)?; |
| validate_response_item_image_urls(&items)?; |
|
|
| thread |
| .inject_response_items(items) |
| .await |
| .map_err(|err| match err.details() { |
| CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()), |
| _ => internal_error(format!("failed to inject response items: {err}")), |
| })?; |
| Ok(ThreadInjectItemsResponse {}) |
| } |
|
|
| async fn set_app_server_client_info( |
| thread: &CodexThread, |
| app_server_client_name: Option<String>, |
| app_server_client_version: Option<String>, |
| ) -> Result<(), JSONRPCErrorError> { |
| let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( |
| app_server_client_name.as_deref(), |
| app_server_client_version.as_deref(), |
| ); |
| thread |
| .set_app_server_client_info( |
| app_server_client_name, |
| app_server_client_version, |
| mcp_elicitations_auto_deny, |
| ) |
| .await |
| .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) |
| } |
|
|
| async fn turn_steer_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: TurnSteerParams, |
| ) -> Result<TurnSteerResponse, JSONRPCErrorError> { |
| let (_, thread) = self |
| .load_thread(¶ms.thread_id) |
| .await |
| .inspect_err(|error| { |
| self.track_error_response(request_id, error, None); |
| })?; |
| self.ensure_direct_input_allowed(request_id, thread.as_ref()) |
| .await?; |
| self.config_manager |
| .check_thread_model_provider(thread.config().await.as_ref()) |
| .await |
| .map_err(|error| config_load_error(&error))?; |
|
|
| if params.expected_turn_id.is_empty() { |
| return Err(invalid_request("expectedTurnId must not be empty")); |
| } |
| self.outgoing |
| .record_request_turn_id(request_id, ¶ms.expected_turn_id) |
| .await; |
| if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { |
| self.track_error_response( |
| request_id, |
| &error, |
| Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), |
| ); |
| return Err(error); |
| } |
|
|
| let mapped_items: Vec<CoreInputItem> = params |
| .input |
| .into_iter() |
| .map(V2UserInput::into_core) |
| .collect(); |
| let additional_context = map_additional_context(params.additional_context); |
|
|
| let submission = thread |
| .steer_turn( |
| TurnInputRequest::new(TurnInput::UserInput { |
| content: mapped_items, |
| client_id: params.client_user_message_id, |
| }) |
| .with_additional_context(additional_context) |
| .with_responses_metadata(params.responsesapi_client_metadata), |
| params.expected_turn_id, |
| ) |
| .await |
| .map_err(|err| { |
| let error = internal_error(format!("failed to steer turn: {err}")); |
| self.track_error_response(request_id, &error, None); |
| error |
| })?; |
| let turn_id = match submission { |
| SteerSubmission::Steered { turn_id } => turn_id, |
| SteerSubmission::NotSubmitted { reason } => { |
| let (message, data, error_type) = match reason { |
| NotSubmittedReason::ServerDraining => { |
| return Err(crate::error_code::server_draining_error()); |
| } |
| NotSubmittedReason::NoActiveTurn | NotSubmittedReason::NotIdle => ( |
| "no active turn to steer".to_string(), |
| None, |
| Some(AnalyticsJsonRpcError::TurnSteer( |
| TurnSteerRequestError::NoActiveTurn, |
| )), |
| ), |
| NotSubmittedReason::ExpectedTurnMismatch { expected, actual } => ( |
| format!("expected active turn id `{expected}` but found `{actual}`"), |
| None, |
| Some(AnalyticsJsonRpcError::TurnSteer( |
| TurnSteerRequestError::ExpectedTurnMismatch, |
| )), |
| ), |
| NotSubmittedReason::ActiveTurnNotSteerable { turn_kind } => { |
| let (message, turn_steer_error) = match turn_kind { |
| codex_protocol::protocol::NonSteerableTurnKind::Review => ( |
| "cannot steer a review turn".to_string(), |
| TurnSteerRequestError::NonSteerableReview, |
| ), |
| codex_protocol::protocol::NonSteerableTurnKind::Compact => ( |
| "cannot steer a compact turn".to_string(), |
| TurnSteerRequestError::NonSteerableCompact, |
| ), |
| }; |
| let error = TurnError { |
| misalignment: None, |
| message: message.clone(), |
| codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { |
| turn_kind: turn_kind.into(), |
| }), |
| additional_details: None, |
| }; |
| let data = match serde_json::to_value(error) { |
| Ok(data) => Some(data), |
| Err(error) => { |
| tracing::error!( |
| ?error, |
| "failed to serialize active-turn-not-steerable turn error" |
| ); |
| None |
| } |
| }; |
| ( |
| message, |
| data, |
| Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), |
| ) |
| } |
| NotSubmittedReason::EmptyInput => ( |
| "input must not be empty".to_string(), |
| None, |
| Some(AnalyticsJsonRpcError::Input(InputError::Empty)), |
| ), |
| NotSubmittedReason::ActiveTurnOutputSchemaMismatch => ( |
| "active turn uses a different output schema".to_string(), |
| None, |
| None, |
| ), |
| NotSubmittedReason::PendingTriggerTurn |
| | NotSubmittedReason::PlanMode |
| | NotSubmittedReason::Superseded => ( |
| "no active turn to steer".to_string(), |
| None, |
| Some(AnalyticsJsonRpcError::TurnSteer( |
| TurnSteerRequestError::NoActiveTurn, |
| )), |
| ), |
| }; |
| let mut error = invalid_request(message); |
| error.data = data; |
| self.track_error_response(request_id, &error, error_type); |
| return Err(error); |
| } |
| }; |
| Ok(TurnSteerResponse { turn_id }) |
| } |
|
|
| async fn prepare_realtime_conversation_thread( |
| &self, |
| request_id: &ConnectionRequestId, |
| thread_id: &str, |
| ) -> Result<Option<(ThreadId, Arc<CodexThread>)>, JSONRPCErrorError> { |
| let (thread_id, thread) = self.load_thread(thread_id).await?; |
| self.ensure_direct_input_allowed(request_id, thread.as_ref()) |
| .await?; |
|
|
| match self |
| .ensure_conversation_listener( |
| thread_id, |
| request_id.connection_id, |
| false, |
| ) |
| .await |
| { |
| Ok(EnsureConversationListenerResult::Attached) => {} |
| Ok(EnsureConversationListenerResult::ConnectionClosed) => { |
| return Ok(None); |
| } |
| Err(error) => return Err(error), |
| } |
|
|
| Ok(Some((thread_id, thread))) |
| } |
|
|
| async fn thread_realtime_start_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeStartParams, |
| ) -> Result<Option<ThreadRealtimeStartResponse>, JSONRPCErrorError> { |
| let attaches_existing_call = matches!( |
| ¶ms.transport, |
| Some(ThreadRealtimeStartTransport::ExistingCall { .. }) |
| ); |
| if attaches_existing_call { |
| let unsupported_option = if params.include_startup_context == Some(true) { |
| Some("includeStartupContext") |
| } else if params.prompt.is_some() { |
| Some("prompt") |
| } else if params |
| .initial_items |
| .as_ref() |
| .is_some_and(|items| !items.is_empty()) |
| { |
| Some("initialItems") |
| } else if params.model.is_some() { |
| Some("model") |
| } else if params.voice.is_some() { |
| Some("voice") |
| } else if params.delegation_ack_filler.is_some() { |
| Some("delegationAckFiller") |
| } else { |
| None |
| }; |
| if let Some(option) = unsupported_option { |
| return Err(invalid_request(format!( |
| "existingCall transport does not support {option}" |
| ))); |
| } |
| } |
| let Some((_, thread)) = self |
| .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) |
| .await? |
| else { |
| return Ok(None); |
| }; |
| self.submit_core_op( |
| request_id, |
| thread.as_ref(), |
| Op::RealtimeConversationStart(ConversationStartParams { |
| client_managed_handoffs: params.client_managed_handoffs.unwrap_or(false), |
| delegation_ack_filler: params.delegation_ack_filler, |
| flush_transcript_tail_on_session_end: params |
| .flush_transcript_tail_on_session_end |
| .unwrap_or(false), |
| codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false), |
| codex_response_item_prefix: params.codex_response_item_prefix, |
| codex_response_handoff_mode: params.codex_response_handoff_mode.unwrap_or_default(), |
| codex_response_handoff_channel_prefixes: params |
| .codex_response_handoff_channel_prefixes, |
| model: params.model, |
| output_modality: params.output_modality, |
| include_startup_context: params |
| .include_startup_context |
| .unwrap_or(!attaches_existing_call), |
| initial_items: params |
| .initial_items |
| .unwrap_or_default() |
| .into_iter() |
| .map(|item| ConversationTextParams { |
| text: item.text, |
| role: item.role, |
| }) |
| .collect(), |
| realtime_start_instructions: params.realtime_start_instructions, |
| realtime_end_instructions: params.realtime_end_instructions, |
| prompt: params.prompt, |
| realtime_session_id: params.realtime_session_id, |
| transport: params.transport.map(|transport| match transport { |
| ThreadRealtimeStartTransport::Websocket => { |
| ConversationStartTransport::Websocket |
| } |
| ThreadRealtimeStartTransport::Webrtc { sdp } => { |
| ConversationStartTransport::Webrtc { sdp } |
| } |
| ThreadRealtimeStartTransport::ExistingCall { call_id } => { |
| ConversationStartTransport::ExistingCall { |
| call_id, |
| sideband_base_url: None, |
| } |
| } |
| }), |
| version: params.version, |
| voice: params.voice, |
| }), |
| ) |
| .await |
| .map_err(|err| internal_error(format!("failed to start realtime conversation: {err}")))?; |
| Ok(Some(ThreadRealtimeStartResponse::default())) |
| } |
|
|
| async fn thread_realtime_append_audio_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeAppendAudioParams, |
| ) -> Result<Option<ThreadRealtimeAppendAudioResponse>, JSONRPCErrorError> { |
| let Some((_, thread)) = self |
| .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) |
| .await? |
| else { |
| return Ok(None); |
| }; |
| self.submit_core_op( |
| request_id, |
| thread.as_ref(), |
| Op::RealtimeConversationAudio(ConversationAudioParams { |
| frame: params.audio.into(), |
| }), |
| ) |
| .await |
| .map_err(|err| { |
| internal_error(format!( |
| "failed to append realtime conversation audio: {err}" |
| )) |
| })?; |
| Ok(Some(ThreadRealtimeAppendAudioResponse::default())) |
| } |
|
|
| async fn thread_realtime_append_text_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeAppendTextParams, |
| ) -> Result<Option<ThreadRealtimeAppendTextResponse>, JSONRPCErrorError> { |
| let Some((_, thread)) = self |
| .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) |
| .await? |
| else { |
| return Ok(None); |
| }; |
| self.submit_core_op( |
| request_id, |
| thread.as_ref(), |
| Op::RealtimeConversationText(ConversationTextParams { |
| text: params.text, |
| role: params.role, |
| }), |
| ) |
| .await |
| .map_err(|err| { |
| internal_error(format!( |
| "failed to append realtime conversation text: {err}" |
| )) |
| })?; |
| Ok(Some(ThreadRealtimeAppendTextResponse::default())) |
| } |
|
|
| async fn thread_realtime_append_speech_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeAppendSpeechParams, |
| ) -> Result<Option<ThreadRealtimeAppendSpeechResponse>, JSONRPCErrorError> { |
| let Some((_, thread)) = self |
| .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) |
| .await? |
| else { |
| return Ok(None); |
| }; |
| self.submit_core_op( |
| request_id, |
| thread.as_ref(), |
| Op::RealtimeConversationSpeech(ConversationSpeechParams { text: params.text }), |
| ) |
| .await |
| .map_err(|err| { |
| internal_error(format!( |
| "failed to append realtime conversation speech: {err}" |
| )) |
| })?; |
| Ok(Some(ThreadRealtimeAppendSpeechResponse::default())) |
| } |
|
|
| async fn thread_realtime_stop_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ThreadRealtimeStopParams, |
| ) -> Result<Option<ThreadRealtimeStopResponse>, JSONRPCErrorError> { |
| let Some((_, thread)) = self |
| .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) |
| .await? |
| else { |
| return Ok(None); |
| }; |
| self.submit_core_op(request_id, thread.as_ref(), Op::RealtimeConversationClose) |
| .await |
| .map_err(|err| { |
| internal_error(format!("failed to stop realtime conversation: {err}")) |
| })?; |
| Ok(Some(ThreadRealtimeStopResponse::default())) |
| } |
|
|
| fn build_review_turn(turn_id: String, display_text: &str) -> Turn { |
| let items = if display_text.is_empty() { |
| Vec::new() |
| } else { |
| vec![ThreadItem::UserMessage { |
| id: turn_id.clone(), |
| client_id: None, |
| content: vec![V2UserInput::Text { |
| text: display_text.to_string(), |
| |
| text_elements: Vec::new(), |
| }], |
| }] |
| }; |
|
|
| Turn { |
| id: turn_id, |
| items, |
| items_view: TurnItemsView::NotLoaded, |
| error: None, |
| status: TurnStatus::InProgress, |
| started_at: None, |
| completed_at: None, |
| duration_ms: None, |
| } |
| } |
|
|
| async fn emit_review_started( |
| &self, |
| request_id: &ConnectionRequestId, |
| turn: Turn, |
| review_thread_id: String, |
| ) { |
| let response = ReviewStartResponse { |
| turn, |
| review_thread_id, |
| }; |
| self.outgoing |
| .send_response(request_id.clone(), response) |
| .await; |
| } |
|
|
| async fn start_inline_review( |
| &self, |
| request_id: &ConnectionRequestId, |
| parent_thread: Arc<CodexThread>, |
| review_request: ReviewRequest, |
| display_text: &str, |
| parent_thread_id: String, |
| ) -> std::result::Result<(), JSONRPCErrorError> { |
| let turn_id = self |
| .submit_core_op( |
| request_id, |
| parent_thread.as_ref(), |
| Op::Review { review_request }, |
| ) |
| .await |
| .map_err(|err| internal_error(format!("failed to start review: {err}")))?; |
| let turn = Self::build_review_turn(turn_id, display_text); |
| self.emit_review_started(request_id, turn, parent_thread_id) |
| .await; |
| Ok(()) |
| } |
|
|
| async fn start_detached_review( |
| &self, |
| request_id: &ConnectionRequestId, |
| parent_thread: Arc<CodexThread>, |
| prompt: &str, |
| ) -> std::result::Result<(), JSONRPCErrorError> { |
| |
| |
| |
| if matches!( |
| parent_thread.config_snapshot().await.history_mode, |
| codex_protocol::protocol::ThreadHistoryMode::Paginated |
| ) { |
| return Err(invalid_request( |
| "paginated threads do not support detached review", |
| )); |
| } |
| let mut config = parent_thread.config().await.as_ref().clone(); |
| if let Some(review_model) = &config.review_model { |
| config.model = Some(review_model.clone()); |
| } |
|
|
| let AgentRun { |
| thread_id, |
| thread: review_thread, |
| turn_id, |
| } = self |
| .agent_runner |
| .start( |
| parent_thread.session_configured().thread_id, |
| AgentInvocation { |
| config, |
| prompt: prompt.to_string(), |
| parent_trace: self.request_trace_context(request_id).await, |
| }, |
| ) |
| .await |
| .map_err(|err| internal_error(format!("failed to start detached review: {err}")))?; |
|
|
| let fallback_provider = self.config.model_provider_id.as_str(); |
| let stored_thread = match review_thread |
| .read_thread( |
| true, false, |
| ) |
| .await |
| { |
| Ok(stored_thread) => { |
| let (thread, _) = |
| thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); |
| Some(thread) |
| } |
| Err(err) => { |
| tracing::warn!("failed to load summary for review thread {thread_id}: {err}"); |
| None |
| } |
| }; |
|
|
| if let Some(mut thread) = stored_thread { |
| let config_snapshot = review_thread.config_snapshot().await; |
| apply_live_thread_settings(&mut thread, &config_snapshot); |
| thread.session_id = review_thread.session_configured().session_id.to_string(); |
| self.thread_watch_manager |
| .upsert_thread_silently(&thread.id) |
| .await; |
| thread.status = resolve_thread_status( |
| self.thread_watch_manager |
| .loaded_status_for_thread(&thread.id) |
| .await, |
| false, |
| ); |
| let notif = thread_started_notification(thread); |
| self.outgoing |
| .send_server_notification(ServerNotification::ThreadStarted(notif)) |
| .await; |
| } |
|
|
| log_listener_attach_result( |
| self.ensure_conversation_listener( |
| thread_id, |
| request_id.connection_id, |
| false, |
| ) |
| .await, |
| thread_id, |
| request_id.connection_id, |
| "review thread", |
| ); |
|
|
| let turn = Self::build_review_turn(turn_id, prompt); |
| let review_thread_id = thread_id.to_string(); |
| self.emit_review_started(request_id, turn, review_thread_id) |
| .await; |
|
|
| Ok(()) |
| } |
|
|
| async fn review_start_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: ReviewStartParams, |
| ) -> Result<(), JSONRPCErrorError> { |
| let ReviewStartParams { |
| thread_id, |
| target, |
| delivery, |
| } = params; |
|
|
| let (_, parent_thread) = self.load_thread(&thread_id).await?; |
| self.ensure_direct_input_allowed(request_id, parent_thread.as_ref()) |
| .await?; |
| self.config_manager |
| .check_thread_model_provider(parent_thread.config().await.as_ref()) |
| .await |
| .map_err(|error| config_load_error(&error))?; |
| let (review_request, display_text, target_prompt) = |
| Self::review_request_from_target(target)?; |
| match delivery.unwrap_or(ApiReviewDelivery::Inline).to_core() { |
| CoreReviewDelivery::Inline => { |
| self.start_inline_review( |
| request_id, |
| parent_thread, |
| review_request, |
| &display_text, |
| thread_id, |
| ) |
| .await?; |
| } |
| CoreReviewDelivery::Detached => { |
| let review_skill_path = system_cache_root_dir(&self.config.codex_home) |
| .join("review-agent") |
| .join("SKILL.md"); |
| let prompt = format!( |
| "Use [$review-agent]({}) for this review.\n\n{target_prompt}", |
| review_skill_path.display() |
| ); |
| let actual_chars = prompt.chars().count(); |
| if actual_chars > MAX_USER_INPUT_TEXT_CHARS { |
| return Err(Self::input_too_large_error(actual_chars)); |
| } |
| self.start_detached_review(request_id, parent_thread, &prompt) |
| .await?; |
| } |
| } |
| Ok(()) |
| } |
|
|
| async fn turn_interrupt_inner( |
| &self, |
| request_id: &ConnectionRequestId, |
| params: TurnInterruptParams, |
| ) -> Result<Option<TurnInterruptResponse>, JSONRPCErrorError> { |
| let TurnInterruptParams { thread_id, turn_id } = params; |
| let is_startup_interrupt = turn_id.is_empty(); |
|
|
| let (thread_uuid, thread) = self.load_thread(&thread_id).await?; |
|
|
| |
| |
| if !is_startup_interrupt { |
| let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; |
| let is_running = matches!(thread.agent_status().await, AgentStatus::Running); |
| { |
| let mut thread_state = thread_state.lock().await; |
| if let Some(active_turn) = thread_state.active_turn_snapshot() { |
| if active_turn.id != turn_id { |
| return Err(invalid_request(format!( |
| "expected active turn id {turn_id} but found {}", |
| active_turn.id |
| ))); |
| } |
| } else if thread_state.last_terminal_turn_id.as_deref() == Some(turn_id.as_str()) |
| || !is_running |
| { |
| return Err(invalid_request("no active turn to interrupt")); |
| } |
| thread_state.pending_interrupts.push(request_id.clone()); |
| } |
|
|
| self.outgoing |
| .record_request_turn_id(request_id, &turn_id) |
| .await; |
| } |
|
|
| |
| |
| match self |
| .submit_core_op(request_id, thread.as_ref(), Op::Interrupt) |
| .await |
| { |
| Ok(_) if is_startup_interrupt => Ok(Some(TurnInterruptResponse {})), |
| Ok(_) => Ok(None), |
| Err(err) => { |
| if !is_startup_interrupt { |
| let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; |
| let mut thread_state = thread_state.lock().await; |
| thread_state |
| .pending_interrupts |
| .retain(|pending_request_id| pending_request_id != request_id); |
| } |
| let interrupt_target = if is_startup_interrupt { |
| "startup" |
| } else { |
| "turn" |
| }; |
| Err(internal_error(format!( |
| "failed to interrupt {interrupt_target}: {err}" |
| ))) |
| } |
| } |
| } |
|
|
| fn listener_task_context(&self) -> ListenerTaskContext { |
| ListenerTaskContext { |
| thread_manager: Arc::clone(&self.thread_manager), |
| thread_state_manager: self.thread_state_manager.clone(), |
| outgoing: Arc::clone(&self.outgoing), |
| pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), |
| thread_watch_manager: self.thread_watch_manager.clone(), |
| codex_home: self.config.codex_home.to_path_buf(), |
| thread_unload_delay: self.config.thread_unload_delay, |
| skills_watcher: Arc::clone(&self.skills_watcher), |
| turn_cost_worker: self.turn_cost_worker.clone(), |
| } |
| } |
|
|
| async fn ensure_conversation_listener( |
| &self, |
| conversation_id: ThreadId, |
| connection_id: ConnectionId, |
| raw_events_enabled: bool, |
| ) -> Result<EnsureConversationListenerResult, JSONRPCErrorError> { |
| super::thread_lifecycle::ensure_conversation_listener( |
| self.listener_task_context(), |
| conversation_id, |
| connection_id, |
| raw_events_enabled, |
| ) |
| .await |
| } |
| } |
|
|
| fn xcode_26_4_mcp_elicitations_auto_deny( |
| client_name: Option<&str>, |
| client_version: Option<&str>, |
| ) -> bool { |
| |
| |
| |
| client_name == Some("Xcode") |
| && client_version.is_some_and(|version| version.starts_with("26.4")) |
| } |
|
|