Skip to main content

claude_code_acp/session/
session.rs

1//! Session state and management
2//!
3//! Each session represents an active Claude conversation with its own
4//! ClaudeClient instance, usage tracking, and permission state.
5
6use dashmap::DashMap;
7use std::collections::{BTreeMap, HashMap};
8use std::error::Error;
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::sync::OnceLock;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::time::Instant;
14use tokio::sync::broadcast;
15
16use claude_code_agent_sdk::types::config::PermissionMode as SdkPermissionMode;
17use claude_code_agent_sdk::types::mcp::McpSdkServerConfig;
18use claude_code_agent_sdk::{
19    ClaudeAgentOptions, ClaudeClient, HookEvent, HookMatcher, McpServerConfig, McpServers,
20    SystemPrompt, SystemPromptPreset,
21};
22use sacp::JrConnectionCx;
23use sacp::link::AgentToClient;
24use sacp::schema::McpServer;
25use tokio::sync::RwLock;
26use tracing::instrument;
27
28use crate::converter::NotificationConverter;
29use crate::hooks::{HookCallbackRegistry, create_post_tool_use_hook, create_pre_tool_use_hook};
30use crate::mcp::AcpMcpServer;
31use crate::permissions::create_can_use_tool_callback;
32use crate::settings::{PermissionChecker, SettingsManager};
33use crate::terminal::TerminalClient;
34use crate::types::{AgentConfig, AgentError, NewSessionMeta, Result};
35
36use super::BackgroundProcessManager;
37use super::permission::{PermissionHandler, PermissionMode};
38use super::usage::UsageTracker;
39
40/// Get the list of tools that should be replaced by ACP MCP server tools.
41///
42/// Only tools that interact with the terminal or filesystem should be replaced:
43/// - Terminal tools: Bash, BashOutput, KillShell
44/// - File tools: Read, Write, Edit
45///
46/// Other tools like Glob, Grep, Task, etc. should remain as CLI built-in tools.
47fn get_acp_replacement_tools() -> Vec<&'static str> {
48    vec![
49        // Terminal tools - must be replaced to use ACP Terminal API
50        "Bash",
51        "BashOutput",
52        "KillShell",
53        // File tools - must be replaced for ACP file synchronization
54        "Read",
55        "Write",
56        "Edit",
57    ]
58}
59
60/// An active Claude session
61///
62/// Each session holds its own ClaudeClient instance and maintains
63/// independent state for usage tracking, permissions, and message conversion.
64pub struct Session {
65    /// Unique session identifier
66    pub session_id: String,
67    /// Working directory for this session
68    pub cwd: PathBuf,
69    /// The Claude client for this session
70    client: RwLock<ClaudeClient>,
71    /// Whether this session has been cancelled
72    cancelled: Arc<AtomicBool>,
73    /// Permission handler for tool execution (wrapped in Arc for can_use_tool callback)
74    permission: Arc<RwLock<PermissionHandler>>,
75    /// Token usage tracker
76    usage_tracker: UsageTracker,
77    /// Notification converter with tool use cache
78    converter: NotificationConverter,
79    /// Whether the client is connected
80    connected: AtomicBool,
81    /// Hook callback registry for PostToolUse callbacks
82    hook_callback_registry: Arc<HookCallbackRegistry>,
83    /// Permission checker for hooks
84    permission_checker: Arc<RwLock<PermissionChecker>>,
85    /// Shared permission mode (synced with permission handler and hooks)
86    permission_mode: Arc<RwLock<PermissionMode>>,
87    /// Current model ID for this session (set once during initialization)
88    current_model: OnceLock<String>,
89    /// ACP MCP server for tool execution with notifications
90    acp_mcp_server: Arc<AcpMcpServer>,
91    /// Background process manager
92    background_processes: Arc<BackgroundProcessManager>,
93    /// External MCP servers to connect (from client request)
94    /// Set once during session initialization via set_external_mcp_servers()
95    external_mcp_servers: OnceLock<Vec<McpServer>>,
96    /// Whether external MCP servers have been connected
97    external_mcp_connected: AtomicBool,
98    /// Connection context OnceLock for ACP requests (shared with hooks)
99    /// Used by pre_tool_use_hook for permission requests
100    connection_cx_lock: Arc<OnceLock<JrConnectionCx<AgentToClient>>>,
101    /// Cancel signal sender - used to notify when MCP cancellation is received
102    cancel_sender: broadcast::Sender<()>,
103    /// Cache for permission results by tool_input
104    /// PreToolUse hook saves authorized results here, can_use_tool callback checks it
105    /// Key: JSON string of tool_input, Value: true if authorized
106    /// Only stores authorized results (denied tools don't execute, no need to cache)
107    permission_cache: Arc<DashMap<String, bool>>,
108    /// Cache for tool_use_id by tool_input
109    /// PreToolUse hook caches this when Ask decision is made
110    /// can_use_tool callback uses this to get tool_use_id when CLI doesn't provide it
111    /// Key: stable cache key of tool_input, Value: tool_use_id
112    tool_use_id_cache: Arc<DashMap<String, String>>,
113}
114
115/// Generate a stable cache key from JSON value
116///
117/// JSON serialization order is not guaranteed to be stable.
118/// This function canonicalizes the JSON by sorting object keys using BTreeMap,
119/// ensuring identical content always produces the same cache key.
120pub fn stable_cache_key(tool_input: &serde_json::Value) -> String {
121    fn canonicalize(value: &serde_json::Value) -> serde_json::Value {
122        match value {
123            serde_json::Value::Object(map) => {
124                // Use BTreeMap to ensure keys are sorted
125                let sorted: BTreeMap<_, _> = map
126                    .iter()
127                    .map(|(k, v)| (k.clone(), canonicalize(v)))
128                    .collect();
129                serde_json::Value::Object(sorted.into_iter().collect())
130            }
131            serde_json::Value::Array(arr) => {
132                serde_json::Value::Array(arr.iter().map(canonicalize).collect())
133            }
134            other => other.clone(),
135        }
136    }
137    canonicalize(tool_input).to_string()
138}
139
140impl Session {
141    /// Create a new session and wrap in Arc
142    ///
143    /// Returns Arc<Self> because the can_use_tool callback needs Arc<Session>.
144    /// We use OnceLock to break the circular dependency between Session and callback.
145    ///
146    /// # Arguments
147    ///
148    /// * `session_id` - Unique identifier for this session
149    /// * `cwd` - Working directory
150    /// * `config` - Agent configuration from environment
151    /// * `meta` - Session metadata from the new session request
152    #[instrument(
153        name = "session_create",
154        skip(config, meta),
155        fields(
156            session_id = %session_id,
157            cwd = ?cwd,
158            has_meta = meta.is_some(),
159        )
160    )]
161    pub fn new(
162        session_id: String,
163        cwd: PathBuf,
164        config: &AgentConfig,
165        meta: Option<&NewSessionMeta>,
166    ) -> Result<Arc<Self>> {
167        let start_time = Instant::now();
168
169        tracing::info!(
170            session_id = %session_id,
171            cwd = ?cwd,
172            "Creating new session"
173        );
174
175        // Create hook callback registry
176        let hook_callback_registry = Arc::new(HookCallbackRegistry::new());
177
178        // Create permission checker for hooks
179        // Load settings from ~/.claude/settings.json, .claude/settings.json, etc.
180        let settings_manager = SettingsManager::new(&cwd)
181            .unwrap_or_else(|e| {
182                tracing::warn!("Failed to load settings manager from cwd: {}. Using default settings.", e);
183                // Fallback: try to load settings from home directory
184                match dirs::home_dir() {
185                    Some(home) => {
186                        tracing::info!("Attempting to load settings from home directory");
187                        SettingsManager::new(&home).unwrap_or_else(|e2| {
188                            tracing::error!("Failed to load settings from home directory: {}. Using minimal default settings.", e2);
189                            // Last resort: create a manager with minimal settings
190                            SettingsManager::new_with_settings(crate::settings::Settings::default(), "/")
191                        })
192                    }
193                    None => {
194                        tracing::error!("No home directory found. Using minimal default settings.");
195                        SettingsManager::new_with_settings(crate::settings::Settings::default(), "/")
196                    }
197                }
198            });
199        // Create shared permission checker that will be used by both hook and permission handler
200        // This ensures that runtime rule changes (e.g., "Always Allow") are reflected in both places
201        let permission_checker = Arc::new(RwLock::new(PermissionChecker::new(
202            settings_manager.settings().clone(),
203            &cwd,
204        )));
205
206        // Create shared permission mode that can be updated at runtime
207        // This is shared between the hook and the permission handler
208        // Default to Default mode (standard behavior with permission prompts)
209        let permission_mode = Arc::new(RwLock::new(PermissionMode::Default));
210
211        // Create shared connection_cx_lock for hook permission requests
212        let connection_cx_lock: Arc<OnceLock<JrConnectionCx<AgentToClient>>> =
213            Arc::new(OnceLock::new());
214
215        // Create shared permission_cache for hook-to-callback communication
216        // PreToolUse hook caches permission results, can_use_tool callback checks it
217        let permission_cache: Arc<DashMap<String, bool>> = Arc::new(DashMap::new());
218
219        // Create shared tool_use_id_cache for hook-to-callback tool_use_id passing
220        // PreToolUse hook caches tool_use_id when Ask decision is made
221        // can_use_tool callback uses this when CLI doesn't provide tool_use_id
222        let tool_use_id_cache: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
223
224        // Create hooks with shared permission checker and mode
225        let pre_tool_use_hook = create_pre_tool_use_hook(
226            connection_cx_lock.clone(),
227            session_id.clone(),
228            Some(permission_checker.clone()),
229            permission_mode.clone(),
230            permission_cache.clone(),
231            tool_use_id_cache.clone(),
232        );
233        let post_tool_use_hook = create_post_tool_use_hook(hook_callback_registry.clone());
234
235        // Build hooks map
236        let mut hooks_map: HashMap<HookEvent, Vec<HookMatcher>> = HashMap::new();
237        hooks_map.insert(
238            HookEvent::PreToolUse,
239            vec![
240                HookMatcher::builder()
241                    .hooks(vec![pre_tool_use_hook])
242                    .build(),
243            ],
244        );
245        hooks_map.insert(
246            HookEvent::PostToolUse,
247            vec![
248                HookMatcher::builder()
249                    .hooks(vec![post_tool_use_hook])
250                    .build(),
251            ],
252        );
253
254        tracing::info!(
255            session_id = %session_id,
256            hooks_count = 2,
257            "Hooks configured: PreToolUse, PostToolUse"
258        );
259
260        // Create PermissionHandler with shared PermissionChecker
261        // This ensures both pre_tool_use_hook and can_use_tool callback use the same rules
262        // PermissionHandler uses AcceptEdits mode (compatible with root, allows all tools)
263        let permission_handler = Arc::new(RwLock::new(PermissionHandler::with_checker(
264            permission_checker.clone(),
265        )));
266
267        // Create OnceLock for storing Arc<Session> (needed for callback)
268        let session_lock: Arc<OnceLock<Arc<Session>>> = Arc::new(OnceLock::new());
269
270        // Create ACP MCP server
271        let acp_mcp_server = Arc::new(AcpMcpServer::new("acp", env!("CARGO_PKG_VERSION")));
272
273        // Create background process manager
274        let background_processes = Arc::new(BackgroundProcessManager::new());
275
276        // Build MCP servers with our ACP server
277        let mut mcp_servers_dict = HashMap::new();
278        mcp_servers_dict.insert(
279            "acp".to_string(),
280            McpServerConfig::Sdk(McpSdkServerConfig {
281                name: "acp".to_string(),
282                instance: acp_mcp_server.clone(),
283            }),
284        );
285
286        tracing::info!(
287            session_id = %session_id,
288            mcp_server_count = mcp_servers_dict.len(),
289            "MCP servers configured"
290        );
291
292        // Create can_use_tool callback with OnceLock<Session>
293        let can_use_tool_callback = create_can_use_tool_callback(session_lock.clone());
294
295        // Build ClaudeAgentOptions
296        //
297        // Note: We use AcceptEdits instead of BypassPermissions because
298        // BypassPermissions mode cannot be used with root/sudo privileges
299        // for security reasons (Claude CLI enforces this restriction).
300        // AcceptEdits allows tool execution without permission prompts while
301        // being compatible with root user environments.
302        let mut options = ClaudeAgentOptions::builder()
303            .cwd(cwd.clone())
304            .hooks(hooks_map)
305            .mcp_servers(McpServers::Dict(mcp_servers_dict))
306            .can_use_tool(can_use_tool_callback)
307            .permission_mode(SdkPermissionMode::AcceptEdits)
308            .build();
309
310        // Debug: Verify can_use_tool is set
311        tracing::info!(
312            session_id = %session_id,
313            has_can_use_tool = options.can_use_tool.is_some(),
314            has_hooks = options.hooks.is_some(),
315            "Options configured after build"
316        );
317
318        // Verify mcp_servers is set correctly
319        match &options.mcp_servers {
320            McpServers::Dict(dict) => {
321                tracing::debug!(
322                    session_id = %session_id,
323                    servers = ?dict.keys().collect::<Vec<_>>(),
324                    "MCP servers registered"
325                );
326            }
327            McpServers::Empty => {
328                tracing::warn!(
329                    session_id = %session_id,
330                    "MCP servers is Empty - this is unexpected!"
331                );
332            }
333            McpServers::Path(p) => {
334                tracing::warn!(
335                    session_id = %session_id,
336                    path = ?p,
337                    "MCP servers is Path - this is unexpected!"
338                );
339            }
340        }
341
342        // Configure ACP tools to replace CLI built-in tools
343        // This disables CLI's built-in tools and enables our MCP tools with mcp__acp__ prefix
344        let acp_tools = get_acp_replacement_tools();
345        options.use_acp_tools(&acp_tools);
346
347        // Enable streaming to receive incremental content updates
348        // This allows SDK to send StreamEvent messages with content_block_delta
349        options.include_partial_messages = true;
350
351        tracing::debug!(
352            session_id = %session_id,
353            acp_tools = ?acp_tools,
354            disallowed_tools = ?options.disallowed_tools,
355            allowed_tools = ?options.allowed_tools,
356            "ACP tools configured"
357        );
358
359        // Apply config from environment
360        config.apply_to_options(&mut options);
361
362        tracing::debug!(
363            session_id = %session_id,
364            model = ?options.model,
365            fallback_model = ?options.fallback_model,
366            max_thinking_tokens = ?options.max_thinking_tokens,
367            base_url = ?config.base_url,
368            api_key = ?config.masked_api_key(),
369            env_vars_count = options.env.len(),
370            "Agent config applied"
371        );
372
373        // Apply meta options if provided
374        if let Some(meta) = meta {
375            // Set system prompt: replace takes priority over append
376            if let Some(replace) = meta.get_system_prompt_replace() {
377                // Complete replacement of system prompt
378                options.system_prompt = Some(SystemPrompt::Text(replace.to_string()));
379                tracing::info!(
380                    session_id = %session_id,
381                    prompt_len = replace.len(),
382                    "Using custom system prompt from meta (replace)"
383                );
384            } else if let Some(append) = meta.get_system_prompt_append() {
385                // Append to default claude_code preset
386                let preset = SystemPromptPreset::with_append("claude_code", append);
387                options.system_prompt = Some(SystemPrompt::Preset(preset));
388                tracing::info!(
389                    session_id = %session_id,
390                    append_len = append.len(),
391                    "Appending to system prompt from meta"
392                );
393            }
394
395            // Set resume session if provided
396            if let Some(resume_id) = meta.get_resume_session_id() {
397                options.resume = Some(resume_id.to_string());
398                tracing::info!(
399                    session_id = %session_id,
400                    resume_session_id = %resume_id,
401                    "Resuming from previous session"
402                );
403            }
404
405            // Set max thinking tokens if provided (enables extended thinking mode)
406            if let Some(tokens) = meta.get_max_thinking_tokens() {
407                options.max_thinking_tokens = Some(tokens);
408                tracing::info!(
409                    session_id = %session_id,
410                    max_thinking_tokens = tokens,
411                    "Extended thinking mode enabled via meta"
412                );
413            }
414        }
415
416        // Create the client
417        let client = ClaudeClient::new(options);
418
419        let elapsed = start_time.elapsed();
420        tracing::info!(
421            session_id = %session_id,
422            elapsed_ms = elapsed.as_millis(),
423            "Session created successfully"
424        );
425
426        // Clone cwd for converter before moving cwd into the struct
427        let cwd_for_converter = cwd.clone();
428
429        // Build the Session struct
430        let session = Self {
431            session_id,
432            cwd,
433            client: RwLock::new(client),
434            cancelled: Arc::new(AtomicBool::new(false)),
435            permission: permission_handler,
436            usage_tracker: UsageTracker::new(),
437            converter: NotificationConverter::with_cwd(cwd_for_converter),
438            connected: AtomicBool::new(false),
439            hook_callback_registry,
440            permission_checker,
441            permission_mode,
442            current_model: OnceLock::new(),
443            acp_mcp_server,
444            background_processes,
445            external_mcp_servers: OnceLock::new(),
446            external_mcp_connected: AtomicBool::new(false),
447            connection_cx_lock,
448            cancel_sender: broadcast::channel(1).0,
449            permission_cache,
450            tool_use_id_cache,
451        };
452
453        // Wrap in Arc
454        let session_arc = Arc::new(session);
455
456        // Set the OnceLock so the callback can access the Session
457        drop(session_lock.set(session_arc.clone()));
458
459        Ok(session_arc)
460    }
461
462    /// Set external MCP servers to connect
463    ///
464    /// # Arguments
465    ///
466    /// * `servers` - List of MCP servers from the client request
467    pub fn set_external_mcp_servers(&self, servers: Vec<McpServer>) {
468        if !servers.is_empty() {
469            tracing::info!(
470                session_id = %self.session_id,
471                server_count = servers.len(),
472                "Storing external MCP servers for later connection"
473            );
474
475            for server in &servers {
476                match server {
477                    McpServer::Stdio(s) => {
478                        tracing::debug!(
479                            session_id = %self.session_id,
480                            server_name = %s.name,
481                            command = ?s.command,
482                            args = ?s.args,
483                            "External MCP server (stdio)"
484                        );
485                    }
486                    McpServer::Http(s) => {
487                        tracing::debug!(
488                            session_id = %self.session_id,
489                            server_name = %s.name,
490                            url = %s.url,
491                            "External MCP server (http)"
492                        );
493                    }
494                    McpServer::Sse(s) => {
495                        tracing::debug!(
496                            session_id = %self.session_id,
497                            server_name = %s.name,
498                            url = %s.url,
499                            "External MCP server (sse)"
500                        );
501                    }
502                    _ => {
503                        tracing::debug!(
504                            session_id = %self.session_id,
505                            "External MCP server (unknown type)"
506                        );
507                    }
508                }
509            }
510        }
511
512        // Only set if not already set (configure_acp_server may be called multiple times)
513        if self.external_mcp_servers.get().is_none() {
514            drop(self.external_mcp_servers.set(servers));
515        }
516    }
517
518    /// Set the connection context for ACP requests
519    ///
520    /// This is called once during handle_prompt to enable permission requests.
521    /// The OnceLock ensures it's only set once even if called multiple times.
522    pub fn set_connection_cx(&self, cx: JrConnectionCx<AgentToClient>) {
523        if self.connection_cx_lock.get().is_none() {
524            drop(self.connection_cx_lock.set(cx));
525        }
526    }
527
528    /// Get the connection context if available
529    ///
530    /// Returns None if called before handle_prompt sets the connection.
531    pub fn get_connection_cx(&self) -> Option<&JrConnectionCx<AgentToClient>> {
532        self.connection_cx_lock.get()
533    }
534
535    /// Cache a permission result for a tool_input
536    ///
537    /// Called by PreToolUse hook after user grants permission.
538    /// The can_use_tool callback checks this cache before sending permission requests.
539    pub fn cache_permission(&self, tool_input: &serde_json::Value, allowed: bool) {
540        let key = stable_cache_key(tool_input);
541        tracing::debug!(
542            key_len = key.len(),
543            allowed = allowed,
544            "Caching permission result"
545        );
546        self.permission_cache.insert(key, allowed);
547    }
548
549    /// Check if a tool_input has cached permission
550    ///
551    /// Called by can_use_tool callback to check if permission was already granted.
552    /// Returns Some(true) if allowed, Some(false) if denied, None if not cached.
553    /// Removes the entry from cache after retrieval (one-time use).
554    pub fn check_cached_permission(&self, tool_input: &serde_json::Value) -> Option<bool> {
555        let key = stable_cache_key(tool_input);
556        self.permission_cache.remove(&key).map(|(_, v)| v)
557    }
558
559    /// Get a reference to the permission_cache for sharing with hooks
560    pub fn permission_cache(&self) -> Arc<DashMap<String, bool>> {
561        Arc::clone(&self.permission_cache)
562    }
563
564    /// Cache tool_use_id for a tool_input
565    ///
566    /// Called by PreToolUse hook when Ask decision is made.
567    /// The can_use_tool callback uses this to get tool_use_id when CLI doesn't provide it.
568    pub fn cache_tool_use_id(&self, tool_input: &serde_json::Value, tool_use_id: &str) {
569        let key = stable_cache_key(tool_input);
570        tracing::debug!(
571            key_len = key.len(),
572            tool_use_id = %tool_use_id,
573            "Caching tool_use_id"
574        );
575        self.tool_use_id_cache.insert(key, tool_use_id.to_string());
576    }
577
578    /// Get cached tool_use_id for a tool_input
579    ///
580    /// Called by can_use_tool callback to get tool_use_id when CLI doesn't provide it.
581    /// Returns the tool_use_id if cached, None otherwise.
582    /// Removes the entry from cache after retrieval (one-time use).
583    pub fn get_cached_tool_use_id(&self, tool_input: &serde_json::Value) -> Option<String> {
584        let key = stable_cache_key(tool_input);
585        self.tool_use_id_cache.remove(&key).map(|(_, v)| v)
586    }
587
588    /// Get a reference to the tool_use_id_cache for sharing with hooks
589    pub fn tool_use_id_cache(&self) -> Arc<DashMap<String, String>> {
590        Arc::clone(&self.tool_use_id_cache)
591    }
592
593    /// Connect to external MCP servers
594    ///
595    /// This should be called before the first prompt to ensure all
596    /// external MCP tools are available.
597    #[instrument(
598        name = "connect_external_mcp_servers",
599        skip(self),
600        fields(session_id = %self.session_id)
601    )]
602    pub async fn connect_external_mcp_servers(&self) -> Result<()> {
603        // Only connect once
604        if self.external_mcp_connected.load(Ordering::SeqCst) {
605            tracing::debug!(
606                session_id = %self.session_id,
607                "External MCP servers already connected"
608            );
609            return Ok(());
610        }
611
612        // Get servers (no lock needed with OnceLock)
613        let servers = match self.external_mcp_servers.get() {
614            Some(s) => s,
615            None => {
616                tracing::debug!(
617                    session_id = %self.session_id,
618                    "No external MCP servers to connect"
619                );
620                self.external_mcp_connected.store(true, Ordering::SeqCst);
621                return Ok(());
622            }
623        };
624
625        // Clone server list to avoid holding reference
626        let servers_vec: Vec<_> = servers.iter().cloned().collect();
627
628        let server_count = servers_vec.len();
629        let start_time = Instant::now();
630
631        tracing::info!(
632            session_id = %self.session_id,
633            server_count = server_count,
634            "Connecting to external MCP servers"
635        );
636
637        let external_manager = self.acp_mcp_server.mcp_server().external_manager();
638
639        let mut success_count = 0;
640        let mut error_count = 0;
641
642        for server in servers_vec.iter() {
643            match server {
644                McpServer::Stdio(s) => {
645                    let server_start = Instant::now();
646
647                    tracing::info!(
648                        session_id = %self.session_id,
649                        server_name = %s.name,
650                        command = ?s.command,
651                        args = ?s.args,
652                        "Connecting to external MCP server (stdio)"
653                    );
654
655                    // Convert env variables
656                    let env: Option<HashMap<String, String>> = if s.env.is_empty() {
657                        None
658                    } else {
659                        Some(
660                            s.env
661                                .iter()
662                                .map(|e| (e.name.clone(), e.value.clone()))
663                                .collect(),
664                        )
665                    };
666
667                    match external_manager
668                        .connect(
669                            s.name.clone(),
670                            s.command.to_string_lossy().as_ref(),
671                            &s.args,
672                            env.as_ref(),
673                            Some(self.cwd.as_path()),
674                        )
675                        .await
676                    {
677                        Ok(_) => {
678                            success_count += 1;
679                            let elapsed = server_start.elapsed();
680                            tracing::info!(
681                                session_id = %self.session_id,
682                                server_name = %s.name,
683                                elapsed_ms = elapsed.as_millis(),
684                                "Successfully connected to external MCP server"
685                            );
686                        }
687                        Err(e) => {
688                            error_count += 1;
689                            let elapsed = server_start.elapsed();
690                            tracing::error!(
691                                session_id = %self.session_id,
692                                server_name = %s.name,
693                                error = %e,
694                                elapsed_ms = elapsed.as_millis(),
695                                "Failed to connect to external MCP server"
696                            );
697                        }
698                    }
699                }
700                McpServer::Http(s) => {
701                    tracing::warn!(
702                        session_id = %self.session_id,
703                        server_name = %s.name,
704                        url = %s.url,
705                        "HTTP MCP servers not yet supported"
706                    );
707                }
708                McpServer::Sse(s) => {
709                    tracing::warn!(
710                        session_id = %self.session_id,
711                        server_name = %s.name,
712                        url = %s.url,
713                        "SSE MCP servers not yet supported"
714                    );
715                }
716                _ => {
717                    tracing::warn!(
718                        session_id = %self.session_id,
719                        "Unknown MCP server type - not supported"
720                    );
721                }
722            }
723        }
724
725        let total_elapsed = start_time.elapsed();
726        tracing::info!(
727            session_id = %self.session_id,
728            total_servers = server_count,
729            success_count = success_count,
730            error_count = error_count,
731            total_elapsed_ms = total_elapsed.as_millis(),
732            "Finished connecting external MCP servers"
733        );
734
735        self.external_mcp_connected.store(true, Ordering::SeqCst);
736        Ok(())
737    }
738
739    /// Connect to Claude CLI
740    ///
741    /// This spawns the Claude CLI process and establishes JSON-RPC communication.
742    #[instrument(
743        name = "session_connect",
744        skip(self),
745        fields(session_id = %self.session_id)
746    )]
747    pub async fn connect(&self) -> Result<()> {
748        if self.connected.load(Ordering::SeqCst) {
749            tracing::debug!(
750                session_id = %self.session_id,
751                "Already connected to Claude CLI"
752            );
753            return Ok(());
754        }
755
756        let start_time = Instant::now();
757        tracing::info!(
758            session_id = %self.session_id,
759            cwd = ?self.cwd,
760            "Connecting to Claude CLI..."
761        );
762
763        let mut client = self.client.write().await;
764        client.connect().await.map_err(|e| {
765            let agent_error = AgentError::from(e);
766            tracing::error!(
767                session_id = %self.session_id,
768                error = %agent_error,
769                error_code = ?agent_error.error_code(),
770                is_retryable = %agent_error.is_retryable(),
771                error_chain = ?agent_error.source(),
772                "Failed to connect to Claude CLI"
773            );
774            agent_error
775        })?;
776
777        self.connected.store(true, Ordering::SeqCst);
778
779        let elapsed = start_time.elapsed();
780        tracing::info!(
781            session_id = %self.session_id,
782            elapsed_ms = elapsed.as_millis(),
783            "Successfully connected to Claude CLI"
784        );
785
786        Ok(())
787    }
788
789    /// Disconnect from Claude CLI
790    #[instrument(
791        name = "session_disconnect",
792        skip(self),
793        fields(session_id = %self.session_id)
794    )]
795    pub async fn disconnect(&self) -> Result<()> {
796        if !self.connected.load(Ordering::SeqCst) {
797            tracing::debug!(
798                session_id = %self.session_id,
799                "Already disconnected from Claude CLI"
800            );
801            return Ok(());
802        }
803
804        let start_time = Instant::now();
805        tracing::info!(
806            session_id = %self.session_id,
807            "Disconnecting from Claude CLI..."
808        );
809
810        let mut client = self.client.write().await;
811        client.disconnect().await.map_err(|e| {
812            let agent_error = AgentError::from(e);
813            tracing::error!(
814                session_id = %self.session_id,
815                error = %agent_error,
816                error_code = ?agent_error.error_code(),
817                is_retryable = %agent_error.is_retryable(),
818                error_chain = ?agent_error.source(),
819                "Failed to disconnect from Claude CLI"
820            );
821            agent_error
822        })?;
823
824        self.connected.store(false, Ordering::SeqCst);
825
826        let elapsed = start_time.elapsed();
827        tracing::info!(
828            session_id = %self.session_id,
829            elapsed_ms = elapsed.as_millis(),
830            "Disconnected from Claude CLI"
831        );
832
833        Ok(())
834    }
835
836    /// Check if the session is connected
837    pub fn is_connected(&self) -> bool {
838        self.connected.load(Ordering::SeqCst)
839    }
840
841    /// Get read access to the client
842    pub async fn client(&self) -> tokio::sync::RwLockReadGuard<'_, ClaudeClient> {
843        self.client.read().await
844    }
845
846    /// Get write access to the client
847    pub async fn client_mut(&self) -> tokio::sync::RwLockWriteGuard<'_, ClaudeClient> {
848        self.client.write().await
849    }
850
851    /// Get a receiver for cancel signals
852    ///
853    /// This can be used to listen for MCP cancellation notifications.
854    /// When a cancel notification is received, a signal is sent through the channel.
855    pub fn cancel_receiver(&self) -> broadcast::Receiver<()> {
856        self.cancel_sender.subscribe()
857    }
858
859    /// Check if the session has been cancelled
860    pub fn is_cancelled(&self) -> bool {
861        self.cancelled.load(Ordering::SeqCst)
862    }
863
864    /// Cancel this session and interrupt the Claude CLI
865    #[instrument(
866        name = "session_cancel",
867        skip(self),
868        fields(session_id = %self.session_id)
869    )]
870    pub async fn cancel(&self) {
871        tracing::info!(
872            session_id = %self.session_id,
873            "Cancelling session and sending interrupt signal"
874        );
875
876        self.cancelled.store(true, Ordering::SeqCst);
877
878        // Send interrupt signal to Claude CLI to stop current operation
879        if let Ok(client) = self.client.try_read() {
880            if let Err(e) = client.interrupt().await {
881                tracing::warn!(
882                    session_id = %self.session_id,
883                    error = %e,
884                    "Failed to send interrupt signal to Claude CLI"
885                );
886            } else {
887                tracing::info!(
888                    session_id = %self.session_id,
889                    "Interrupt signal sent to Claude CLI"
890                );
891            }
892        } else {
893            tracing::warn!(
894                session_id = %self.session_id,
895                "Could not acquire client lock for interrupt"
896            );
897        }
898    }
899
900    /// Reset the cancelled flag
901    pub fn reset_cancelled(&self) {
902        self.cancelled.store(false, Ordering::SeqCst);
903    }
904
905    /// Get the permission handler
906    pub async fn permission(&self) -> tokio::sync::RwLockReadGuard<'_, PermissionHandler> {
907        self.permission.read().await
908    }
909
910    /// Get the current permission mode
911    pub async fn permission_mode(&self) -> PermissionMode {
912        self.permission.read().await.mode()
913    }
914
915    /// Set the permission mode
916    ///
917    /// Updates both the PermissionHandler and the shared permission mode
918    /// used by the pre_tool_use_hook.
919    pub async fn set_permission_mode(&self, mode: PermissionMode) {
920        // Update the permission handler
921        self.permission.write().await.set_mode(mode);
922        // Also update the shared permission mode used by the hook
923        *self.permission_mode.write().await = mode;
924
925        tracing::info!(
926            session_id = %self.session_id,
927            mode = mode.as_str(),
928            "Permission mode updated"
929        );
930    }
931
932    /// Add an allow rule for a tool
933    ///
934    /// This is called when user selects "Always Allow" in permission prompt.
935    pub async fn add_permission_allow_rule(&self, tool_name: &str) {
936        self.permission.read().await.add_allow_rule(tool_name).await;
937    }
938
939    /// Get the current model ID
940    ///
941    /// Note: Not yet used because sacp SDK does not support SetSessionModel.
942    #[allow(dead_code)]
943    pub fn current_model(&self) -> Option<String> {
944        self.current_model.get().cloned()
945    }
946
947    /// Set the model for this session
948    ///
949    /// Note: Not yet used because sacp SDK does not support SetSessionModel.
950    #[allow(dead_code)]
951    pub fn set_model(&self, model_id: String) {
952        // Only set if not already set (may be called multiple times)
953        if self.current_model.get().is_none() {
954            drop(self.current_model.set(model_id));
955        }
956    }
957
958    /// Get the usage tracker
959    pub fn usage_tracker(&self) -> &UsageTracker {
960        &self.usage_tracker
961    }
962
963    /// Get the notification converter
964    pub fn converter(&self) -> &NotificationConverter {
965        &self.converter
966    }
967
968    /// Get the hook callback registry
969    pub fn hook_callback_registry(&self) -> &Arc<HookCallbackRegistry> {
970        &self.hook_callback_registry
971    }
972
973    /// Get the permission checker
974    pub fn permission_checker(&self) -> &Arc<RwLock<PermissionChecker>> {
975        &self.permission_checker
976    }
977
978    /// Register a PostToolUse callback for a tool use
979    pub fn register_post_tool_use_callback(
980        &self,
981        tool_use_id: String,
982        callback: crate::hooks::PostToolUseCallback,
983    ) {
984        self.hook_callback_registry
985            .register_post_tool_use(tool_use_id, callback);
986    }
987
988    /// Get the ACP MCP server
989    pub fn acp_mcp_server(&self) -> &Arc<AcpMcpServer> {
990        &self.acp_mcp_server
991    }
992
993    /// Get the background process manager
994    pub fn background_processes(&self) -> &Arc<BackgroundProcessManager> {
995        &self.background_processes
996    }
997
998    /// Configure the ACP MCP server with connection and terminal client
999    ///
1000    /// This should be called after creating the session to enable Terminal API
1001    /// integration for Bash commands.
1002    pub async fn configure_acp_server(
1003        &self,
1004        connection_cx: JrConnectionCx<AgentToClient>,
1005        terminal_client: Option<Arc<TerminalClient>>,
1006    ) {
1007        self.acp_mcp_server.set_session_id(&self.session_id);
1008        self.acp_mcp_server.set_connection(connection_cx);
1009        self.acp_mcp_server.set_cwd(self.cwd.clone()).await;
1010        self.acp_mcp_server
1011            .set_background_processes(self.background_processes.clone());
1012        self.acp_mcp_server
1013            .set_permission_checker(self.permission_checker.clone());
1014
1015        if let Some(client) = terminal_client {
1016            self.acp_mcp_server.set_terminal_client(client);
1017        }
1018
1019        // Set up cancel callback to interrupt Claude CLI when MCP cancellation is received
1020        let cancelled_flag = self.cancelled.clone();
1021        let session_id = self.session_id.clone();
1022        let cancel_sender = self.cancel_sender.clone();
1023
1024        self.acp_mcp_server
1025            .set_cancel_callback(move || {
1026                tracing::info!(
1027                    session_id = %session_id,
1028                    "MCP cancel callback invoked, sending cancel signal"
1029                );
1030                cancelled_flag.store(true, Ordering::SeqCst);
1031                // Send cancel signal through the channel
1032                let _ = cancel_sender.send(());
1033            })
1034            .await;
1035    }
1036}
1037
1038#[allow(clippy::missing_fields_in_debug)]
1039impl std::fmt::Debug for Session {
1040    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1041        f.debug_struct("Session")
1042            .field("session_id", &self.session_id)
1043            .field("cwd", &self.cwd)
1044            .field("cancelled", &self.cancelled.load(Ordering::Relaxed))
1045            .field("connected", &self.connected.load(Ordering::Relaxed))
1046            .finish()
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053
1054    fn test_config() -> AgentConfig {
1055        AgentConfig {
1056            base_url: None,
1057            api_key: None,
1058            model: None,
1059            small_fast_model: None,
1060            max_thinking_tokens: None,
1061        }
1062    }
1063
1064    #[test]
1065    fn test_session_new() {
1066        let session = Session::new(
1067            "test-session-1".to_string(),
1068            PathBuf::from("/tmp"),
1069            &test_config(),
1070            None,
1071        )
1072        .unwrap();
1073
1074        assert_eq!(session.session_id, "test-session-1");
1075        assert_eq!(session.cwd, PathBuf::from("/tmp"));
1076        assert!(!session.is_cancelled());
1077        assert!(!session.is_connected());
1078    }
1079
1080    #[tokio::test]
1081    async fn test_session_cancel() {
1082        let session = Session::new(
1083            "test-session-2".to_string(),
1084            PathBuf::from("/tmp"),
1085            &test_config(),
1086            None,
1087        )
1088        .unwrap();
1089
1090        assert!(!session.is_cancelled());
1091        session.cancel().await;
1092        assert!(session.is_cancelled());
1093        session.reset_cancelled();
1094        assert!(!session.is_cancelled());
1095    }
1096
1097    #[tokio::test]
1098    async fn test_session_permission_mode() {
1099        let session = Session::new(
1100            "test-session-3".to_string(),
1101            PathBuf::from("/tmp"),
1102            &test_config(),
1103            None,
1104        )
1105        .unwrap();
1106
1107        // Default is Default mode (standard behavior with permission prompts)
1108        assert_eq!(session.permission_mode().await, PermissionMode::Default);
1109        session.set_permission_mode(PermissionMode::DontAsk).await;
1110        assert_eq!(session.permission_mode().await, PermissionMode::DontAsk);
1111    }
1112
1113    #[test]
1114    fn test_stable_cache_key_ordering() {
1115        use serde_json::json;
1116
1117        // JSON objects with same content but different key ordering should produce same cache key
1118        let json1 = json!({"a": 1, "b": 2, "c": 3});
1119        let json2 = json!({"c": 3, "b": 2, "a": 1});
1120        let json3 = json!({"b": 2, "a": 1, "c": 3});
1121
1122        let key1 = stable_cache_key(&json1);
1123        let key2 = stable_cache_key(&json2);
1124        let key3 = stable_cache_key(&json3);
1125
1126        assert_eq!(
1127            key1, key2,
1128            "Different key ordering should produce same cache key"
1129        );
1130        assert_eq!(
1131            key2, key3,
1132            "Different key ordering should produce same cache key"
1133        );
1134    }
1135
1136    #[test]
1137    fn test_stable_cache_key_nested_objects() {
1138        use serde_json::json;
1139
1140        // Nested objects should also be canonicalized
1141        let json1 = json!({
1142            "command": "cargo build",
1143            "options": {"a": 1, "b": 2}
1144        });
1145        let json2 = json!({
1146            "options": {"b": 2, "a": 1},
1147            "command": "cargo build"
1148        });
1149
1150        let key1 = stable_cache_key(&json1);
1151        let key2 = stable_cache_key(&json2);
1152
1153        assert_eq!(key1, key2, "Nested objects should also produce stable keys");
1154    }
1155
1156    #[test]
1157    fn test_stable_cache_key_arrays() {
1158        use serde_json::json;
1159
1160        // Arrays with objects inside should be canonicalized
1161        let json1 = json!({
1162            "items": [{"a": 1, "b": 2}, {"c": 3, "d": 4}]
1163        });
1164        let json2 = json!({
1165            "items": [{"b": 2, "a": 1}, {"d": 4, "c": 3}]
1166        });
1167
1168        let key1 = stable_cache_key(&json1);
1169        let key2 = stable_cache_key(&json2);
1170
1171        assert_eq!(key1, key2, "Arrays with objects should produce stable keys");
1172    }
1173
1174    #[test]
1175    fn test_stable_cache_key_different_content() {
1176        use serde_json::json;
1177
1178        // Different content should produce different keys
1179        let json1 = json!({"command": "cargo build"});
1180        let json2 = json!({"command": "cargo test"});
1181
1182        let key1 = stable_cache_key(&json1);
1183        let key2 = stable_cache_key(&json2);
1184
1185        assert_ne!(
1186            key1, key2,
1187            "Different content should produce different keys"
1188        );
1189    }
1190}