Download codex-rs/app-server/src/outgoing_message.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 59 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/app-server/src/outgoing_message.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/app-server/src/outgoing_message.rs
-
curl -L -o outgoing_message.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/app-server/src/outgoing_message.rs
59 kB
| use std::collections::HashMap; | |
| use std::collections::HashSet; | |
| use std::sync::Arc; | |
| use std::sync::OnceLock; | |
| use std::sync::atomic::AtomicI64; | |
| use std::sync::atomic::Ordering; | |
| use std::time::SystemTime; | |
| use std::time::UNIX_EPOCH; | |
| use codex_analytics::AnalyticsEventsClient; | |
| use codex_app_server_protocol::ClientResponsePayload; | |
| use codex_app_server_protocol::JSONRPCErrorError; | |
| use codex_app_server_protocol::RequestId; | |
| use codex_app_server_protocol::Result; | |
| use codex_app_server_protocol::ServerNotification; | |
| use codex_app_server_protocol::ServerNotificationEnvelope; | |
| use codex_app_server_protocol::ServerRequest; | |
| use codex_app_server_protocol::ServerRequestPayload; | |
| use codex_app_server_protocol::ServerResponse; | |
| use codex_diagnostics::Gauge; | |
| use codex_diagnostics::GaugeGuard; | |
| use codex_otel::span_w3c_trace_context; | |
| use codex_protocol::ThreadId; | |
| use codex_protocol::protocol::W3cTraceContext; | |
| use codex_protocol::request_permissions::RequestPermissionsResponse; | |
| use tokio::sync::Mutex; | |
| use tokio::sync::mpsc; | |
| use tokio::sync::oneshot; | |
| use tracing::Instrument; | |
| use tracing::Span; | |
| use tracing::warn; | |
| use crate::error_code::internal_error; | |
| use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON; | |
| pub(crate) use codex_app_server_transport::ConnectionId; | |
| pub(crate) use codex_app_server_transport::OutgoingError; | |
| pub(crate) use codex_app_server_transport::OutgoingMessage; | |
| pub(crate) use codex_app_server_transport::OutgoingResponse; | |
| pub(crate) use codex_app_server_transport::QueuedOutgoingMessage; | |
| use codex_protocol::account::PlanType; | |
| pub(crate) type ClientRequestResult = std::result::Result<Result, JSONRPCErrorError>; | |
| static IN_FLIGHT_REQUESTS: Gauge = Gauge::new("app.requests.in_flight"); | |
| static PENDING_SERVER_REQUESTS: Gauge = Gauge::new("app.server_requests.pending"); | |
| mod account_notifications; | |
| pub(crate) use account_notifications::AccountNotification; | |
| mod user_verification_auth; | |
| /// Stable identifier for a client request scoped to a transport connection. | |
| pub(crate) struct ConnectionRequestId { | |
| pub(crate) connection_id: ConnectionId, | |
| pub(crate) request_id: RequestId, | |
| } | |
| /// Trace data and cancellation state retained until an incoming request's final response or error. | |
| pub(crate) struct RequestContext { | |
| request_id: ConnectionRequestId, | |
| pub(crate) cancellation: tokio_util::sync::CancellationToken, | |
| cancellation_scope: RequestCancellationScope, | |
| span: Span, | |
| parent_trace: Option<W3cTraceContext>, | |
| _diagnostics_guard: Arc<GaugeGuard>, | |
| } | |
| enum RequestCancellationScope { | |
| Unavailable, | |
| UserVerification, | |
| } | |
| impl RequestContext { | |
| pub(crate) fn new( | |
| request_id: ConnectionRequestId, | |
| method: &str, | |
| span: Span, | |
| parent_trace: Option<W3cTraceContext>, | |
| ) -> Self { | |
| Self { | |
| request_id, | |
| cancellation: tokio_util::sync::CancellationToken::new(), | |
| cancellation_scope: match method { | |
| "userVerification/status" | |
| | "userVerification/enroll" | |
| | "userVerification/delete" | |
| | "userVerification/verify" => RequestCancellationScope::UserVerification, | |
| _ => RequestCancellationScope::Unavailable, | |
| }, | |
| span, | |
| parent_trace, | |
| _diagnostics_guard: Arc::new(IN_FLIGHT_REQUESTS.track()), | |
| } | |
| } | |
| pub(crate) fn request_trace(&self) -> Option<W3cTraceContext> { | |
| span_w3c_trace_context(&self.span).or_else(|| self.parent_trace.clone()) | |
| } | |
| pub(crate) fn span(&self) -> Span { | |
| self.span.clone() | |
| } | |
| fn record_turn_id(&self, turn_id: &str) { | |
| self.span.record("turn.id", turn_id); | |
| } | |
| } | |
| pub(crate) enum OutgoingEnvelope { | |
| ToConnection { | |
| connection_id: ConnectionId, | |
| message: OutgoingMessage, | |
| write_complete_tx: Option<oneshot::Sender<()>>, | |
| }, | |
| Broadcast { | |
| message: OutgoingMessage, | |
| }, | |
| } | |
| /// Sends messages to the client and manages request callbacks. | |
| pub(crate) struct OutgoingMessageSender { | |
| verification_auth: OnceLock<Arc<codex_login::AuthManager>>, | |
| verification_connections: Mutex<HashSet<ConnectionId>>, | |
| next_server_request_id: AtomicI64, | |
| sender: mpsc::Sender<OutgoingEnvelope>, | |
| request_id_to_callback: Mutex<HashMap<RequestId, PendingCallbackEntry>>, | |
| /// Incoming requests that are still waiting on a final response or error. | |
| /// We keep them here because this is where responses, errors, and | |
| /// disconnect cleanup all get handled. | |
| request_contexts: Mutex<HashMap<ConnectionRequestId, RequestContext>>, | |
| analytics_events_client: AnalyticsEventsClient, | |
| } | |
| pub(crate) struct ThreadScopedOutgoingMessageSender { | |
| outgoing: Arc<OutgoingMessageSender>, | |
| connection_ids: Arc<Vec<ConnectionId>>, | |
| thread_id: ThreadId, | |
| } | |
| struct PendingCallbackEntry { | |
| verification_owner: Option<ConnectionId>, | |
| verification_auth_revision: Option<u64>, | |
| verification_identity: Option<user_verification_auth::Identity>, | |
| callback: oneshot::Sender<ClientRequestResult>, | |
| thread_id: Option<ThreadId>, | |
| request: ServerRequest, | |
| _diagnostics_guard: GaugeGuard, | |
| } | |
| impl ThreadScopedOutgoingMessageSender { | |
| pub(crate) fn new( | |
| outgoing: Arc<OutgoingMessageSender>, | |
| connection_ids: Vec<ConnectionId>, | |
| thread_id: ThreadId, | |
| ) -> Self { | |
| Self { | |
| outgoing, | |
| connection_ids: Arc::new(connection_ids), | |
| thread_id, | |
| } | |
| } | |
| pub(crate) async fn send_request( | |
| &self, | |
| payload: ServerRequestPayload, | |
| ) -> (RequestId, oneshot::Receiver<ClientRequestResult>) { | |
| self.outgoing | |
| .send_request_to_connections( | |
| Some(self.connection_ids.as_slice()), | |
| payload, | |
| Some(self.thread_id), | |
| ) | |
| .await | |
| } | |
| pub(crate) fn track_effective_permissions_approval_response( | |
| &self, | |
| request_id: RequestId, | |
| response: RequestPermissionsResponse, | |
| ) { | |
| self.outgoing | |
| .analytics_events_client | |
| .track_effective_permissions_approval_response( | |
| now_unix_timestamp_ms(), | |
| request_id, | |
| response, | |
| ); | |
| } | |
| pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { | |
| self.outgoing | |
| .analytics_events_client | |
| .track_notification(¬ification); | |
| if self.connection_ids.is_empty() { | |
| return; | |
| } | |
| self.outgoing | |
| .send_server_notification_to_connections(self.connection_ids.as_slice(), notification) | |
| .await; | |
| } | |
| pub(crate) async fn send_global_server_notification(&self, notification: ServerNotification) { | |
| self.outgoing.send_server_notification(notification).await; | |
| } | |
| pub(crate) async fn abort_pending_server_requests(&self) { | |
| self.outgoing | |
| .cancel_requests_for_thread( | |
| self.thread_id, | |
| Some({ | |
| let mut error = internal_error( | |
| "client request resolved because the turn state was changed", | |
| ); | |
| error.data = Some(serde_json::json!({ | |
| "reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON, | |
| })); | |
| error | |
| }), | |
| ) | |
| .await | |
| } | |
| pub(crate) async fn send_response<T>(&self, request_id: ConnectionRequestId, response: T) | |
| where | |
| T: Into<ClientResponsePayload>, | |
| { | |
| self.outgoing.send_response(request_id, response).await; | |
| } | |
| } | |
| impl OutgoingMessageSender { | |
| pub(crate) fn new( | |
| sender: mpsc::Sender<OutgoingEnvelope>, | |
| analytics_events_client: AnalyticsEventsClient, | |
| ) -> Self { | |
| Self { | |
| verification_auth: OnceLock::new(), | |
| verification_connections: Mutex::new(HashSet::new()), | |
| next_server_request_id: AtomicI64::new(0), | |
| sender, | |
| request_id_to_callback: Mutex::new(HashMap::new()), | |
| request_contexts: Mutex::new(HashMap::new()), | |
| analytics_events_client, | |
| } | |
| } | |
| pub(crate) async fn register_request_context(&self, request_context: RequestContext) { | |
| let mut request_contexts = self.request_contexts.lock().await; | |
| if request_contexts | |
| .insert(request_context.request_id.clone(), request_context) | |
| .is_some() | |
| { | |
| warn!("replaced unresolved request context"); | |
| } | |
| } | |
| pub(crate) async fn cancel_user_verification_request(&self, request_id: &ConnectionRequestId) { | |
| let contexts = self.request_contexts.lock().await; | |
| if let Some(context) = contexts.get(request_id) | |
| && context.cancellation_scope == RequestCancellationScope::UserVerification | |
| { | |
| context.cancellation.cancel(); | |
| } | |
| } | |
| pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { | |
| self.disconnect_user_verification_connection(connection_id) | |
| .await; | |
| let mut request_contexts = self.request_contexts.lock().await; | |
| request_contexts.retain(|request_id, _| request_id.connection_id != connection_id); | |
| } | |
| pub(crate) async fn request_trace_context( | |
| &self, | |
| request_id: &ConnectionRequestId, | |
| ) -> Option<W3cTraceContext> { | |
| let request_contexts = self.request_contexts.lock().await; | |
| request_contexts | |
| .get(request_id) | |
| .and_then(RequestContext::request_trace) | |
| } | |
| pub(crate) async fn record_request_turn_id( | |
| &self, | |
| request_id: &ConnectionRequestId, | |
| turn_id: &str, | |
| ) { | |
| let request_contexts = self.request_contexts.lock().await; | |
| if let Some(request_context) = request_contexts.get(request_id) { | |
| request_context.record_turn_id(turn_id); | |
| } | |
| } | |
| async fn take_request_context( | |
| &self, | |
| request_id: &ConnectionRequestId, | |
| ) -> Option<RequestContext> { | |
| let mut request_contexts = self.request_contexts.lock().await; | |
| request_contexts.remove(request_id) | |
| } | |
| async fn request_context_count(&self) -> usize { | |
| self.request_contexts.lock().await.len() | |
| } | |
| pub(crate) async fn send_request( | |
| &self, | |
| request: ServerRequestPayload, | |
| ) -> (RequestId, oneshot::Receiver<ClientRequestResult>) { | |
| self.send_request_to_connections( | |
| /*connection_ids*/ None, request, /*thread_id*/ None, | |
| ) | |
| .await | |
| } | |
| fn next_request_id(&self) -> RequestId { | |
| RequestId::Integer(self.next_server_request_id.fetch_add(1, Ordering::Relaxed)) | |
| } | |
| pub(crate) async fn send_request_to_connections( | |
| &self, | |
| connection_ids: Option<&[ConnectionId]>, | |
| request: ServerRequestPayload, | |
| thread_id: Option<ThreadId>, | |
| ) -> (RequestId, oneshot::Receiver<ClientRequestResult>) { | |
| let id = self.next_request_id(); | |
| let outgoing_message_id = id.clone(); | |
| let request = request.request_with_id(outgoing_message_id.clone()); | |
| let user_verification = matches!( | |
| &request, | |
| ServerRequest::McpServerElicitationRequest { params, .. } | |
| if matches!(¶ms.request, codex_app_server_protocol::McpServerElicitationRequest::UserVerification { .. }) | |
| ); | |
| // Snapshot before waiting on eligibility or callback locks. A request cannot inherit | |
| // whichever account happens to be current after an unrelated operation releases a lock. | |
| let verification_auth_revision = user_verification | |
| .then(|| self.verification_auth_revision()) | |
| .flatten(); | |
| let verification_identity = user_verification | |
| .then(|| self.verification_identity()) | |
| .flatten(); | |
| let auth_changed = || { | |
| user_verification | |
| && (verification_auth_revision != self.verification_auth_revision() | |
| || verification_identity != self.verification_identity()) | |
| }; | |
| let (tx_approve, rx_approve) = oneshot::channel(); | |
| // One app owns this ceremony. Reconnect and other subscribers cannot answer it. | |
| let verification_owner = if user_verification { | |
| let eligible = self.verification_connections.lock().await; | |
| connection_ids | |
| .and_then(|ids| ids.iter().find(|id| eligible.contains(*id))) | |
| .copied() | |
| } else { | |
| None | |
| }; | |
| if user_verification && (verification_owner.is_none() || auth_changed()) { | |
| return (outgoing_message_id, rx_approve); | |
| } | |
| let connection_ids = if user_verification { | |
| verification_owner.as_ref().map(std::slice::from_ref) | |
| } else { | |
| connection_ids | |
| }; | |
| { | |
| let mut request_id_to_callback = self.request_id_to_callback.lock().await; | |
| request_id_to_callback.insert( | |
| id, | |
| PendingCallbackEntry { | |
| verification_owner, | |
| verification_auth_revision, | |
| verification_identity: verification_identity.clone(), | |
| callback: tx_approve, | |
| thread_id, | |
| request: request.clone(), | |
| _diagnostics_guard: PENDING_SERVER_REQUESTS.track(), | |
| }, | |
| ); | |
| } | |
| // Disconnect may finish its callback cleanup before registration acquires the lock. | |
| // Recheck afterward so that ordering cannot leave an orphaned verification callback. | |
| if let Some(owner) = verification_owner { | |
| let eligible = self.verification_connections.lock().await.contains(&owner); | |
| if !eligible || auth_changed() { | |
| self.request_id_to_callback | |
| .lock() | |
| .await | |
| .remove(&outgoing_message_id); | |
| return (outgoing_message_id, rx_approve); | |
| } | |
| } | |
| let outgoing_message = OutgoingMessage::Request(request.clone()); | |
| let send_result = match connection_ids { | |
| None => { | |
| self.sender | |
| .send(OutgoingEnvelope::Broadcast { | |
| message: outgoing_message, | |
| }) | |
| .await | |
| } | |
| Some(connection_ids) => { | |
| let mut send_error = None; | |
| for connection_id in connection_ids { | |
| if let Err(err) = self | |
| .sender | |
| .send(OutgoingEnvelope::ToConnection { | |
| connection_id: *connection_id, | |
| message: outgoing_message.clone(), | |
| write_complete_tx: None, | |
| }) | |
| .await | |
| { | |
| send_error = Some(err); | |
| break; | |
| } else { | |
| self.analytics_events_client | |
| .track_server_request(connection_id.0, request.clone()); | |
| } | |
| } | |
| match send_error { | |
| Some(err) => Err(err), | |
| None => Ok(()), | |
| } | |
| } | |
| }; | |
| if let Err(err) = send_result { | |
| warn!("failed to send request {outgoing_message_id:?} to client: {err:?}"); | |
| let mut request_id_to_callback = self.request_id_to_callback.lock().await; | |
| request_id_to_callback.remove(&outgoing_message_id); | |
| } | |
| (outgoing_message_id, rx_approve) | |
| } | |
| pub(crate) async fn replay_requests_to_connection_for_thread( | |
| &self, | |
| connection_id: ConnectionId, | |
| thread_id: ThreadId, | |
| ) { | |
| let requests = self.pending_requests_for_thread(thread_id).await; | |
| for request in requests { | |
| if let Err(err) = self | |
| .sender | |
| .send(OutgoingEnvelope::ToConnection { | |
| connection_id, | |
| message: OutgoingMessage::Request(request), | |
| write_complete_tx: None, | |
| }) | |
| .await | |
| { | |
| warn!("failed to resend request to client: {err:?}"); | |
| } | |
| } | |
| } | |
| pub(crate) async fn notify_client_response( | |
| &self, | |
| connection_id: ConnectionId, | |
| id: RequestId, | |
| result: Result, | |
| ) { | |
| let entry = self.take_connection_callback(connection_id, &id).await; | |
| match entry { | |
| Some((id, entry)) => { | |
| let completed_at_ms = now_unix_timestamp_ms(); | |
| if entry.verification_owner.is_none() | |
| && let Ok(response) = entry.request.response_from_result(result.clone()) | |
| { | |
| tracing::info!("<- response: {response:?}"); | |
| if !matches!(response, ServerResponse::PermissionsRequestApproval { .. }) { | |
| self.analytics_events_client | |
| .track_server_response(completed_at_ms, response); | |
| } | |
| } | |
| if entry.callback.send(Ok(result)).is_err() { | |
| warn!("could not notify callback for {id:?}: receiver dropped"); | |
| } | |
| } | |
| None => { | |
| warn!("could not find callback for {id:?}"); | |
| } | |
| } | |
| } | |
| pub(crate) async fn notify_client_error( | |
| &self, | |
| connection_id: ConnectionId, | |
| id: RequestId, | |
| error: JSONRPCErrorError, | |
| ) { | |
| let entry = self.take_connection_callback(connection_id, &id).await; | |
| match entry { | |
| Some((id, entry)) => { | |
| // Don't log error messages or data because they may contain credentials. | |
| warn!(code = error.code, "client responded with error for {id:?}"); | |
| self.analytics_events_client | |
| .track_server_request_aborted(now_unix_timestamp_ms(), id.clone()); | |
| if entry.callback.send(Err(error)).is_err() { | |
| warn!("could not notify callback for {id:?}: receiver dropped"); | |
| } | |
| } | |
| None => { | |
| warn!("could not find callback for {id:?}"); | |
| } | |
| } | |
| } | |
| pub(crate) async fn cancel_request(&self, id: &RequestId) -> bool { | |
| let entry = self.take_request_callback(id).await; | |
| if let Some((request_id, _entry)) = entry { | |
| self.analytics_events_client | |
| .track_server_request_aborted(now_unix_timestamp_ms(), request_id); | |
| true | |
| } else { | |
| false | |
| } | |
| } | |
| pub(crate) async fn cancel_all_requests(&self, error: Option<JSONRPCErrorError>) { | |
| let entries = { | |
| let mut request_id_to_callback = self.request_id_to_callback.lock().await; | |
| request_id_to_callback | |
| .drain() | |
| .map(|(_, entry)| entry) | |
| .collect::<Vec<_>>() | |
| }; | |
| for entry in entries { | |
| self.analytics_events_client | |
| .track_server_request_aborted(now_unix_timestamp_ms(), entry.request.id().clone()); | |
| if let Some(error) = error.as_ref() | |
| && entry.callback.send(Err(error.clone())).is_err() | |
| { | |
| let request_id = entry.request.id(); | |
| warn!("could not notify callback for {request_id:?}: receiver dropped"); | |
| } | |
| } | |
| } | |
| async fn take_request_callback( | |
| &self, | |
| id: &RequestId, | |
| ) -> Option<(RequestId, PendingCallbackEntry)> { | |
| let mut request_id_to_callback = self.request_id_to_callback.lock().await; | |
| request_id_to_callback.remove_entry(id) | |
| } | |
| async fn take_connection_callback( | |
| &self, | |
| connection_id: ConnectionId, | |
| id: &RequestId, | |
| ) -> Option<(RequestId, PendingCallbackEntry)> { | |
| let mut callbacks = self.request_id_to_callback.lock().await; | |
| let entry = callbacks.get(id)?; | |
| if let Some(owner) = entry.verification_owner { | |
| if owner != connection_id { | |
| return None; | |
| } | |
| if entry.verification_identity != self.verification_identity() | |
| || entry.verification_auth_revision != self.verification_auth_revision() | |
| { | |
| callbacks.remove(id); | |
| return None; | |
| } | |
| } | |
| callbacks.remove_entry(id) | |
| } | |
| pub(crate) async fn pending_requests_for_thread( | |
| &self, | |
| thread_id: ThreadId, | |
| ) -> Vec<ServerRequest> { | |
| let request_id_to_callback = self.request_id_to_callback.lock().await; | |
| let mut requests = request_id_to_callback | |
| .values() | |
| .filter_map(|entry| { | |
| (entry.thread_id == Some(thread_id) && entry.verification_owner.is_none()) | |
| .then_some(entry.request.clone()) | |
| }) | |
| .collect::<Vec<_>>(); | |
| requests.sort_by(|left, right| left.id().cmp(right.id())); | |
| requests | |
| } | |
| pub(crate) async fn cancel_requests_for_thread( | |
| &self, | |
| thread_id: ThreadId, | |
| error: Option<JSONRPCErrorError>, | |
| ) { | |
| let entries = { | |
| let mut request_id_to_callback = self.request_id_to_callback.lock().await; | |
| let request_ids = request_id_to_callback | |
| .iter() | |
| .filter_map(|(request_id, entry)| { | |
| (entry.thread_id == Some(thread_id)).then_some(request_id.clone()) | |
| }) | |
| .collect::<Vec<_>>(); | |
| let mut entries = Vec::with_capacity(request_ids.len()); | |
| for request_id in request_ids { | |
| if let Some(entry) = request_id_to_callback.remove(&request_id) { | |
| entries.push(entry); | |
| } | |
| } | |
| entries | |
| }; | |
| for entry in entries { | |
| self.analytics_events_client | |
| .track_server_request_aborted(now_unix_timestamp_ms(), entry.request.id().clone()); | |
| if let Some(error) = error.as_ref() | |
| && entry.callback.send(Err(error.clone())).is_err() | |
| { | |
| let request_id = entry.request.id(); | |
| warn!("could not notify callback for {request_id:?}: receiver dropped"); | |
| } | |
| } | |
| } | |
| pub(crate) async fn send_response<T>(&self, request_id: ConnectionRequestId, response: T) | |
| where | |
| T: Into<ClientResponsePayload>, | |
| { | |
| self.send_response_as_inner(request_id, response.into(), /*thread_originator*/ None) | |
| .await; | |
| } | |
| pub(crate) async fn send_response_with_thread_originator<T>( | |
| &self, | |
| request_id: ConnectionRequestId, | |
| response: T, | |
| thread_originator: String, | |
| ) where | |
| T: Into<ClientResponsePayload>, | |
| { | |
| self.send_response_as_inner(request_id, response.into(), Some(thread_originator)) | |
| .await; | |
| } | |
| pub(crate) async fn send_response_as( | |
| &self, | |
| request_id: ConnectionRequestId, | |
| response: ClientResponsePayload, | |
| ) { | |
| self.send_response_as_inner(request_id, response, /*thread_originator*/ None) | |
| .await; | |
| } | |
| /// Revalidates a sensitive result after reserving queue capacity, with no | |
| /// suspension between the identity check and handing off the response. | |
| pub(crate) async fn send_response_as_checked( | |
| &self, | |
| request_id: ConnectionRequestId, | |
| response: ClientResponsePayload, | |
| check: impl FnOnce() -> std::result::Result<(), JSONRPCErrorError>, | |
| ) { | |
| // Remain cancellable while waiting to deliver a proof, including after native work ends. | |
| let permit = self.sender.reserve().await; | |
| let _context = self.take_request_context(&request_id).await; | |
| let Ok(permit) = permit else { | |
| return; | |
| }; | |
| let message = match check() { | |
| Ok(()) => { | |
| self.analytics_events_client.track_response( | |
| request_id.connection_id.0, | |
| request_id.request_id.clone(), | |
| &response, | |
| ); | |
| OutgoingMessage::Response(OutgoingResponse { | |
| id: request_id.request_id, | |
| result: Box::new(response), | |
| }) | |
| } | |
| Err(error) => OutgoingMessage::Error(OutgoingError { | |
| id: request_id.request_id, | |
| error, | |
| }), | |
| }; | |
| permit.send(OutgoingEnvelope::ToConnection { | |
| connection_id: request_id.connection_id, | |
| message, | |
| write_complete_tx: None, | |
| }); | |
| } | |
| async fn send_response_as_inner( | |
| &self, | |
| request_id: ConnectionRequestId, | |
| response: ClientResponsePayload, | |
| thread_originator: Option<String>, | |
| ) { | |
| let connection_id = request_id.connection_id; | |
| let request_id_for_analytics = request_id.request_id.clone(); | |
| match thread_originator { | |
| Some(thread_originator) => { | |
| self.analytics_events_client | |
| .track_response_with_thread_originator( | |
| connection_id.0, | |
| request_id_for_analytics, | |
| &response, | |
| thread_originator, | |
| ); | |
| } | |
| None => { | |
| self.analytics_events_client.track_response( | |
| connection_id.0, | |
| request_id_for_analytics, | |
| &response, | |
| ); | |
| } | |
| } | |
| let response = Box::new(response); | |
| let request_context = self.take_request_context(&request_id).await; | |
| let outgoing_message = OutgoingMessage::Response(OutgoingResponse { | |
| id: request_id.request_id, | |
| result: response, | |
| }); | |
| self.send_outgoing_message_to_connection( | |
| request_context, | |
| connection_id, | |
| outgoing_message, | |
| "response", | |
| ) | |
| .await; | |
| } | |
| pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { | |
| if matches!( | |
| notification, | |
| ServerNotification::ThreadArchived(_) | ServerNotification::ThreadUnarchived(_) | |
| ) { | |
| self.analytics_events_client | |
| .track_notification(¬ification); | |
| } | |
| self.send_server_notification_to_connections(&[], notification) | |
| .await; | |
| } | |
| pub(crate) async fn send_server_notification_to_connections( | |
| &self, | |
| connection_ids: &[ConnectionId], | |
| notification: ServerNotification, | |
| ) { | |
| tracing::trace!( | |
| targeted_connections = connection_ids.len(), | |
| "app-server event: {notification}" | |
| ); | |
| let outgoing_message = timestamped_server_notification(notification); | |
| if connection_ids.is_empty() { | |
| if let Err(err) = self | |
| .sender | |
| .send(OutgoingEnvelope::Broadcast { | |
| message: outgoing_message, | |
| }) | |
| .await | |
| { | |
| warn!("failed to send server notification to client: {err:?}"); | |
| } | |
| return; | |
| } | |
| for connection_id in connection_ids { | |
| if let Err(err) = self | |
| .sender | |
| .send(OutgoingEnvelope::ToConnection { | |
| connection_id: *connection_id, | |
| message: outgoing_message.clone(), | |
| write_complete_tx: None, | |
| }) | |
| .await | |
| { | |
| warn!("failed to send server notification to client: {err:?}"); | |
| } | |
| } | |
| } | |
| pub(crate) async fn send_server_notification_to_connection_and_wait( | |
| &self, | |
| connection_id: ConnectionId, | |
| notification: ServerNotification, | |
| ) -> bool { | |
| tracing::trace!("app-server event: {notification}"); | |
| let outgoing_message = timestamped_server_notification(notification); | |
| let (write_complete_tx, write_complete_rx) = oneshot::channel(); | |
| if let Err(err) = self | |
| .sender | |
| .send(OutgoingEnvelope::ToConnection { | |
| connection_id, | |
| message: outgoing_message, | |
| write_complete_tx: Some(write_complete_tx), | |
| }) | |
| .await | |
| { | |
| warn!("failed to send server notification to client: {err:?}"); | |
| } | |
| write_complete_rx.await.is_ok() | |
| } | |
| pub(crate) async fn send_error( | |
| &self, | |
| request_id: ConnectionRequestId, | |
| error: impl Into<JSONRPCErrorError>, | |
| ) { | |
| let request_context = self.take_request_context(&request_id).await; | |
| self.send_error_inner(request_context, request_id, error.into()) | |
| .await; | |
| } | |
| pub(crate) async fn send_result<T, E>( | |
| &self, | |
| request_id: ConnectionRequestId, | |
| result: std::result::Result<T, E>, | |
| ) where | |
| T: Into<ClientResponsePayload>, | |
| E: Into<JSONRPCErrorError>, | |
| { | |
| match result { | |
| Ok(response) => { | |
| self.send_response(request_id, response).await; | |
| } | |
| Err(error) => self.send_error(request_id, error).await, | |
| } | |
| } | |
| async fn send_error_inner( | |
| &self, | |
| request_context: Option<RequestContext>, | |
| request_id: ConnectionRequestId, | |
| error: JSONRPCErrorError, | |
| ) { | |
| let outgoing_message = OutgoingMessage::Error(OutgoingError { | |
| id: request_id.request_id, | |
| error, | |
| }); | |
| self.send_outgoing_message_to_connection( | |
| request_context, | |
| request_id.connection_id, | |
| outgoing_message, | |
| "error", | |
| ) | |
| .await; | |
| } | |
| async fn send_outgoing_message_to_connection( | |
| &self, | |
| request_context: Option<RequestContext>, | |
| connection_id: ConnectionId, | |
| message: OutgoingMessage, | |
| message_kind: &'static str, | |
| ) { | |
| let send_fut = self.sender.send(OutgoingEnvelope::ToConnection { | |
| connection_id, | |
| message, | |
| write_complete_tx: None, | |
| }); | |
| let send_result = if let Some(request_context) = request_context { | |
| send_fut.instrument(request_context.span()).await | |
| } else { | |
| send_fut.await | |
| }; | |
| if let Err(err) = send_result { | |
| warn!("failed to send {message_kind} to client: {err:?}"); | |
| } | |
| } | |
| } | |
| fn now_unix_timestamp_ms() -> u64 { | |
| SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .unwrap_or_default() | |
| .as_millis() | |
| .try_into() | |
| .unwrap_or_default() | |
| } | |
| fn timestamped_server_notification(notification: ServerNotification) -> OutgoingMessage { | |
| OutgoingMessage::AppServerNotification(ServerNotificationEnvelope { | |
| notification, | |
| emitted_at_ms: Some(now_unix_timestamp_ms().try_into().unwrap_or_default()), | |
| }) | |
| } | |
| mod user_verification_ownership_tests; | |
| mod user_verification_cancel_context_tests; | |
| mod tests { | |
| use std::time::Duration; | |
| use codex_app_server_protocol::AccountLoginCompletedNotification; | |
| use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; | |
| use codex_app_server_protocol::AccountUpdatedNotification; | |
| use codex_app_server_protocol::ApplyPatchApprovalParams; | |
| use codex_app_server_protocol::AuthMode; | |
| use codex_app_server_protocol::CommandExecutionApprovalDecision; | |
| use codex_app_server_protocol::CommandExecutionRequestApprovalParams; | |
| use codex_app_server_protocol::ConfigWarningNotification; | |
| use codex_app_server_protocol::DynamicToolCallParams; | |
| use codex_app_server_protocol::FileChangeRequestApprovalParams; | |
| use codex_app_server_protocol::GuardianWarningNotification; | |
| use codex_app_server_protocol::ModelRerouteReason; | |
| use codex_app_server_protocol::ModelReroutedNotification; | |
| use codex_app_server_protocol::ModelVerification; | |
| use codex_app_server_protocol::ModelVerificationNotification; | |
| use codex_app_server_protocol::RateLimitSnapshot; | |
| use codex_app_server_protocol::RateLimitWindow; | |
| use codex_app_server_protocol::ServerResponse; | |
| use codex_app_server_protocol::ToolRequestUserInputParams; | |
| use codex_app_server_protocol::TurnModerationMetadataNotification; | |
| use codex_protocol::ThreadId; | |
| use pretty_assertions::assert_eq; | |
| use serde_json::json; | |
| use std::sync::Arc; | |
| use tokio::time::timeout; | |
| use uuid::Uuid; | |
| use super::*; | |
| fn verify_server_notification_serialization() { | |
| let notification = | |
| ServerNotification::AccountLoginCompleted(AccountLoginCompletedNotification { | |
| login_id: Some(Uuid::nil().to_string()), | |
| success: true, | |
| error: None, | |
| onboarding_entrypoint: None, | |
| }); | |
| let jsonrpc_notification = | |
| OutgoingMessage::AppServerNotification(ServerNotificationEnvelope { | |
| notification, | |
| emitted_at_ms: Some(1_234), | |
| }); | |
| assert_eq!( | |
| json!({ | |
| "method": "account/login/completed", | |
| "params": { | |
| "loginId": Uuid::nil().to_string(), | |
| "success": true, | |
| "error": null, | |
| "onboardingEntrypoint": null, | |
| }, | |
| "emittedAtMs": 1_234, | |
| }), | |
| serde_json::to_value(jsonrpc_notification) | |
| .expect("ensure the strum macros serialize the method field correctly"), | |
| "ensure the strum macros serialize the method field correctly" | |
| ); | |
| } | |
| fn verify_account_login_completed_notification_serialization() { | |
| let notification = | |
| ServerNotification::AccountLoginCompleted(AccountLoginCompletedNotification { | |
| login_id: Some(Uuid::nil().to_string()), | |
| success: true, | |
| error: None, | |
| onboarding_entrypoint: None, | |
| }); | |
| assert_eq!( | |
| json!({ | |
| "method": "account/login/completed", | |
| "params": { | |
| "loginId": Uuid::nil().to_string(), | |
| "success": true, | |
| "error": null, | |
| "onboardingEntrypoint": null, | |
| }, | |
| }), | |
| serde_json::to_value(notification) | |
| .expect("ensure the notification serializes correctly"), | |
| "ensure the notification serializes correctly" | |
| ); | |
| } | |
| fn verify_account_rate_limits_notification_serialization() { | |
| let notification = | |
| ServerNotification::AccountRateLimitsUpdated(AccountRateLimitsUpdatedNotification { | |
| rate_limits: RateLimitSnapshot { | |
| limit_id: Some("codex".to_string()), | |
| limit_name: None, | |
| normal_model_slug: None, | |
| primary: Some(RateLimitWindow { | |
| used_percent: 25, | |
| window_duration_mins: Some(15), | |
| resets_at: Some(123), | |
| }), | |
| secondary: None, | |
| credits: None, | |
| individual_limit: None, | |
| spend_control_reached: None, | |
| plan_type: Some(PlanType::SelfServeBusinessProLite), | |
| rate_limit_reached_type: None, | |
| }, | |
| }); | |
| assert_eq!( | |
| json!({ | |
| "method": "account/rateLimits/updated", | |
| "params": { | |
| "rateLimits": { | |
| "limitId": "codex", | |
| "limitName": null, | |
| "normalModelSlug": null, | |
| "primary": { | |
| "usedPercent": 25, | |
| "windowDurationMins": 15, | |
| "resetsAt": 123 | |
| }, | |
| "secondary": null, | |
| "credits": null, | |
| "individualLimit": null, | |
| "spendControlReached": null, | |
| "planType": "self_serve_business_prolite", | |
| "rateLimitReachedType": null | |
| } | |
| }, | |
| }), | |
| serde_json::to_value(notification) | |
| .expect("ensure the notification serializes correctly"), | |
| "ensure the notification serializes correctly" | |
| ); | |
| } | |
| fn verify_account_updated_notification_serialization() { | |
| let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification { | |
| auth_mode: Some(AuthMode::Chatgpt), | |
| plan_type: Some(PlanType::SelfServeBusinessProLite), | |
| }); | |
| assert_eq!( | |
| json!({ | |
| "method": "account/updated", | |
| "params": { | |
| "authMode": "chatgpt", | |
| "planType": "self_serve_business_prolite" | |
| }, | |
| }), | |
| serde_json::to_value(notification) | |
| .expect("ensure the notification serializes correctly"), | |
| "ensure the notification serializes correctly" | |
| ); | |
| } | |
| fn verify_config_warning_notification_serialization() { | |
| let notification = ServerNotification::ConfigWarning(ConfigWarningNotification { | |
| summary: "Config error: using defaults".to_string(), | |
| details: Some("error loading config: bad config".to_string()), | |
| path: None, | |
| range: None, | |
| }); | |
| assert_eq!( | |
| json!( { | |
| "method": "configWarning", | |
| "params": { | |
| "summary": "Config error: using defaults", | |
| "details": "error loading config: bad config", | |
| }, | |
| }), | |
| serde_json::to_value(notification) | |
| .expect("ensure the notification serializes correctly"), | |
| "ensure the notification serializes correctly" | |
| ); | |
| } | |
| fn verify_guardian_warning_notification_serialization() { | |
| let notification = ServerNotification::GuardianWarning(GuardianWarningNotification { | |
| thread_id: "thread-1".to_string(), | |
| message: "Automatic approval review denied the requested action.".to_string(), | |
| }); | |
| assert_eq!( | |
| json!({ | |
| "method": "guardianWarning", | |
| "params": { | |
| "threadId": "thread-1", | |
| "message": "Automatic approval review denied the requested action.", | |
| }, | |
| }), | |
| serde_json::to_value(notification) | |
| .expect("ensure the notification serializes correctly"), | |
| "ensure the notification serializes correctly" | |
| ); | |
| } | |
| fn verify_model_rerouted_notification_serialization() { | |
| let notification = ServerNotification::ModelRerouted(ModelReroutedNotification { | |
| thread_id: "thread-1".to_string(), | |
| turn_id: "turn-1".to_string(), | |
| from_model: "gpt-5.3-codex".to_string(), | |
| to_model: "gpt-5.2".to_string(), | |
| reason: ModelRerouteReason::HighRiskCyberActivity, | |
| }); | |
| assert_eq!( | |
| json!({ | |
| "method": "model/rerouted", | |
| "params": { | |
| "threadId": "thread-1", | |
| "turnId": "turn-1", | |
| "fromModel": "gpt-5.3-codex", | |
| "toModel": "gpt-5.2", | |
| "reason": "highRiskCyberActivity", | |
| }, | |
| }), | |
| serde_json::to_value(notification) | |
| .expect("ensure the notification serializes correctly"), | |
| "ensure the notification serializes correctly" | |
| ); | |
| } | |
| fn verify_model_verification_notification_serialization() { | |
| let notification = ServerNotification::ModelVerification(ModelVerificationNotification { | |
| thread_id: "thread-1".to_string(), | |
| turn_id: "turn-1".to_string(), | |
| verifications: vec![ModelVerification::TrustedAccessForCyber], | |
| }); | |
| assert_eq!( | |
| json!({ | |
| "method": "model/verification", | |
| "params": { | |
| "threadId": "thread-1", | |
| "turnId": "turn-1", | |
| "verifications": ["trustedAccessForCyber"], | |
| }, | |
| }), | |
| serde_json::to_value(notification) | |
| .expect("ensure the notification serializes correctly"), | |
| "ensure the notification serializes correctly" | |
| ); | |
| } | |
| fn verify_turn_moderation_metadata_notification_serialization() { | |
| let notification = | |
| ServerNotification::TurnModerationMetadata(TurnModerationMetadataNotification { | |
| thread_id: "thread-1".to_string(), | |
| turn_id: "turn-1".to_string(), | |
| metadata: json!({"presentation": "inline"}), | |
| }); | |
| assert_eq!( | |
| json!({ | |
| "method": "turn/moderationMetadata", | |
| "params": { | |
| "threadId": "thread-1", | |
| "turnId": "turn-1", | |
| "metadata": {"presentation": "inline"}, | |
| }, | |
| }), | |
| serde_json::to_value(notification) | |
| .expect("ensure the notification serializes correctly"), | |
| "ensure the notification serializes correctly" | |
| ); | |
| } | |
| fn server_request_response_from_result_decodes_typed_response() { | |
| let request = ServerRequest::CommandExecutionRequestApproval { | |
| request_id: RequestId::Integer(7), | |
| params: CommandExecutionRequestApprovalParams { | |
| kind: Default::default(), | |
| thread_id: "thread-1".to_string(), | |
| turn_id: "turn-1".to_string(), | |
| item_id: "item-1".to_string(), | |
| started_at_ms: 0, | |
| approval_id: None, | |
| environment_id: None, | |
| reason: None, | |
| network_approval_context: None, | |
| command: Some("echo hi".to_string()), | |
| cwd: None, | |
| command_actions: None, | |
| additional_permissions: None, | |
| proposed_execpolicy_amendment: None, | |
| proposed_network_policy_amendments: None, | |
| available_decisions: None, | |
| }, | |
| }; | |
| let response = request | |
| .response_from_result(json!({ | |
| "decision": "acceptForSession", | |
| })) | |
| .expect("decode typed server response"); | |
| let ServerResponse::CommandExecutionRequestApproval { | |
| request_id, | |
| response, | |
| } = response | |
| else { | |
| panic!("expected command execution approval response"); | |
| }; | |
| assert_eq!(request_id, RequestId::Integer(7)); | |
| assert_eq!( | |
| response.decision, | |
| CommandExecutionApprovalDecision::AcceptForSession | |
| ); | |
| } | |
| async fn send_response_routes_to_target_connection() { | |
| let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(4); | |
| let outgoing = | |
| OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); | |
| let request_id = ConnectionRequestId { | |
| connection_id: ConnectionId(42), | |
| request_id: RequestId::Integer(7), | |
| }; | |
| outgoing | |
| .send_response( | |
| request_id.clone(), | |
| ClientResponsePayload::ThreadArchive( | |
| codex_app_server_protocol::ThreadArchiveResponse {}, | |
| ), | |
| ) | |
| .await; | |
| let envelope = timeout(Duration::from_secs(1), rx.recv()) | |
| .await | |
| .expect("should receive envelope before timeout") | |
| .expect("channel should contain one message"); | |
| match envelope { | |
| OutgoingEnvelope::ToConnection { | |
| connection_id, | |
| message, | |
| .. | |
| } => { | |
| assert_eq!(connection_id, ConnectionId(42)); | |
| let OutgoingMessage::Response(response) = message else { | |
| panic!("expected response message"); | |
| }; | |
| assert_eq!(response.id, request_id.request_id); | |
| assert_eq!( | |
| serde_json::to_value(response.result).expect("result should serialize"), | |
| json!({}) | |
| ); | |
| } | |
| other => panic!("expected targeted response envelope, got: {other:?}"), | |
| } | |
| } | |
| async fn send_response_clears_registered_request_context() { | |
| let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4); | |
| let outgoing = | |
| OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); | |
| let request_id = ConnectionRequestId { | |
| connection_id: ConnectionId(42), | |
| request_id: RequestId::Integer(7), | |
| }; | |
| outgoing | |
| .register_request_context(RequestContext::new( | |
| request_id.clone(), | |
| "thread/start", | |
| tracing::info_span!("app_server.request", rpc.method = "thread/start"), | |
| /*parent_trace*/ None, | |
| )) | |
| .await; | |
| assert_eq!(outgoing.request_context_count().await, 1); | |
| outgoing | |
| .send_response( | |
| request_id, | |
| ClientResponsePayload::ThreadArchive( | |
| codex_app_server_protocol::ThreadArchiveResponse {}, | |
| ), | |
| ) | |
| .await; | |
| assert_eq!(outgoing.request_context_count().await, 0); | |
| } | |
| async fn send_error_routes_to_target_connection() { | |
| let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(4); | |
| let outgoing = | |
| OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); | |
| let request_id = ConnectionRequestId { | |
| connection_id: ConnectionId(9), | |
| request_id: RequestId::Integer(3), | |
| }; | |
| let error = internal_error("boom"); | |
| outgoing.send_error(request_id.clone(), error.clone()).await; | |
| let envelope = timeout(Duration::from_secs(1), rx.recv()) | |
| .await | |
| .expect("should receive envelope before timeout") | |
| .expect("channel should contain one message"); | |
| match envelope { | |
| OutgoingEnvelope::ToConnection { | |
| connection_id, | |
| message, | |
| .. | |
| } => { | |
| assert_eq!(connection_id, ConnectionId(9)); | |
| let OutgoingMessage::Error(outgoing_error) = message else { | |
| panic!("expected error message"); | |
| }; | |
| assert_eq!(outgoing_error.id, RequestId::Integer(3)); | |
| assert_eq!(outgoing_error.error, error); | |
| } | |
| other => panic!("expected targeted error envelope, got: {other:?}"), | |
| } | |
| } | |
| async fn send_server_notification_to_connections_reuses_timestamp() { | |
| let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(2); | |
| let outgoing = | |
| OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); | |
| outgoing | |
| .send_server_notification_to_connections( | |
| &[ConnectionId(1), ConnectionId(2)], | |
| ServerNotification::ConfigWarning(ConfigWarningNotification { | |
| summary: "test".to_string(), | |
| details: None, | |
| path: None, | |
| range: None, | |
| }), | |
| ) | |
| .await; | |
| let timestamps = [ | |
| rx.recv() | |
| .await | |
| .expect("first connection should receive notification"), | |
| rx.recv() | |
| .await | |
| .expect("second connection should receive notification"), | |
| ] | |
| .map(|envelope| match envelope { | |
| OutgoingEnvelope::ToConnection { | |
| message: OutgoingMessage::AppServerNotification(envelope), | |
| .. | |
| } => envelope.emitted_at_ms, | |
| _ => panic!("expected targeted server notification"), | |
| }); | |
| assert_eq!(timestamps[0], timestamps[1]); | |
| } | |
| async fn send_server_notification_to_connection_and_wait_tracks_write_completion() { | |
| let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(4); | |
| let outgoing = | |
| OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); | |
| let send_task = tokio::spawn(async move { | |
| outgoing | |
| .send_server_notification_to_connection_and_wait( | |
| ConnectionId(42), | |
| ServerNotification::ModelRerouted(ModelReroutedNotification { | |
| thread_id: "thread-1".to_string(), | |
| turn_id: "turn-1".to_string(), | |
| from_model: "gpt-5.3-codex".to_string(), | |
| to_model: "gpt-5.2".to_string(), | |
| reason: ModelRerouteReason::HighRiskCyberActivity, | |
| }), | |
| ) | |
| .await | |
| }); | |
| let envelope = timeout(Duration::from_secs(1), rx.recv()) | |
| .await | |
| .expect("should receive envelope before timeout") | |
| .expect("channel should contain one message"); | |
| let OutgoingEnvelope::ToConnection { | |
| connection_id, | |
| message, | |
| write_complete_tx, | |
| } = envelope | |
| else { | |
| panic!("expected targeted server notification envelope"); | |
| }; | |
| assert_eq!(connection_id, ConnectionId(42)); | |
| let OutgoingMessage::AppServerNotification(envelope) = message else { | |
| panic!("expected app-server notification"); | |
| }; | |
| assert!( | |
| envelope | |
| .emitted_at_ms | |
| .is_some_and(|emitted_at_ms| emitted_at_ms > 0) | |
| ); | |
| write_complete_tx | |
| .expect("write completion sender should be attached") | |
| .send(()) | |
| .expect("receiver should still be waiting"); | |
| timeout(Duration::from_secs(1), send_task) | |
| .await | |
| .expect("send task should finish after write completion is signaled") | |
| .expect("send task should not panic"); | |
| } | |
| async fn connection_closed_clears_registered_request_contexts() { | |
| let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4); | |
| let outgoing = | |
| OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); | |
| let closed_connection_request = ConnectionRequestId { | |
| connection_id: ConnectionId(9), | |
| request_id: RequestId::Integer(3), | |
| }; | |
| let open_connection_request = ConnectionRequestId { | |
| connection_id: ConnectionId(10), | |
| request_id: RequestId::Integer(4), | |
| }; | |
| outgoing | |
| .register_request_context(RequestContext::new( | |
| closed_connection_request, | |
| "turn/interrupt", | |
| tracing::info_span!("app_server.request", rpc.method = "turn/interrupt"), | |
| /*parent_trace*/ None, | |
| )) | |
| .await; | |
| outgoing | |
| .register_request_context(RequestContext::new( | |
| open_connection_request, | |
| "turn/start", | |
| tracing::info_span!("app_server.request", rpc.method = "turn/start"), | |
| /*parent_trace*/ None, | |
| )) | |
| .await; | |
| assert_eq!(outgoing.request_context_count().await, 2); | |
| outgoing.connection_closed(ConnectionId(9)).await; | |
| assert_eq!(outgoing.request_context_count().await, 1); | |
| } | |
| async fn notify_client_error_forwards_error_to_waiter() { | |
| let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4); | |
| let outgoing = | |
| OutgoingMessageSender::new(tx, codex_analytics::AnalyticsEventsClient::disabled()); | |
| let (request_id, wait_for_result) = outgoing | |
| .send_request(ServerRequestPayload::ApplyPatchApproval( | |
| ApplyPatchApprovalParams { | |
| conversation_id: ThreadId::new(), | |
| call_id: "call-id".to_string(), | |
| file_changes: HashMap::new(), | |
| reason: None, | |
| grant_root: None, | |
| }, | |
| )) | |
| .await; | |
| let error = internal_error("refresh failed"); | |
| outgoing | |
| .notify_client_error(ConnectionId(1), request_id, error.clone()) | |
| .await; | |
| let result = timeout(Duration::from_secs(1), wait_for_result) | |
| .await | |
| .expect("wait should not time out") | |
| .expect("waiter should receive a callback"); | |
| assert_eq!(result, Err(error)); | |
| } | |
| async fn pending_requests_for_thread_returns_thread_requests_in_request_id_order() { | |
| let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(8); | |
| let outgoing = Arc::new(OutgoingMessageSender::new( | |
| tx, | |
| codex_analytics::AnalyticsEventsClient::disabled(), | |
| )); | |
| let thread_id = ThreadId::new(); | |
| let thread_outgoing = ThreadScopedOutgoingMessageSender::new( | |
| outgoing.clone(), | |
| vec![ConnectionId(1)], | |
| thread_id, | |
| ); | |
| let (dynamic_tool_request_id, _dynamic_tool_waiter) = thread_outgoing | |
| .send_request(ServerRequestPayload::DynamicToolCall( | |
| DynamicToolCallParams { | |
| thread_id: thread_id.to_string(), | |
| turn_id: "turn-1".to_string(), | |
| call_id: "call-0".to_string(), | |
| namespace: None, | |
| tool: "tool".to_string(), | |
| arguments: json!({}), | |
| }, | |
| )) | |
| .await; | |
| let (first_request_id, _first_waiter) = thread_outgoing | |
| .send_request(ServerRequestPayload::ToolRequestUserInput( | |
| ToolRequestUserInputParams { | |
| thread_id: thread_id.to_string(), | |
| turn_id: "turn-1".to_string(), | |
| item_id: "call-1".to_string(), | |
| questions: vec![], | |
| is_blocking: true, | |
| auto_resolution_ms: None, | |
| }, | |
| )) | |
| .await; | |
| let (second_request_id, _second_waiter) = thread_outgoing | |
| .send_request(ServerRequestPayload::FileChangeRequestApproval( | |
| FileChangeRequestApprovalParams { | |
| thread_id: thread_id.to_string(), | |
| turn_id: "turn-1".to_string(), | |
| item_id: "call-2".to_string(), | |
| started_at_ms: 0, | |
| reason: None, | |
| grant_root: None, | |
| }, | |
| )) | |
| .await; | |
| let pending_requests = outgoing.pending_requests_for_thread(thread_id).await; | |
| assert_eq!( | |
| pending_requests | |
| .iter() | |
| .map(ServerRequest::id) | |
| .collect::<Vec<_>>(), | |
| vec![ | |
| &dynamic_tool_request_id, | |
| &first_request_id, | |
| &second_request_id | |
| ] | |
| ); | |
| } | |
| async fn cancel_requests_for_thread_cancels_all_thread_requests() { | |
| let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(8); | |
| let outgoing = Arc::new(OutgoingMessageSender::new( | |
| tx, | |
| codex_analytics::AnalyticsEventsClient::disabled(), | |
| )); | |
| let thread_id = ThreadId::new(); | |
| let thread_outgoing = ThreadScopedOutgoingMessageSender::new( | |
| outgoing.clone(), | |
| vec![ConnectionId(1)], | |
| thread_id, | |
| ); | |
| let (_dynamic_tool_request_id, dynamic_tool_waiter) = thread_outgoing | |
| .send_request(ServerRequestPayload::DynamicToolCall( | |
| DynamicToolCallParams { | |
| thread_id: thread_id.to_string(), | |
| turn_id: "turn-1".to_string(), | |
| call_id: "call-0".to_string(), | |
| namespace: None, | |
| tool: "tool".to_string(), | |
| arguments: json!({}), | |
| }, | |
| )) | |
| .await; | |
| let (_request_id, user_input_waiter) = thread_outgoing | |
| .send_request(ServerRequestPayload::ToolRequestUserInput( | |
| ToolRequestUserInputParams { | |
| thread_id: thread_id.to_string(), | |
| turn_id: "turn-1".to_string(), | |
| item_id: "call-1".to_string(), | |
| questions: vec![], | |
| is_blocking: true, | |
| auto_resolution_ms: None, | |
| }, | |
| )) | |
| .await; | |
| let error = internal_error("tracked request cancelled"); | |
| outgoing | |
| .cancel_requests_for_thread(thread_id, Some(error.clone())) | |
| .await; | |
| let dynamic_tool_result = timeout(Duration::from_secs(1), dynamic_tool_waiter) | |
| .await | |
| .expect("dynamic tool waiter should resolve") | |
| .expect("dynamic tool waiter should receive a callback"); | |
| let user_input_result = timeout(Duration::from_secs(1), user_input_waiter) | |
| .await | |
| .expect("user input waiter should resolve") | |
| .expect("user input waiter should receive a callback"); | |
| assert_eq!(dynamic_tool_result, Err(error.clone())); | |
| assert_eq!(user_input_result, Err(error)); | |
| assert!( | |
| outgoing | |
| .pending_requests_for_thread(thread_id) | |
| .await | |
| .is_empty() | |
| ); | |
| } | |
| } | |