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