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}