1use std::collections::HashMap;
42use std::collections::HashSet;
43use std::collections::hash_map::Entry;
44use std::io::Error as IoError;
45use std::io::ErrorKind;
46use std::io::Result as IoResult;
47use std::sync::Arc;
48use std::sync::RwLock;
49use std::sync::atomic::AtomicBool;
50use std::sync::atomic::Ordering;
51use std::time::Duration;
52
53use crate::analytics_utils::analytics_events_client_from_config;
54use crate::config_manager::ConfigManager;
55use crate::error_code::OVERLOADED_ERROR_CODE;
56use crate::error_code::internal_error;
57use crate::error_code::invalid_request;
58use crate::message_processor::ConnectionSessionState;
59use crate::message_processor::MessageProcessor;
60use crate::message_processor::MessageProcessorArgs;
61use crate::outgoing_message::ConnectionId;
62use crate::outgoing_message::OutgoingEnvelope;
63use crate::outgoing_message::OutgoingMessage;
64use crate::outgoing_message::OutgoingMessageSender;
65use crate::outgoing_message::QueuedOutgoingMessage;
66use crate::transport::CHANNEL_CAPACITY;
67use crate::transport::OutboundConnectionState;
68use crate::transport::route_outgoing_envelope;
69use codex_analytics::AppServerRpcTransport;
70use codex_app_server_protocol::ClientNotification;
71use codex_app_server_protocol::ClientRequest;
72use codex_app_server_protocol::ConfigWarningNotification;
73use codex_app_server_protocol::InitializeParams;
74use codex_app_server_protocol::JSONRPCErrorError;
75use codex_app_server_protocol::RequestId;
76use codex_app_server_protocol::Result;
77use codex_app_server_protocol::ServerNotification;
78use codex_app_server_protocol::ServerRequest;
79use codex_arg0::Arg0DispatchPaths;
80use codex_config::CloudConfigBundleLoader;
81use codex_config::LoaderOverrides;
82use codex_config::ThreadConfigLoader;
83use codex_core::check_execpolicy_for_warnings;
84use codex_core::config::Config;
85use codex_core::resolve_installation_id;
86use codex_exec_server::EnvironmentManager;
87use codex_feedback::CodexFeedback;
88use codex_login::AuthManager;
89use codex_protocol::protocol::SessionSource;
90pub use codex_rollout::StateDbHandle;
91pub use codex_state::log_db::LogDbLayer;
92use tokio::sync::mpsc;
93use tokio::sync::oneshot;
94use tokio::time::timeout;
95use toml::Value as TomlValue;
96use tracing::warn;
97
98const IN_PROCESS_CONNECTION_ID: ConnectionId = ConnectionId(0);
99const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
100pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = CHANNEL_CAPACITY;
102
103type PendingClientRequestResponse = std::result::Result<Result, JSONRPCErrorError>;
104
105fn server_notification_requires_delivery(notification: &ServerNotification) -> bool {
106 matches!(
107 notification,
108 ServerNotification::TurnCompleted(_)
109 | ServerNotification::ThreadSettingsUpdated(_)
110 | ServerNotification::ExternalAgentConfigImportCompleted(_)
111 )
112}
113
114#[derive(Clone)]
119pub struct InProcessStartArgs {
120 pub arg0_paths: Arg0DispatchPaths,
122 pub config: Arc<Config>,
124 pub cli_overrides: Vec<(String, TomlValue)>,
126 pub loader_overrides: LoaderOverrides,
128 pub strict_config: bool,
130 pub cloud_config_bundle: CloudConfigBundleLoader,
132 pub thread_config_loader: Arc<dyn ThreadConfigLoader>,
134 pub feedback: CodexFeedback,
136 pub log_db: Option<LogDbLayer>,
138 pub state_db: Option<StateDbHandle>,
140 pub environment_manager: Arc<EnvironmentManager>,
142 pub config_warnings: Vec<ConfigWarningNotification>,
144 pub session_source: SessionSource,
146 pub enable_codex_api_key_env: bool,
148 pub initialize: InitializeParams,
150 pub channel_capacity: usize,
152}
153
154#[derive(Debug, Clone)]
159pub enum InProcessServerEvent {
160 ServerRequest(ServerRequest),
162 ServerNotification(ServerNotification),
164 Lagged { skipped: usize },
166}
167
168enum InProcessClientMessage {
174 Request {
175 request: Box<ClientRequest>,
176 response_tx: oneshot::Sender<PendingClientRequestResponse>,
177 },
178 Notification {
179 notification: ClientNotification,
180 },
181 ServerRequestResponse {
182 request_id: RequestId,
183 result: Result,
184 },
185 ServerRequestError {
186 request_id: RequestId,
187 error: JSONRPCErrorError,
188 },
189 Shutdown {
190 done_tx: oneshot::Sender<()>,
191 },
192}
193
194enum ProcessorCommand {
195 Request(Box<ClientRequest>),
196 Notification(ClientNotification),
197}
198
199#[derive(Clone)]
200pub struct InProcessClientSender {
201 client_tx: mpsc::Sender<InProcessClientMessage>,
202}
203
204impl InProcessClientSender {
205 pub async fn request(&self, request: ClientRequest) -> IoResult<PendingClientRequestResponse> {
206 let (response_tx, response_rx) = oneshot::channel();
207 self.try_send_client_message(InProcessClientMessage::Request {
208 request: Box::new(request),
209 response_tx,
210 })?;
211 response_rx.await.map_err(|err| {
212 IoError::new(
213 ErrorKind::BrokenPipe,
214 format!("in-process request response channel closed: {err}"),
215 )
216 })
217 }
218
219 pub fn notify(&self, notification: ClientNotification) -> IoResult<()> {
220 self.try_send_client_message(InProcessClientMessage::Notification { notification })
221 }
222
223 pub fn respond_to_server_request(&self, request_id: RequestId, result: Result) -> IoResult<()> {
224 self.try_send_client_message(InProcessClientMessage::ServerRequestResponse {
225 request_id,
226 result,
227 })
228 }
229
230 pub fn fail_server_request(
231 &self,
232 request_id: RequestId,
233 error: JSONRPCErrorError,
234 ) -> IoResult<()> {
235 self.try_send_client_message(InProcessClientMessage::ServerRequestError {
236 request_id,
237 error,
238 })
239 }
240
241 fn try_send_client_message(&self, message: InProcessClientMessage) -> IoResult<()> {
242 match self.client_tx.try_send(message) {
243 Ok(()) => Ok(()),
244 Err(mpsc::error::TrySendError::Full(_)) => Err(IoError::new(
245 ErrorKind::WouldBlock,
246 "in-process app-server client queue is full",
247 )),
248 Err(mpsc::error::TrySendError::Closed(_)) => Err(IoError::new(
249 ErrorKind::BrokenPipe,
250 "in-process app-server runtime is closed",
251 )),
252 }
253 }
254}
255
256pub struct InProcessClientHandle {
262 client: InProcessClientSender,
263 event_rx: mpsc::Receiver<InProcessServerEvent>,
264 runtime_handle: tokio::task::JoinHandle<()>,
265 #[cfg(test)]
266 _test_codex_home: Option<tempfile::TempDir>,
267}
268
269impl InProcessClientHandle {
270 pub async fn request(&self, request: ClientRequest) -> IoResult<PendingClientRequestResponse> {
278 self.client.request(request).await
279 }
280
281 pub fn notify(&self, notification: ClientNotification) -> IoResult<()> {
286 self.client.notify(notification)
287 }
288
289 pub fn respond_to_server_request(&self, request_id: RequestId, result: Result) -> IoResult<()> {
295 self.client.respond_to_server_request(request_id, result)
296 }
297
298 pub fn fail_server_request(
303 &self,
304 request_id: RequestId,
305 error: JSONRPCErrorError,
306 ) -> IoResult<()> {
307 self.client.fail_server_request(request_id, error)
308 }
309
310 pub async fn next_event(&mut self) -> Option<InProcessServerEvent> {
315 self.event_rx.recv().await
316 }
317
318 pub async fn shutdown(self) -> IoResult<()> {
323 let mut runtime_handle = self.runtime_handle;
324 let (done_tx, done_rx) = oneshot::channel();
325
326 if self
327 .client
328 .client_tx
329 .send(InProcessClientMessage::Shutdown { done_tx })
330 .await
331 .is_ok()
332 {
333 let _ = timeout(SHUTDOWN_TIMEOUT, done_rx).await;
334 }
335
336 if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut runtime_handle).await {
337 runtime_handle.abort();
338 let _ = runtime_handle.await;
339 }
340 Ok(())
341 }
342
343 pub fn sender(&self) -> InProcessClientSender {
344 self.client.clone()
345 }
346}
347
348pub async fn start(mut args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
354 if let Ok(Some(err)) = check_execpolicy_for_warnings(&args.config.config_layer_stack).await {
355 let (path, range) = crate::exec_policy_warning_location(&err);
356 args.config_warnings.push(ConfigWarningNotification {
357 summary: "Error parsing rules; custom rules not applied.".to_string(),
358 details: Some(err.to_string()),
359 path,
360 range,
361 });
362 }
363 let initialize = args.initialize.clone();
364 let client = start_uninitialized(args).await?;
365
366 let initialize_response = client
367 .request(ClientRequest::Initialize {
368 request_id: RequestId::Integer(0),
369 params: initialize,
370 })
371 .await?;
372 if let Err(error) = initialize_response {
373 let _ = client.shutdown().await;
374 return Err(IoError::new(
375 ErrorKind::InvalidData,
376 format!("in-process initialize failed: {}", error.message),
377 ));
378 }
379 client.notify(ClientNotification::Initialized)?;
380
381 Ok(client)
382}
383
384async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
385 let channel_capacity = args.channel_capacity.max(1);
386 let installation_id = resolve_installation_id(&args.config.codex_home).await?;
387 let (client_tx, mut client_rx) = mpsc::channel::<InProcessClientMessage>(channel_capacity);
388 let (event_tx, event_rx) = mpsc::channel::<InProcessServerEvent>(channel_capacity);
389
390 let runtime_handle = tokio::spawn(async move {
391 let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(channel_capacity);
392 let auth_manager =
393 AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env)
394 .await;
395 let analytics_events_client =
396 analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref());
397 let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(
398 outgoing_tx,
399 analytics_events_client.clone(),
400 ));
401
402 let (writer_tx, mut writer_rx) = mpsc::channel::<QueuedOutgoingMessage>(channel_capacity);
403 let outbound_initialized = Arc::new(AtomicBool::new(false));
404 let outbound_experimental_api_enabled = Arc::new(AtomicBool::new(false));
405 let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new()));
406
407 let mut outbound_connections = HashMap::<ConnectionId, OutboundConnectionState>::new();
408 outbound_connections.insert(
409 IN_PROCESS_CONNECTION_ID,
410 OutboundConnectionState::new(
411 writer_tx,
412 Arc::clone(&outbound_initialized),
413 Arc::clone(&outbound_experimental_api_enabled),
414 Arc::clone(&outbound_opted_out_notification_methods),
415 None,
416 ),
417 );
418 let mut outbound_handle = tokio::spawn(async move {
419 while let Some(envelope) = outgoing_rx.recv().await {
420 route_outgoing_envelope(&mut outbound_connections, envelope).await;
421 }
422 });
423
424 let processor_outgoing = Arc::clone(&outgoing_message_sender);
425 let config_manager = ConfigManager::new(
426 args.config.codex_home.to_path_buf(),
427 args.cli_overrides,
428 args.loader_overrides,
429 args.strict_config,
430 args.cloud_config_bundle,
431 args.arg0_paths.clone(),
432 args.thread_config_loader,
433 );
434 let (processor_tx, mut processor_rx) = mpsc::channel::<ProcessorCommand>(channel_capacity);
435 let mut processor_handle = tokio::spawn(async move {
436 let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs {
437 outgoing: Arc::clone(&processor_outgoing),
438 analytics_events_client,
439 arg0_paths: args.arg0_paths,
440 config: args.config,
441 config_manager,
442 environment_manager: args.environment_manager,
443 feedback: args.feedback,
444 log_db: args.log_db,
445 state_db: args.state_db,
446 config_warnings: args.config_warnings,
447 session_source: args.session_source,
448 auth_manager,
449 installation_id,
450 rpc_transport: AppServerRpcTransport::InProcess,
451 remote_control_handle: None,
452 plugin_startup_tasks: crate::PluginStartupTasks::Start,
453 }));
454 let mut thread_created_rx = processor.thread_created_receiver();
455 let session = Arc::new(ConnectionSessionState::new());
456 let mut listen_for_threads = true;
457
458 loop {
459 tokio::select! {
460 command = processor_rx.recv() => {
461 match command {
462 Some(ProcessorCommand::Request(request)) => {
463 let was_initialized = session.initialized();
464 processor
465 .process_client_request(
466 IN_PROCESS_CONNECTION_ID,
467 *request,
468 Arc::clone(&session),
469 &outbound_initialized,
470 )
471 .await;
472 let opted_out_notification_methods_snapshot =
473 session.opted_out_notification_methods();
474 let experimental_api_enabled =
475 session.experimental_api_enabled();
476 let is_initialized = session.initialized();
477 if let Ok(mut opted_out_notification_methods) =
478 outbound_opted_out_notification_methods.write()
479 {
480 *opted_out_notification_methods =
481 opted_out_notification_methods_snapshot;
482 } else {
483 warn!("failed to update outbound opted-out notifications");
484 }
485 outbound_experimental_api_enabled.store(
486 experimental_api_enabled,
487 Ordering::Release,
488 );
489 if !was_initialized && is_initialized {
490 processor.send_initialize_notifications().await;
491 }
492 }
493 Some(ProcessorCommand::Notification(notification)) => {
494 processor.process_client_notification(notification).await;
495 }
496 None => {
497 break;
498 }
499 }
500 }
501 created = thread_created_rx.recv(), if listen_for_threads => {
502 match created {
503 Ok(thread_id) => {
504 let connection_ids = if session.initialized() {
505 vec![IN_PROCESS_CONNECTION_ID]
506 } else {
507 Vec::<ConnectionId>::new()
508 };
509 processor
510 .try_attach_thread_listener(thread_id, connection_ids)
511 .await;
512 }
513 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
514 warn!("thread_created receiver lagged; skipping resync");
515 }
516 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
517 listen_for_threads = false;
518 }
519 }
520 }
521 }
522 }
523
524 processor.clear_runtime_references();
525 processor.cancel_active_login().await;
526 processor
527 .connection_closed(IN_PROCESS_CONNECTION_ID, &session)
528 .await;
529 processor.clear_all_thread_listeners().await;
530 processor.drain_background_tasks().await;
531 processor.shutdown_threads().await;
532 });
533 let mut pending_request_responses =
534 HashMap::<RequestId, oneshot::Sender<PendingClientRequestResponse>>::new();
535 let mut shutdown_ack = None;
536
537 loop {
538 tokio::select! {
539 message = client_rx.recv() => {
540 match message {
541 Some(InProcessClientMessage::Request { request, response_tx }) => {
542 let request = *request;
543 let request_id = request.id().clone();
544 match pending_request_responses.entry(request_id.clone()) {
545 Entry::Vacant(entry) => {
546 entry.insert(response_tx);
547 }
548 Entry::Occupied(_) => {
549 let _ = response_tx.send(Err(invalid_request(format!(
550 "duplicate request id: {request_id:?}"
551 ))));
552 continue;
553 }
554 }
555
556 match processor_tx.try_send(ProcessorCommand::Request(Box::new(request))) {
557 Ok(()) => {}
558 Err(mpsc::error::TrySendError::Full(_)) => {
559 if let Some(response_tx) =
560 pending_request_responses.remove(&request_id)
561 {
562 let _ = response_tx.send(Err(JSONRPCErrorError {
563 code: OVERLOADED_ERROR_CODE,
564 message: "in-process app-server request queue is full"
565 .to_string(),
566 data: None,
567 }));
568 }
569 }
570 Err(mpsc::error::TrySendError::Closed(_)) => {
571 if let Some(response_tx) =
572 pending_request_responses.remove(&request_id)
573 {
574 let _ = response_tx.send(Err(internal_error(
575 "in-process app-server request processor is closed",
576 )));
577 }
578 break;
579 }
580 }
581 }
582 Some(InProcessClientMessage::Notification { notification }) => {
583 match processor_tx.try_send(ProcessorCommand::Notification(notification)) {
584 Ok(()) => {}
585 Err(mpsc::error::TrySendError::Full(_)) => {
586 warn!("dropping in-process client notification (queue full)");
587 }
588 Err(mpsc::error::TrySendError::Closed(_)) => {
589 break;
590 }
591 }
592 }
593 Some(InProcessClientMessage::ServerRequestResponse { request_id, result }) => {
594 outgoing_message_sender
595 .notify_client_response(request_id, result)
596 .await;
597 }
598 Some(InProcessClientMessage::ServerRequestError { request_id, error }) => {
599 outgoing_message_sender
600 .notify_client_error(request_id, error)
601 .await;
602 }
603 Some(InProcessClientMessage::Shutdown { done_tx }) => {
604 shutdown_ack = Some(done_tx);
605 break;
606 }
607 None => {
608 break;
609 }
610 }
611 }
612 queued_message = writer_rx.recv() => {
613 let Some(queued_message) = queued_message else {
614 break;
615 };
616 let outgoing_message = queued_message.message;
617 match outgoing_message {
618 OutgoingMessage::Response(response) => {
619 if let Some(response_tx) = pending_request_responses.remove(&response.id) {
620 let _ = response_tx.send(Ok(response.result));
621 } else {
622 warn!(
623 request_id = ?response.id,
624 "dropping unmatched in-process response"
625 );
626 }
627 }
628 OutgoingMessage::Error(error) => {
629 if let Some(response_tx) = pending_request_responses.remove(&error.id) {
630 let _ = response_tx.send(Err(error.error));
631 } else {
632 warn!(
633 request_id = ?error.id,
634 "dropping unmatched in-process error response"
635 );
636 }
637 }
638 OutgoingMessage::Request(request) => {
639 if let Err(send_error) = event_tx
642 .try_send(InProcessServerEvent::ServerRequest(request))
643 {
644 let (error, inner) = match send_error {
645 mpsc::error::TrySendError::Full(inner) => (
646 JSONRPCErrorError {
647 code: OVERLOADED_ERROR_CODE,
648 message:
649 "in-process server request queue is full".to_string(),
650 data: None,
651 },
652 inner,
653 ),
654 mpsc::error::TrySendError::Closed(inner) => (
655 internal_error(
656 "in-process server request consumer is closed",
657 ),
658 inner,
659 ),
660 };
661 let request_id = match inner {
662 InProcessServerEvent::ServerRequest(req) => req.id().clone(),
663 _ => unreachable!("we just sent a ServerRequest variant"),
664 };
665 outgoing_message_sender
666 .notify_client_error(request_id, error)
667 .await;
668 }
669 }
670 OutgoingMessage::AppServerNotification(envelope) => {
671 let notification = envelope.notification;
672 if server_notification_requires_delivery(¬ification) {
673 if event_tx
674 .send(InProcessServerEvent::ServerNotification(notification))
675 .await
676 .is_err()
677 {
678 break;
679 }
680 } else if let Err(send_error) =
681 event_tx.try_send(InProcessServerEvent::ServerNotification(notification))
682 {
683 match send_error {
684 mpsc::error::TrySendError::Full(_) => {
685 warn!("dropping in-process server notification (queue full)");
686 }
687 mpsc::error::TrySendError::Closed(_) => {
688 break;
689 }
690 }
691 }
692 }
693 }
694 if let Some(write_complete_tx) = queued_message.write_complete_tx {
695 let _ = write_complete_tx.send(());
696 }
697 }
698 }
699 }
700
701 drop(writer_rx);
702 drop(processor_tx);
703 outgoing_message_sender
704 .cancel_all_requests(Some(internal_error(
705 "in-process app-server runtime is shutting down",
706 )))
707 .await;
708 drop(outgoing_message_sender);
711 for (_, response_tx) in pending_request_responses {
712 let _ = response_tx.send(Err(internal_error(
713 "in-process app-server runtime is shutting down",
714 )));
715 }
716
717 if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut processor_handle).await {
718 processor_handle.abort();
719 let _ = processor_handle.await;
720 }
721 if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut outbound_handle).await {
722 outbound_handle.abort();
723 let _ = outbound_handle.await;
724 }
725
726 if let Some(done_tx) = shutdown_ack {
727 let _ = done_tx.send(());
728 }
729 });
730
731 Ok(InProcessClientHandle {
732 client: InProcessClientSender { client_tx },
733 event_rx,
734 runtime_handle,
735 #[cfg(test)]
736 _test_codex_home: None,
737 })
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use codex_app_server_protocol::ClientInfo;
744 use codex_app_server_protocol::ConfigRequirementsReadResponse;
745 use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
746 use codex_app_server_protocol::SessionSource as ApiSessionSource;
747 use codex_app_server_protocol::ThreadStartParams;
748 use codex_app_server_protocol::ThreadStartResponse;
749 use codex_app_server_protocol::Turn;
750 use codex_app_server_protocol::TurnCompletedNotification;
751 use codex_app_server_protocol::TurnItemsView;
752 use codex_app_server_protocol::TurnStatus;
753 use codex_core::config::ConfigBuilder;
754 use pretty_assertions::assert_eq;
755 use std::path::Path;
756 use tempfile::TempDir;
757
758 async fn build_test_config(codex_home: &Path) -> Config {
759 match ConfigBuilder::default()
760 .codex_home(codex_home.to_path_buf())
761 .build()
762 .await
763 {
764 Ok(config) => config,
765 Err(_) => Config::load_default_with_cli_overrides_for_codex_home(
766 codex_home.to_path_buf(),
767 Vec::new(),
768 )
769 .await
770 .expect("default config should load"),
771 }
772 }
773
774 async fn start_test_client_with_capacity(
775 session_source: SessionSource,
776 channel_capacity: usize,
777 ) -> InProcessClientHandle {
778 let codex_home = TempDir::new().expect("temp dir");
779 let config = Arc::new(build_test_config(codex_home.path()).await);
780 let state_db = codex_rollout::state_db::try_init(config.as_ref())
781 .await
782 .expect("state db should initialize for in-process test");
783 let args = InProcessStartArgs {
784 arg0_paths: Arg0DispatchPaths::default(),
785 config,
786 cli_overrides: Vec::new(),
787 loader_overrides: LoaderOverrides::default(),
788 strict_config: false,
789 cloud_config_bundle: CloudConfigBundleLoader::default(),
790 thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
791 feedback: CodexFeedback::new(),
792 log_db: None,
793 state_db: Some(state_db),
794 environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
795 config_warnings: Vec::new(),
796 session_source,
797 enable_codex_api_key_env: false,
798 initialize: InitializeParams {
799 client_info: ClientInfo {
800 name: "codex-in-process-test".to_string(),
801 title: None,
802 version: "0.0.0".to_string(),
803 },
804 capabilities: None,
805 },
806 channel_capacity,
807 };
808 let mut client = start(args).await.expect("in-process runtime should start");
809 client._test_codex_home = Some(codex_home);
810 client
811 }
812
813 async fn start_test_client(session_source: SessionSource) -> InProcessClientHandle {
814 start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await
815 }
816
817 #[tokio::test]
818 async fn in_process_start_initializes_and_handles_typed_v2_request() {
819 let client = start_test_client(SessionSource::Cli).await;
820 let response = client
821 .request(ClientRequest::ConfigRequirementsRead {
822 request_id: RequestId::Integer(1),
823 params: None,
824 })
825 .await
826 .expect("request transport should work")
827 .expect("request should succeed");
828 assert!(response.is_object());
829
830 let _parsed: ConfigRequirementsReadResponse =
831 serde_json::from_value(response).expect("response should match v2 schema");
832 client
833 .shutdown()
834 .await
835 .expect("in-process runtime should shutdown cleanly");
836 }
837
838 #[tokio::test]
839 async fn in_process_start_uses_requested_session_source_for_thread_start() {
840 for (requested_source, expected_source) in [
841 (SessionSource::Cli, ApiSessionSource::Cli),
842 (SessionSource::Exec, ApiSessionSource::Exec),
843 ] {
844 let client = start_test_client(requested_source).await;
845 let response = client
846 .request(ClientRequest::ThreadStart {
847 request_id: RequestId::Integer(2),
848 params: ThreadStartParams {
849 ephemeral: Some(true),
850 ..ThreadStartParams::default()
851 },
852 })
853 .await
854 .expect("request transport should work")
855 .expect("thread/start should succeed");
856 let parsed: ThreadStartResponse =
857 serde_json::from_value(response).expect("thread/start response should parse");
858 assert_eq!(parsed.thread.source, expected_source);
859 client
860 .shutdown()
861 .await
862 .expect("in-process runtime should shutdown cleanly");
863 }
864 }
865
866 #[tokio::test]
867 async fn in_process_start_clamps_zero_channel_capacity() {
868 let client =
869 start_test_client_with_capacity(SessionSource::Cli, 0).await;
870 let response = loop {
871 match client
872 .request(ClientRequest::ConfigRequirementsRead {
873 request_id: RequestId::Integer(4),
874 params: None,
875 })
876 .await
877 {
878 Ok(response) => break response.expect("request should succeed"),
879 Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
880 tokio::task::yield_now().await;
881 }
882 Err(err) => panic!("request transport should work: {err}"),
883 }
884 };
885 let _parsed: ConfigRequirementsReadResponse =
886 serde_json::from_value(response).expect("response should match v2 schema");
887 client
888 .shutdown()
889 .await
890 .expect("in-process runtime should shutdown cleanly");
891 }
892
893 #[test]
894 fn guaranteed_delivery_helpers_cover_terminal_server_notifications() {
895 assert!(server_notification_requires_delivery(
896 &ServerNotification::TurnCompleted(TurnCompletedNotification {
897 thread_id: "thread-1".to_string(),
898 turn: Turn {
899 id: "turn-1".to_string(),
900 items: Vec::new(),
901 items_view: TurnItemsView::NotLoaded,
902 status: TurnStatus::Completed,
903 error: None,
904 started_at: None,
905 completed_at: Some(0),
906 duration_ms: None,
907 },
908 })
909 ));
910 assert!(server_notification_requires_delivery(
911 &ServerNotification::ExternalAgentConfigImportCompleted(
912 ExternalAgentConfigImportCompletedNotification {
913 import_id: "import".to_string(),
914 item_type_results: Vec::new(),
915 },
916 )
917 ));
918 }
919}