cc_sdk/client.rs
1//! Interactive client for bidirectional communication with Claude
2//!
3//! This module provides the `ClaudeSDKClient` for interactive, stateful
4//! conversations with Claude Code CLI.
5
6use crate::{
7 errors::{Result, SdkError},
8 internal_query::Query,
9 token_tracker::BudgetManager,
10 transport::{InputMessage, SubprocessTransport, Transport},
11 types::{ClaudeCodeOptions, ControlRequest, ControlResponse, Message},
12};
13use futures::stream::{Stream, StreamExt};
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::pin::Pin;
17use tokio::sync::{Mutex, RwLock, mpsc};
18use tokio_stream::wrappers::ReceiverStream;
19use tracing::{debug, error, info};
20
21/// Client state
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ClientState {
24 /// Not connected
25 Disconnected,
26 /// Connected and ready
27 Connected,
28 /// Error state
29 Error,
30}
31
32/// Interactive client for bidirectional communication with Claude
33///
34/// `ClaudeSDKClient` provides a stateful, interactive interface for communicating
35/// with Claude Code CLI. Unlike the simple `query` function, this client supports:
36///
37/// - Bidirectional communication
38/// - Multiple sessions
39/// - Interrupt capabilities
40/// - State management
41/// - Follow-up messages based on responses
42///
43/// # Example
44///
45/// ```rust,no_run
46/// use cc_sdk::{ClaudeSDKClient, ClaudeCodeOptions, Message, Result};
47/// use futures::StreamExt;
48///
49/// #[tokio::main]
50/// async fn main() -> Result<()> {
51/// let options = ClaudeCodeOptions::builder()
52/// .system_prompt("You are a helpful assistant")
53/// .model("claude-3-opus-20240229")
54/// .build();
55///
56/// let mut client = ClaudeSDKClient::new(options);
57///
58/// // Connect with initial prompt
59/// client.connect(Some("Hello!".to_string())).await?;
60///
61/// // Receive initial response
62/// let mut messages = client.receive_messages().await;
63/// while let Some(msg) = messages.next().await {
64/// match msg? {
65/// Message::Result { .. } => break,
66/// msg => println!("{:?}", msg),
67/// }
68/// }
69///
70/// // Send follow-up
71/// client.send_request("What's 2 + 2?".to_string(), None).await?;
72///
73/// // Receive response
74/// let mut messages = client.receive_messages().await;
75/// while let Some(msg) = messages.next().await {
76/// println!("{:?}", msg?);
77/// }
78///
79/// // Disconnect
80/// client.disconnect().await?;
81///
82/// Ok(())
83/// }
84/// ```
85pub struct ClaudeSDKClient {
86 /// Configuration options
87 #[allow(dead_code)]
88 options: ClaudeCodeOptions,
89 /// Transport layer
90 transport: Arc<Mutex<Box<dyn Transport + Send>>>,
91 /// Internal query handler (when control protocol is enabled)
92 query_handler: Option<Arc<Mutex<Query>>>,
93 /// Client state
94 state: Arc<RwLock<ClientState>>,
95 /// Active sessions
96 sessions: Arc<RwLock<HashMap<String, SessionData>>>,
97 /// Message sender for current receiver
98 message_tx: Arc<Mutex<Option<mpsc::Sender<Result<Message>>>>>,
99 /// Message buffer for multiple receivers
100 message_buffer: Arc<Mutex<Vec<Message>>>,
101 /// Request counter
102 request_counter: Arc<Mutex<u64>>,
103 /// Budget manager for token tracking
104 budget_manager: BudgetManager,
105}
106
107/// Session data
108#[allow(dead_code)]
109struct SessionData {
110 /// Session ID
111 id: String,
112 /// Number of messages sent
113 message_count: usize,
114 /// Creation time
115 created_at: std::time::Instant,
116}
117
118impl ClaudeSDKClient {
119 /// Create a new client with the given options
120 pub fn new(options: ClaudeCodeOptions) -> Self {
121 // Set environment variable to indicate SDK usage
122 unsafe {
123 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "sdk-rust");
124 }
125
126 let transport = match SubprocessTransport::new(options.clone()) {
127 Ok(t) => t,
128 Err(e) => {
129 error!("Failed to create transport: {}", e);
130 // Create with empty path, will fail on connect
131 SubprocessTransport::with_cli_path(options.clone(), "")
132 }
133 };
134
135 // Wrap transport in Arc for sharing
136 let transport_arc: Arc<Mutex<Box<dyn Transport + Send>>> =
137 Arc::new(Mutex::new(Box::new(transport)));
138
139 Self::with_transport_internal(options, transport_arc)
140 }
141
142 /// Create a new client with a custom transport implementation
143 ///
144 /// This allows users to provide their own Transport implementation instead of
145 /// using the default SubprocessTransport. Useful for testing, custom CLI paths,
146 /// or alternative communication mechanisms.
147 ///
148 /// # Arguments
149 ///
150 /// * `options` - Configuration options for the client
151 /// * `transport` - Custom transport implementation
152 ///
153 /// # Example
154 ///
155 /// ```rust,no_run
156 /// # use cc_sdk::{ClaudeSDKClient, ClaudeCodeOptions, SubprocessTransport};
157 /// # fn example() {
158 /// let options = ClaudeCodeOptions::default();
159 /// let transport = SubprocessTransport::with_cli_path(options.clone(), "/custom/path/claude-code");
160 /// let client = ClaudeSDKClient::with_transport(options, Box::new(transport));
161 /// # }
162 /// ```
163 pub fn with_transport(options: ClaudeCodeOptions, transport: Box<dyn Transport + Send>) -> Self {
164 // Set environment variable to indicate SDK usage
165 unsafe {
166 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "sdk-rust");
167 }
168
169 // Wrap transport in Arc for sharing
170 let transport_arc: Arc<Mutex<Box<dyn Transport + Send>>> =
171 Arc::new(Mutex::new(transport));
172
173 Self::with_transport_internal(options, transport_arc)
174 }
175
176 /// Internal helper to construct client with pre-wrapped transport
177 fn with_transport_internal(
178 options: ClaudeCodeOptions,
179 transport_arc: Arc<Mutex<Box<dyn Transport + Send>>>,
180 ) -> Self {
181 // Create query handler if control protocol features are enabled
182 let query_handler = if options.can_use_tool.is_some()
183 || options.hooks.is_some()
184 || !options.mcp_servers.is_empty() {
185 // Extract SDK MCP server instances
186 let sdk_mcp_servers: HashMap<String, Arc<dyn std::any::Any + Send + Sync>> = options.mcp_servers
187 .iter()
188 .filter_map(|(k, v)| {
189 // Only extract SDK type MCP servers
190 if let crate::types::McpServerConfig::Sdk { name: _, instance } = v {
191 Some((k.clone(), instance.clone()))
192 } else {
193 None
194 }
195 })
196 .collect();
197
198 // Enable streaming mode when control protocol is active
199 let is_streaming = options.can_use_tool.is_some()
200 || options.hooks.is_some()
201 || !sdk_mcp_servers.is_empty();
202
203 let query = Query::new(
204 transport_arc.clone(), // Share the same transport
205 is_streaming, // Enable streaming for control protocol
206 options.can_use_tool.clone(),
207 options.hooks.clone(),
208 sdk_mcp_servers,
209 );
210 Some(Arc::new(Mutex::new(query)))
211 } else {
212 None
213 };
214
215 Self {
216 options,
217 transport: transport_arc,
218 query_handler,
219 state: Arc::new(RwLock::new(ClientState::Disconnected)),
220 sessions: Arc::new(RwLock::new(HashMap::new())),
221 message_tx: Arc::new(Mutex::new(None)),
222 message_buffer: Arc::new(Mutex::new(Vec::new())),
223 request_counter: Arc::new(Mutex::new(0)),
224 budget_manager: BudgetManager::new(),
225 }
226 }
227
228 /// Connect to Claude CLI with an optional initial prompt
229 pub async fn connect(&mut self, initial_prompt: Option<String>) -> Result<()> {
230 // Check if already connected
231 {
232 let state = self.state.read().await;
233 if *state == ClientState::Connected {
234 return Ok(());
235 }
236 }
237
238 // Connect transport
239 {
240 let mut transport = self.transport.lock().await;
241 transport.connect().await?;
242 }
243
244 // Initialize query handler if present
245 if let Some(ref query_handler) = self.query_handler {
246 let mut handler = query_handler.lock().await;
247 handler.start().await?;
248 handler.initialize().await?;
249 info!("Initialized SDK control protocol");
250 }
251
252 // Update state
253 {
254 let mut state = self.state.write().await;
255 *state = ClientState::Connected;
256 }
257
258 info!("Connected to Claude CLI");
259
260 // Start message receiver task (always needed for regular messages)
261 self.start_message_receiver().await;
262
263 // Send initial prompt if provided
264 if let Some(prompt) = initial_prompt {
265 self.send_request(prompt, None).await?;
266 }
267
268 Ok(())
269 }
270
271 /// Send a user message to Claude
272 pub async fn send_user_message(&mut self, prompt: String) -> Result<()> {
273 // Check connection
274 {
275 let state = self.state.read().await;
276 if *state != ClientState::Connected {
277 return Err(SdkError::InvalidState {
278 message: "Not connected".into(),
279 });
280 }
281 }
282
283 // Use default session ID
284 let session_id = "default".to_string();
285
286 // Update session data
287 {
288 let mut sessions = self.sessions.write().await;
289 let session = sessions.entry(session_id.clone()).or_insert_with(|| {
290 debug!("Creating new session: {}", session_id);
291 SessionData {
292 id: session_id.clone(),
293 message_count: 0,
294 created_at: std::time::Instant::now(),
295 }
296 });
297 session.message_count += 1;
298 }
299
300 // Create and send message
301 let message = InputMessage::user(prompt, session_id.clone());
302
303 {
304 let mut transport = self.transport.lock().await;
305 transport.send_message(message).await?;
306 }
307
308 debug!("Sent request to Claude");
309 Ok(())
310 }
311
312 /// Send a request to Claude (alias for send_user_message with optional session_id)
313 pub async fn send_request(
314 &mut self,
315 prompt: String,
316 _session_id: Option<String>,
317 ) -> Result<()> {
318 // For now, ignore session_id and use send_user_message
319 self.send_user_message(prompt).await
320 }
321
322 /// Receive messages from Claude
323 ///
324 /// Returns a stream of messages. The stream will end when a Result message
325 /// is received or the connection is closed.
326 pub async fn receive_messages(&mut self) -> impl Stream<Item = Result<Message>> + use<> {
327 // Always use the regular message receiver
328 // (Query handler shares the same transport and receives control messages separately)
329 // Create a new channel for this receiver
330 let (tx, rx) = mpsc::channel(100);
331
332 // Get buffered messages and clear buffer
333 let buffered_messages = {
334 let mut buffer = self.message_buffer.lock().await;
335 std::mem::take(&mut *buffer)
336 };
337
338 // Send buffered messages to the new receiver
339 let tx_clone = tx.clone();
340 tokio::spawn(async move {
341 for msg in buffered_messages {
342 if tx_clone.send(Ok(msg)).await.is_err() {
343 break;
344 }
345 }
346 });
347
348 // Store the sender for the message receiver task
349 {
350 let mut message_tx = self.message_tx.lock().await;
351 *message_tx = Some(tx);
352 }
353
354 ReceiverStream::new(rx)
355 }
356
357 /// Send an interrupt request
358 pub async fn interrupt(&mut self) -> Result<()> {
359 // Check connection
360 {
361 let state = self.state.read().await;
362 if *state != ClientState::Connected {
363 return Err(SdkError::InvalidState {
364 message: "Not connected".into(),
365 });
366 }
367 }
368
369 // If we have a query handler, use it
370 if let Some(ref query_handler) = self.query_handler {
371 let mut handler = query_handler.lock().await;
372 return handler.interrupt().await;
373 }
374
375 // Otherwise use regular interrupt
376 // Generate request ID
377 let request_id = {
378 let mut counter = self.request_counter.lock().await;
379 *counter += 1;
380 format!("interrupt_{}", *counter)
381 };
382
383 // Send interrupt request
384 let request = ControlRequest::Interrupt {
385 request_id: request_id.clone(),
386 };
387
388 {
389 let mut transport = self.transport.lock().await;
390 transport.send_control_request(request).await?;
391 }
392
393 info!("Sent interrupt request: {}", request_id);
394
395 // Wait for acknowledgment (with timeout)
396 let transport = self.transport.clone();
397 let ack_task = tokio::spawn(async move {
398 let mut transport = transport.lock().await;
399 match tokio::time::timeout(
400 std::time::Duration::from_secs(5),
401 transport.receive_control_response(),
402 )
403 .await
404 {
405 Ok(Ok(Some(ControlResponse::InterruptAck {
406 request_id: ack_id,
407 success,
408 }))) => {
409 if ack_id == request_id && success {
410 Ok(())
411 } else {
412 Err(SdkError::ControlRequestError(
413 "Interrupt not acknowledged successfully".into(),
414 ))
415 }
416 }
417 Ok(Ok(None)) => Err(SdkError::ControlRequestError(
418 "No interrupt acknowledgment received".into(),
419 )),
420 Ok(Err(e)) => Err(e),
421 Err(_) => Err(SdkError::timeout(5)),
422 }
423 });
424
425 ack_task
426 .await
427 .map_err(|_| SdkError::ControlRequestError("Interrupt task panicked".into()))?
428 }
429
430 /// Check if the client is connected
431 pub async fn is_connected(&self) -> bool {
432 let state = self.state.read().await;
433 *state == ClientState::Connected
434 }
435
436 /// Get active session IDs
437 pub async fn get_sessions(&self) -> Vec<String> {
438 let sessions = self.sessions.read().await;
439 sessions.keys().cloned().collect()
440 }
441
442 /// Receive messages until and including a ResultMessage
443 ///
444 /// This is a convenience method that collects all messages from a single response.
445 /// It will automatically stop after receiving a ResultMessage.
446 pub async fn receive_response(&mut self) -> Pin<Box<dyn Stream<Item = Result<Message>> + Send + '_>> {
447 let mut messages = self.receive_messages().await;
448
449 // Create a stream that stops after ResultMessage
450 Box::pin(async_stream::stream! {
451 while let Some(msg_result) = messages.next().await {
452 match &msg_result {
453 Ok(Message::Result { .. }) => {
454 yield msg_result;
455 return;
456 }
457 _ => {
458 yield msg_result;
459 }
460 }
461 }
462 })
463 }
464
465 /// Get server information
466 ///
467 /// Returns initialization information from the Claude Code server including:
468 /// - Available commands
469 /// - Current and available output styles
470 /// - Server capabilities
471 pub async fn get_server_info(&self) -> Option<serde_json::Value> {
472 // If we have a query handler with control protocol, get from there
473 if let Some(ref query_handler) = self.query_handler {
474 let handler = query_handler.lock().await;
475 if let Some(init_result) = handler.get_initialization_result() {
476 return Some(init_result.clone());
477 }
478 }
479
480 // Otherwise check message buffer for init message
481 let buffer = self.message_buffer.lock().await;
482 for msg in buffer.iter() {
483 if let Message::System { subtype, data } = msg
484 && subtype == "init" {
485 return Some(data.clone());
486 }
487 }
488 None
489 }
490
491 /// Set permission mode dynamically
492 ///
493 /// Changes the permission mode during an active session.
494 /// Requires control protocol to be enabled (via can_use_tool, hooks, or mcp_servers).
495 ///
496 /// # Arguments
497 ///
498 /// * `mode` - Permission mode: "default", "acceptEdits", "plan", or "bypassPermissions"
499 ///
500 /// # Example
501 ///
502 /// ```rust,no_run
503 /// # use cc_sdk::{ClaudeSDKClient, ClaudeCodeOptions};
504 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
505 /// let mut client = ClaudeSDKClient::new(ClaudeCodeOptions::default());
506 /// client.connect(None).await?;
507 ///
508 /// // Switch to accept edits mode
509 /// client.set_permission_mode("acceptEdits").await?;
510 /// # Ok(())
511 /// # }
512 /// ```
513 pub async fn set_permission_mode(&mut self, mode: &str) -> Result<()> {
514 if let Some(ref query_handler) = self.query_handler {
515 let mut handler = query_handler.lock().await;
516 handler.set_permission_mode(mode).await
517 } else {
518 Err(SdkError::InvalidState {
519 message: "Query handler not initialized. Control protocol features required (enable can_use_tool, hooks, or mcp_servers).".to_string(),
520 })
521 }
522 }
523
524 /// Set model dynamically
525 ///
526 /// Changes the active model during an active session.
527 /// Requires control protocol to be enabled (via can_use_tool, hooks, or mcp_servers).
528 ///
529 /// # Arguments
530 ///
531 /// * `model` - Model identifier (e.g., "claude-3-5-sonnet-20241022") or None to use default
532 ///
533 /// # Example
534 ///
535 /// ```rust,no_run
536 /// # use cc_sdk::{ClaudeSDKClient, ClaudeCodeOptions};
537 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
538 /// let mut client = ClaudeSDKClient::new(ClaudeCodeOptions::default());
539 /// client.connect(None).await?;
540 ///
541 /// // Switch to a different model
542 /// client.set_model(Some("claude-3-5-sonnet-20241022".to_string())).await?;
543 /// # Ok(())
544 /// # }
545 /// ```
546 pub async fn set_model(&mut self, model: Option<String>) -> Result<()> {
547 if let Some(ref query_handler) = self.query_handler {
548 let mut handler = query_handler.lock().await;
549 handler.set_model(model).await
550 } else {
551 Err(SdkError::InvalidState {
552 message: "Query handler not initialized. Control protocol features required (enable can_use_tool, hooks, or mcp_servers).".to_string(),
553 })
554 }
555 }
556
557 /// Send a query with optional session ID
558 ///
559 /// This method is similar to Python SDK's query method in ClaudeSDKClient
560 pub async fn query(&mut self, prompt: String, session_id: Option<String>) -> Result<()> {
561 let session_id = session_id.unwrap_or_else(|| "default".to_string());
562
563 // Send the message
564 let message = InputMessage::user(prompt, session_id);
565
566 {
567 let mut transport = self.transport.lock().await;
568 transport.send_message(message).await?;
569 }
570
571 Ok(())
572 }
573
574 /// Disconnect from Claude CLI
575 pub async fn disconnect(&mut self) -> Result<()> {
576 // Check if already disconnected
577 {
578 let state = self.state.read().await;
579 if *state == ClientState::Disconnected {
580 return Ok(());
581 }
582 }
583
584 // Disconnect transport
585 {
586 let mut transport = self.transport.lock().await;
587 transport.disconnect().await?;
588 }
589
590 // Update state
591 {
592 let mut state = self.state.write().await;
593 *state = ClientState::Disconnected;
594 }
595
596 // Clear sessions
597 {
598 let mut sessions = self.sessions.write().await;
599 sessions.clear();
600 }
601
602 info!("Disconnected from Claude CLI");
603 Ok(())
604 }
605
606 /// Start the message receiver task
607 async fn start_message_receiver(&mut self) {
608 let transport = self.transport.clone();
609 let message_tx = self.message_tx.clone();
610 let message_buffer = self.message_buffer.clone();
611 let state = self.state.clone();
612 let budget_manager = self.budget_manager.clone();
613
614 tokio::spawn(async move {
615 // Subscribe to messages without holding the lock
616 let mut stream = {
617 let mut transport = transport.lock().await;
618 transport.receive_messages()
619 }; // Lock is released here immediately
620
621 while let Some(result) = stream.next().await {
622 match result {
623 Ok(message) => {
624 // Update token usage for Result messages
625 if let Message::Result { .. } = &message
626 && let Message::Result { usage, total_cost_usd, .. } = &message {
627 let (input_tokens, output_tokens) = if let Some(usage_json) = usage {
628 let input = usage_json.get("input_tokens")
629 .and_then(|v| v.as_u64())
630 .unwrap_or(0);
631 let output = usage_json.get("output_tokens")
632 .and_then(|v| v.as_u64())
633 .unwrap_or(0);
634 (input, output)
635 } else {
636 (0, 0)
637 };
638 let cost = total_cost_usd.unwrap_or(0.0);
639 budget_manager.update_usage(input_tokens, output_tokens, cost).await;
640 }
641
642 // Buffer init messages for get_server_info()
643 if let Message::System { subtype, .. } = &message
644 && subtype == "init" {
645 let mut buffer = message_buffer.lock().await;
646 buffer.push(message.clone());
647 }
648
649 // Try to send to current receiver
650 let sent = {
651 let mut tx_opt = message_tx.lock().await;
652 if let Some(tx) = tx_opt.as_mut() {
653 tx.send(Ok(message.clone())).await.is_ok()
654 } else {
655 false
656 }
657 };
658
659 // If no receiver or send failed, buffer the message
660 if !sent {
661 let mut buffer = message_buffer.lock().await;
662 buffer.push(message);
663 }
664 }
665 Err(e) => {
666 error!("Error receiving message: {}", e);
667
668 // Send error to receiver if available
669 let mut tx_opt = message_tx.lock().await;
670 if let Some(tx) = tx_opt.as_mut() {
671 let _ = tx.send(Err(e)).await;
672 }
673
674 // Update state on error
675 let mut state = state.write().await;
676 *state = ClientState::Error;
677 break;
678 }
679 }
680 }
681
682 debug!("Message receiver task ended");
683 });
684 }
685
686 /// Get token usage statistics
687 ///
688 /// Returns the current token usage tracker with cumulative statistics
689 /// for all queries executed by this client.
690 pub async fn get_usage_stats(&self) -> crate::token_tracker::TokenUsageTracker {
691 self.budget_manager.get_usage().await
692 }
693
694 /// Set budget limit with optional warning callback
695 ///
696 /// # Arguments
697 ///
698 /// * `limit` - Budget limit configuration (cost and/or token caps)
699 /// * `on_warning` - Optional callback function triggered when usage exceeds warning threshold
700 ///
701 /// # Example
702 ///
703 /// ```rust,no_run
704 /// use cc_sdk::{ClaudeSDKClient, ClaudeCodeOptions};
705 /// use cc_sdk::token_tracker::{BudgetLimit, BudgetWarningCallback};
706 /// use std::sync::Arc;
707 ///
708 /// # async fn example() {
709 /// let mut client = ClaudeSDKClient::new(ClaudeCodeOptions::default());
710 ///
711 /// // Set budget with callback
712 /// let cb: BudgetWarningCallback = Arc::new(|msg: &str| println!("Budget warning: {}", msg));
713 /// client.set_budget_limit(BudgetLimit::with_cost(5.0), Some(cb)).await;
714 /// # }
715 /// ```
716 pub async fn set_budget_limit(
717 &self,
718 limit: crate::token_tracker::BudgetLimit,
719 on_warning: Option<crate::token_tracker::BudgetWarningCallback>,
720 ) {
721 self.budget_manager.set_limit(limit).await;
722 if let Some(callback) = on_warning {
723 self.budget_manager.set_warning_callback(callback).await;
724 }
725 }
726
727 /// Clear budget limit and reset warning state
728 pub async fn clear_budget_limit(&self) {
729 self.budget_manager.clear_limit().await;
730 }
731
732 /// Reset token usage statistics to zero
733 ///
734 /// Clears all accumulated token and cost statistics.
735 /// Budget limits remain in effect.
736 pub async fn reset_usage_stats(&self) {
737 self.budget_manager.reset_usage().await;
738 }
739
740 /// Check if budget has been exceeded
741 ///
742 /// Returns true if current usage exceeds any configured limits
743 pub async fn is_budget_exceeded(&self) -> bool {
744 self.budget_manager.is_exceeded().await
745 }
746
747 // Removed unused helper; usage is updated inline in message receiver
748}
749
750impl Drop for ClaudeSDKClient {
751 fn drop(&mut self) {
752 // Try to disconnect gracefully
753 let transport = self.transport.clone();
754 let state = self.state.clone();
755
756 tokio::spawn(async move {
757 let state = state.read().await;
758 if *state == ClientState::Connected {
759 let mut transport = transport.lock().await;
760 if let Err(e) = transport.disconnect().await {
761 debug!("Error disconnecting in drop: {}", e);
762 }
763 }
764 });
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 #[tokio::test]
773 async fn test_client_lifecycle() {
774 let options = ClaudeCodeOptions::default();
775 let client = ClaudeSDKClient::new(options);
776
777 assert!(!client.is_connected().await);
778 assert_eq!(client.get_sessions().await.len(), 0);
779 }
780
781 #[tokio::test]
782 async fn test_client_state_transitions() {
783 let options = ClaudeCodeOptions::default();
784 let client = ClaudeSDKClient::new(options);
785
786 let state = client.state.read().await;
787 assert_eq!(*state, ClientState::Disconnected);
788 }
789}