use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use parking_lot::Mutex; use tauri::{AppHandle, Emitter}; use tempfile::NamedTempFile; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::commands::record_cost; use crate::config::ClaudeStartOptions; use crate::process_ext::HideWindow; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::types::{ AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent, PostCompactEvent, StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent, UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo, }; use parking_lot::RwLock; use std::cell::RefCell; thread_local! { /// Stores pending tool uses from the most recent Assistant message /// to enable batching permission requests for sibling cancelled tools static PENDING_TOOL_USES: RefCell> = const { RefCell::new(Vec::new()) }; } #[derive(Debug, Clone)] struct PendingToolUse { tool_use_id: String, tool_name: String, tool_input: serde_json::Value, } const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; fn detect_wsl() -> bool { // A native Windows binary is never running inside WSL, even if launched from a WSL // terminal that has WSL_DISTRO_NAME set in its environment. if cfg!(target_os = "windows") { return false; } // Check /proc/version for WSL indicators if let Ok(version) = std::fs::read_to_string("/proc/version") { let version_lower = version.to_lowercase(); if version_lower.contains("microsoft") || version_lower.contains("wsl") { return true; } } // Fallback: check for WSLInterop if std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() { return true; } // Check for WSL environment variable if std::env::var("WSL_DISTRO_NAME").is_ok() { return true; } false } fn find_claude_binary() -> Option { // Check common installation locations for claude (when HOME is available) if let Ok(home) = std::env::var("HOME") { let paths_to_check = [ format!("{}/.local/bin/claude", home), format!("{}/.claude/local/claude", home), ]; for path in &paths_to_check { if std::path::Path::new(path).exists() { return Some(path.clone()); } } } // Check system-wide locations for path in &["/usr/local/bin/claude", "/usr/bin/claude"] { if std::path::Path::new(path).exists() { return Some((*path).to_string()); } } // Use a login shell to resolve claude via the user's PATH - GUI apps don't // inherit shell PATH, so bare `which` may miss ~/.local/bin entries if let Ok(output) = Command::new("bash").hide_window().args(["-lc", "which claude"]).output() { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !path.is_empty() { return Some(path); } } } None } pub struct WslBridge { process: Arc>>, stdin: Option, working_directory: String, session_id: Option, mcp_config_file: Option, stats: Arc>, conversation_id: Option, /// Set to true once the `system:init` message arrives, false at the start of every new session. received_init: Arc, /// Set to true by stop()/interrupt() before killing the process so handle_stdout knows /// the disconnect was intentional and should not emit a second Disconnected event. intentional_stop: Arc, /// Tracks when the most recent user message was sent. Cleared when a `Result` message /// arrives. The mid-session watchdog uses this to detect a stuck process. pending_since: Arc>>, /// Incremented each time `start()` is called so each session's watchdog knows when to exit. watchdog_generation: Arc, } impl WslBridge { pub fn new() -> Self { WslBridge { process: Arc::new(Mutex::new(None)), stdin: None, working_directory: String::new(), session_id: None, mcp_config_file: None, stats: Arc::new(RwLock::new(UsageStats::new())), conversation_id: None, received_init: Arc::new(AtomicBool::new(false)), intentional_stop: Arc::new(AtomicBool::new(false)), pending_since: Arc::new(Mutex::new(None)), watchdog_generation: Arc::new(AtomicU64::new(0)), } } pub fn new_with_conversation_id(conversation_id: String) -> Self { WslBridge { process: Arc::new(Mutex::new(None)), stdin: None, working_directory: String::new(), session_id: None, mcp_config_file: None, stats: Arc::new(RwLock::new(UsageStats::new())), conversation_id: Some(conversation_id), received_init: Arc::new(AtomicBool::new(false)), intentional_stop: Arc::new(AtomicBool::new(false)), pending_since: Arc::new(Mutex::new(None)), watchdog_generation: Arc::new(AtomicU64::new(0)), } } pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { // If a process handle exists but the process has already exited (e.g. due to a // failed working directory), clean up the stale handle so we can restart cleanly. { let mut proc_guard = self.process.lock(); if let Some(ref mut proc) = *proc_guard { if proc.try_wait().map(|s| s.is_some()).unwrap_or(false) { *proc_guard = None; self.stdin = None; } } if proc_guard.is_some() { return Err("Process already running".to_string()); } } // Load saved achievements and stats when starting a new session let app_clone = app.clone(); let stats = self.stats.clone(); tauri::async_runtime::spawn(async move { tracing::info!("Loading saved achievements..."); let achievements = crate::achievements::load_achievements(&app_clone).await; tracing::info!( "Loaded {} unlocked achievements", achievements.unlocked.len() ); tracing::info!("Loading saved stats..."); let persisted_stats = crate::stats::load_stats(&app_clone).await; let mut stats_guard = stats.write(); stats_guard.achievements = achievements; if let Some(persisted) = persisted_stats { tracing::info!("Applying persisted lifetime stats"); stats_guard.apply_persisted(persisted); } }); let working_dir = &options.working_dir; self.working_directory = working_dir.clone(); emit_connection_status( &app, ConnectionStatus::Connecting, self.conversation_id.clone(), ); // Create temp file for MCP config if provided let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { if !mcp_json.trim().is_empty() { // Validate JSON before writing serde_json::from_str::(mcp_json) .map_err(|e| format!("Invalid MCP servers JSON: {}", e))?; let mut temp_file = NamedTempFile::new() .map_err(|e| format!("Failed to create temp file for MCP config: {}", e))?; temp_file .write_all(mcp_json.as_bytes()) .map_err(|e| format!("Failed to write MCP config: {}", e))?; temp_file .flush() .map_err(|e| format!("Failed to flush MCP config: {}", e))?; let path = temp_file.path().to_string_lossy().to_string(); self.mcp_config_file = Some(temp_file); Some(path) } else { None } } else { None }; // Detect if we're running inside WSL or on Windows let is_wsl = detect_wsl(); tracing::debug!("is_wsl: {}", is_wsl); tracing::debug!("options: {:?}", options); let mut command = if is_wsl { // Running inside WSL - call claude directly // Try to find claude in common locations since GUI apps may not inherit shell PATH let claude_path = find_claude_binary().ok_or_else(|| { "Could not find claude binary. Is Claude Code installed?".to_string() })?; tracing::debug!("Found claude at: {}", claude_path); tracing::debug!("Working dir: {}", working_dir); let mut cmd = Command::new(&claude_path); cmd.hide_window(); cmd.args([ "--output-format", "stream-json", "--input-format", "stream-json", "--verbose", "--debug", "hooks", ]); // Add model if specified if let Some(ref model) = options.model { if !model.is_empty() { cmd.args(["--model", model]); } } // Add allowed tools if any for tool in &options.allowed_tools { cmd.args(["--allowedTools", tool]); } // Add custom instructions as system prompt if specified if let Some(ref instructions) = options.custom_instructions { if !instructions.is_empty() { cmd.args(["--system-prompt", instructions]); } } // Add MCP config if provided if let Some(ref mcp_path) = mcp_config_path { cmd.args(["--mcp-config", mcp_path]); } // Add resume flag if session ID provided if let Some(ref session_id) = options.resume_session_id { if !session_id.is_empty() { cmd.args(["--resume", session_id]); } } // Pass session name to Claude Code if specified if let Some(ref name) = options.session_name { if !name.is_empty() { cmd.args(["--name", name]); } } // Add worktree flag if requested if options.use_worktree { cmd.arg("--worktree"); } // Pass combined settings via --settings flag if any settings are specified { let has_memory_dir = options .auto_memory_directory .as_deref() .map(|d| !d.is_empty()) .unwrap_or(false); let has_overrides = options .model_overrides .as_ref() .map(|m| !m.is_empty()) .unwrap_or(false); if has_memory_dir || has_overrides { let mut settings = serde_json::Map::new(); if let Some(ref dir) = options.auto_memory_directory { if !dir.is_empty() { settings.insert( "autoMemoryDirectory".to_string(), serde_json::Value::String(dir.clone()), ); } } if let Some(ref overrides) = options.model_overrides { if !overrides.is_empty() { if let Ok(val) = serde_json::to_value(overrides) { settings.insert("modelOverrides".to_string(), val); } } } if let Ok(settings_json) = serde_json::to_string(&settings) { cmd.args(["--settings", &settings_json]); } } } cmd.current_dir(working_dir); // Set API key as environment variable if specified if let Some(ref api_key) = options.api_key { if !api_key.is_empty() { cmd.env("ANTHROPIC_API_KEY", api_key); } } // Disable 1M context window if requested if options.disable_1m_context { cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1"); } // Set max output tokens if specified if let Some(max_tokens) = options.max_output_tokens { cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string()); } // Disable cron scheduling if requested if options.disable_cron { cmd.env("CLAUDE_CODE_DISABLE_CRON", "1"); } // Disable built-in git instructions if requested if !options.include_git_instructions { cmd.env("CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS", "1"); } // Opt out of claude.ai MCP servers if requested if !options.enable_claudeai_mcp_servers { cmd.env("ENABLE_CLAUDEAI_MCP_SERVERS", "false"); } cmd } else { // Running on Windows - use wsl with bash login shell to ensure PATH is loaded tracing::debug!("Windows path - using wsl"); // Check if Claude binary is installed inside WSL let binary_check = Command::new("wsl") .hide_window() .args(["-e", "bash", "-lc", "which claude"]) .output(); if let Ok(output) = binary_check { if !output.status.success() { return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string()); } } // Validate the working directory exists inside WSL before spawning let dir_check = Command::new("wsl") .hide_window() .args(["-e", "test", "-d", working_dir]) .output(); if let Ok(output) = dir_check { if !output.status.success() { return Err(format!( "Working directory does not exist: {}", working_dir )); } } let mut cmd = Command::new("wsl"); // Build the claude command with all arguments let mut claude_cmd = format!("cd '{}' && ", working_dir); // Set API key as environment variable if specified if let Some(ref api_key) = options.api_key { if !api_key.is_empty() { claude_cmd.push_str(&format!("ANTHROPIC_API_KEY='{}' ", api_key)); } } // Disable 1M context window if requested if options.disable_1m_context { claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 "); } // Set max output tokens if specified if let Some(max_tokens) = options.max_output_tokens { claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens)); } // Disable cron scheduling if requested if options.disable_cron { claude_cmd.push_str("CLAUDE_CODE_DISABLE_CRON=1 "); } // Disable built-in git instructions if requested if !options.include_git_instructions { claude_cmd.push_str("CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1 "); } // Opt out of claude.ai MCP servers if requested if !options.enable_claudeai_mcp_servers { claude_cmd.push_str("ENABLE_CLAUDEAI_MCP_SERVERS=false "); } claude_cmd.push_str( "claude --output-format stream-json --input-format stream-json --verbose", ); // Add model if specified if let Some(ref model) = options.model { if !model.is_empty() { claude_cmd.push_str(&format!(" --model '{}'", model)); } } // Add allowed tools if any for tool in &options.allowed_tools { claude_cmd.push_str(&format!(" --allowedTools '{}'", tool)); } // Add custom instructions as system prompt if specified if let Some(ref instructions) = options.custom_instructions { if !instructions.is_empty() { // Escape single quotes in instructions let escaped = instructions.replace('\'', "'\\''"); claude_cmd.push_str(&format!(" --system-prompt '{}'", escaped)); } } // Add MCP config if provided if let Some(ref mcp_path) = mcp_config_path { claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path)); } // Add resume flag if session ID provided if let Some(ref session_id) = options.resume_session_id { if !session_id.is_empty() { claude_cmd.push_str(&format!(" --resume '{}'", session_id)); } } // Pass session name to Claude Code if specified if let Some(ref name) = options.session_name { if !name.is_empty() { let escaped = name.replace('\'', "'\\''"); claude_cmd.push_str(&format!(" --name '{}'", escaped)); } } // Add worktree flag if requested if options.use_worktree { claude_cmd.push_str(" --worktree"); } // Pass combined settings via --settings flag if any settings are specified { let has_memory_dir = options .auto_memory_directory .as_deref() .map(|d| !d.is_empty()) .unwrap_or(false); let has_overrides = options .model_overrides .as_ref() .map(|m| !m.is_empty()) .unwrap_or(false); if has_memory_dir || has_overrides { let mut settings = serde_json::Map::new(); if let Some(ref dir) = options.auto_memory_directory { if !dir.is_empty() { settings.insert( "autoMemoryDirectory".to_string(), serde_json::Value::String(dir.clone()), ); } } if let Some(ref overrides) = options.model_overrides { if !overrides.is_empty() { if let Ok(val) = serde_json::to_value(overrides) { settings.insert("modelOverrides".to_string(), val); } } } if let Ok(settings_json) = serde_json::to_string(&settings) { let escaped = settings_json.replace('\'', "'\\''"); claude_cmd.push_str(&format!(" --settings '{}'", escaped)); } } } // Use bash -lc to load login profile (ensures PATH includes claude) cmd.args(["-e", "bash", "-lc", &claude_cmd]); // Hide the console window on Windows cmd.hide_window(); cmd }; command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = command.spawn().map_err(|e| { tracing::error!("Spawn error: {:?}", e); format!("Failed to spawn process: {}", e) })?; let stdin = child.stdin.take(); let stdout = child.stdout.take(); let stderr = child.stderr.take(); self.stdin = stdin; *self.process.lock() = Some(child); // Reset flags so the watchdog and stdout handler start fresh. self.received_init.store(false, Ordering::SeqCst); self.intentional_stop.store(false, Ordering::SeqCst); // Note: We no longer reset stats here - stats persist across reconnects // Stats are only reset when explicitly disconnecting via stop() // Load saved achievements let app_handle = app.clone(); let stats_clone = self.stats.clone(); tokio::spawn(async move { let saved_progress = crate::achievements::load_achievements(&app_handle).await; stats_clone.write().achievements = saved_progress; }); if let Some(stdout) = stdout { let app_clone = app.clone(); let stats_clone = self.stats.clone(); let conv_id = self.conversation_id.clone(); let received_init_clone = self.received_init.clone(); let intentional_stop_clone = self.intentional_stop.clone(); let pending_since_clone = self.pending_since.clone(); thread::spawn(move || { handle_stdout( stdout, app_clone, stats_clone, conv_id, received_init_clone, intentional_stop_clone, pending_since_clone, ); }); } if let Some(stderr) = stderr { let app_clone = app.clone(); let conv_id = self.conversation_id.clone(); thread::spawn(move || { handle_stderr(stderr, app_clone, conv_id); }); } // Emit Connected immediately so the frontend can send the greeting message. // This is intentionally optimistic — Claude Code buffers stdout until stdin receives // data on Windows/WSL, so we must send something to stdin first or system:init never // arrives. The received_init flag below tracks whether init actually arrived. emit_connection_status( &app, ConnectionStatus::Connected, self.conversation_id.clone(), ); // Watchdog: if system:init never arrives the process is truly hung (e.g. a silent crash // after spawning). After 30 seconds we kill it so the user is not stuck forever. // The CLI v2.1.79 stdin-hang fix means startup is reliably fast; 30 s is a generous // safety net rather than a workaround for a known hang. let process_watchdog = self.process.clone(); let received_init_watchdog = self.received_init.clone(); thread::spawn(move || { thread::sleep(Duration::from_secs(30)); if !received_init_watchdog.load(Ordering::SeqCst) { if let Some(mut proc) = process_watchdog.lock().take() { let _ = proc.kill(); let _ = proc.wait(); } } }); // Reset the pending-since tracker for this new session so stale state from a previous // session never triggers the mid-session watchdog immediately. *self.pending_since.lock() = None; // Mid-session watchdog: if a user message is sent but no Result arrives within 5 minutes, // the Claude Code process is stuck. Kill it so the user gets a disconnect event and can // reconnect. The generation counter ensures old watchdogs from previous sessions exit // cleanly when `start()` is called again. let generation = self.watchdog_generation.fetch_add(1, Ordering::SeqCst) + 1; let process_mid_watchdog = self.process.clone(); let pending_since_watchdog = self.pending_since.clone(); let generation_watchdog = self.watchdog_generation.clone(); const STUCK_TIMEOUT: Duration = Duration::from_secs(5 * 60); const POLL_INTERVAL: Duration = Duration::from_secs(30); thread::spawn(move || { loop { thread::sleep(POLL_INTERVAL); // Exit if a newer session has started. if generation_watchdog.load(Ordering::SeqCst) != generation { break; } // Exit if the process has already been taken (killed or stopped). if process_mid_watchdog.lock().is_none() { break; } let elapsed = (*pending_since_watchdog.lock()).map(|t| t.elapsed()); if let Some(elapsed) = elapsed { if elapsed >= STUCK_TIMEOUT { tracing::warn!( "Mid-session watchdog: no Result received in {:?}; killing stuck process", elapsed ); if let Some(mut proc) = process_mid_watchdog.lock().take() { let _ = proc.kill(); let _ = proc.wait(); } break; } } } }); Ok(()) } pub fn send_message(&mut self, message: &str) -> Result<(), String> { let stdin = self.stdin.as_mut().ok_or("Process not running")?; // Track input for cost estimation on interrupt { let mut stats = self.stats.write(); stats.current_request_input = Some(message.to_string()); stats.current_request_output_chars = 0; stats.current_request_thinking_chars = 0; stats.current_request_tools.clear(); } let input = serde_json::json!({ "type": "user", "message": { "role": "user", "content": [{ "type": "text", "text": message }] } }); let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?; stdin .write_all(format!("{}\n", json_line).as_bytes()) .map_err(|e| format!("Failed to write to stdin: {}", e))?; stdin .flush() .map_err(|e| format!("Failed to flush stdin: {}", e))?; // Record the time this message was sent so the mid-session watchdog can detect // if no Result ever arrives (i.e. the process is stuck). *self.pending_since.lock() = Some(Instant::now()); Ok(()) } pub fn send_tool_result( &mut self, tool_use_id: &str, result: serde_json::Value, ) -> Result<(), String> { let stdin = self.stdin.as_mut().ok_or("Process not running")?; // The content should be a JSON string representation of the result let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?; let input = serde_json::json!({ "type": "user", "message": { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": tool_use_id, "content": content_str }] } }); let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?; stdin .write_all(format!("{}\n", json_line).as_bytes()) .map_err(|e| format!("Failed to write to stdin: {}", e))?; stdin .flush() .map_err(|e| format!("Failed to flush stdin: {}", e))?; Ok(()) } pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> { // Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work, // we have to kill the process. This is the only reliable way to stop it. // See: https://github.com/anthropics/claude-code/issues/3455 // Extract the process first so the MutexGuard is dropped before we mutably // borrow `self` again via estimate_interrupted_request_cost. // Signal handle_stdout that this is an intentional stop so it doesn't emit // a second Disconnected event after stdout closes due to the kill. self.intentional_stop.store(true, Ordering::SeqCst); let maybe_process = self.process.lock().take(); if let Some(mut process) = maybe_process { // Estimate cost for interrupted request before killing self.estimate_interrupted_request_cost(app); // Kill the process immediately let _ = process.kill(); let _ = process.wait(); // Clear stdin self.stdin = None; // Clear tracking fields { let mut stats = self.stats.write(); stats.current_request_input = None; stats.current_request_output_chars = 0; stats.current_request_thinking_chars = 0; stats.current_request_tools.clear(); } // Keep session_id and working directory for user reference // The user will see what session was interrupted // Emit disconnected status emit_connection_status( app, ConnectionStatus::Disconnected, self.conversation_id.clone(), ); Ok(()) } else { Err("No active process to interrupt".to_string()) } } fn estimate_interrupted_request_cost(&mut self, app: &AppHandle) { // Read tracking data from stats let (input_chars, output_chars, thinking_chars, tools, model) = { let stats = self.stats.read(); // Only estimate if we have tracked content if stats.current_request_input.is_none() && stats.current_request_output_chars == 0 && stats.current_request_thinking_chars == 0 && stats.current_request_tools.is_empty() { return; } let input_chars = stats.current_request_input.as_ref().map(|s| s.len() as u64).unwrap_or(0); let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-6".to_string()); (input_chars, stats.current_request_output_chars, stats.current_request_thinking_chars, stats.current_request_tools.clone(), model) }; tracing::info!("[COST ESTIMATION] Estimating cost for interrupted request"); // Char-based estimation: 3.5 chars/token is intentionally conservative (standard English // averages ~4 chars/token). CLI v2.1.75 fixed over-counting in Claude Code's compaction // logic but does not affect this heuristic — we count characters ourselves, independently // of Claude Code's internal token tracking. The 1.2 safety margin avoids undercharging. let estimated_input_tokens = (input_chars as f64 / 3.5).ceil() as u64; let estimated_output_tokens = ((output_chars as f64 / 3.5).ceil() as u64) + ((thinking_chars as f64 / 3.5).ceil() as u64); // Add tool overhead based on session averages let mut tool_overhead_tokens = 0u64; { let stats = self.stats.read(); for tool_name in &tools { if let Some(tool_stats) = stats.session_tools_usage.get(tool_name) { if tool_stats.call_count > 0 { // Use session average tokens per call for this tool let avg_tokens = (tool_stats.estimated_input_tokens + tool_stats.estimated_output_tokens) / tool_stats.call_count; tool_overhead_tokens += avg_tokens; tracing::info!("[COST ESTIMATION] Tool {} average: {} tokens", tool_name, avg_tokens); } } } } let total_estimated_input = estimated_input_tokens + tool_overhead_tokens; let total_estimated_output = estimated_output_tokens; // Add 20% safety margin to overestimate let safety_margin = 1.2; let conservative_input = (total_estimated_input as f64 * safety_margin).ceil() as u64; let conservative_output = (total_estimated_output as f64 * safety_margin).ceil() as u64; tracing::info!("[COST ESTIMATION] Input: {} chars → {} tokens (+ {} tool overhead) × 1.2 safety = {} tokens", input_chars, estimated_input_tokens, tool_overhead_tokens, conservative_input); tracing::info!("[COST ESTIMATION] Output: {} chars → {} tokens × 1.2 safety = {} tokens", output_chars + thinking_chars, estimated_output_tokens, conservative_output); // Calculate cost (no cache tokens for interrupted requests) let estimated_cost = calculate_cost( conservative_input, conservative_output, &model, None, None, ); tracing::info!("[COST ESTIMATION] Estimated cost: ${:.4} (conservative)", estimated_cost); // Add to stats with estimated flag { let mut stats_guard = self.stats.write(); stats_guard.add_usage( conservative_input, conservative_output, &model, None, None, ); } // Emit stats update let stats_update_event = StatsUpdateEvent { stats: self.stats.read().clone(), }; let _ = app.emit("claude:stats", stats_update_event); // Record to historical cost tracking (mark as estimated) let app_clone = app.clone(); tauri::async_runtime::spawn(async move { record_cost(&app_clone, conservative_input, conservative_output, estimated_cost).await; }); } pub fn stop(&mut self, app: &AppHandle) { // Signal handle_stdout that this is an intentional stop so it doesn't emit // a second Disconnected event after stdout closes due to the kill. self.intentional_stop.store(true, Ordering::SeqCst); if let Some(mut process) = self.process.lock().take() { let _ = process.kill(); let _ = process.wait(); } self.stdin = None; self.session_id = None; self.mcp_config_file = None; // Temp file is automatically deleted when dropped // Save lifetime stats before resetting session let stats_snapshot = self.stats.read().clone(); let app_clone = app.clone(); tauri::async_runtime::spawn(async move { tracing::info!("Saving stats on session stop..."); if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await { tracing::error!("Failed to save stats: {}", e); } else { tracing::info!("Stats saved successfully on session stop"); } }); // Reset session stats on explicit disconnect self.stats.write().reset_session(); emit_connection_status( app, ConnectionStatus::Disconnected, self.conversation_id.clone(), ); } pub fn is_running(&self) -> bool { self.process.lock().is_some() } pub fn get_working_directory(&self) -> &str { &self.working_directory } pub fn get_stats(&self) -> UsageStats { self.stats.read().clone() } } impl Default for WslBridge { fn default() -> Self { Self::new() } } fn handle_stdout( stdout: std::process::ChildStdout, app: AppHandle, stats: Arc>, conversation_id: Option, received_init: Arc, intentional_stop: Arc, pending_since: Arc>>, ) { let reader = BufReader::new(stdout); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { if let Err(e) = process_json_line( &line, &app, &stats, &conversation_id, &received_init, &pending_since, ) { tracing::error!("Error processing line: {}", e); } } Err(e) => { tracing::error!("Error reading stdout: {}", e); break; } _ => {} } } // If this was an intentional stop (stop()/interrupt() was called), the caller already // emitted a Disconnected event. Skip all post-loop emissions to prevent duplicates. if intentional_stop.load(Ordering::SeqCst) { return; } // If stdout closed before system:init arrived the process exited without initialising. // Emit an error line so the user understands why the connection failed. if !received_init.load(Ordering::SeqCst) { let _ = app.emit( "claude:output", OutputEvent { line_type: "error".to_string(), content: "Claude Code exited before initialising. Check the working directory and Claude Code installation, then try connecting again.".to_string(), tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } // If Claude exited while a prompt was in-flight, the user's message was never processed. // Emit a specific error so they know to resend their prompt. let had_pending_request = stats.read().current_request_input.is_some(); if had_pending_request { let _ = app.emit( "claude:output", OutputEvent { line_type: "error".to_string(), content: "Claude Code exited before finishing your request — your last prompt was not processed. Please reconnect and try again.".to_string(), tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id); } fn handle_stderr( stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option, ) { let reader = BufReader::new(stderr); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { // Check if this is a SubagentStart hook message if line.contains("[SubagentStart Hook]") { if let Some(agent_data) = parse_subagent_start_hook(&line) { tracing::debug!("Parsed SubagentStart hook: agent_id={}, parent={:?}", agent_data.agent_id, agent_data.parent_tool_use_id); // Emit an agent-update event with the agent_id and agent_type let _ = app.emit( "claude:agent-update", serde_json::json!({ "conversationId": conversation_id.clone(), "toolUseId": agent_data.parent_tool_use_id, "agentId": agent_data.agent_id, "agentType": agent_data.agent_type, }), ); } } // Check if this is a SubagentStop hook message if line.contains("[SubagentStop Hook]") { if let Some(stop_data) = parse_subagent_stop_hook(&line) { tracing::debug!("Parsed SubagentStop hook: tool_use_id={:?}", stop_data.parent_tool_use_id); // Emit agent-end event if we have a tool_use_id if let Some(tool_use_id) = stop_data.parent_tool_use_id { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let _ = app.emit( "claude:agent-end", AgentEndEvent { tool_use_id, ended_at: now, is_error: false, conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, last_assistant_message: stop_data.last_assistant_message, }, ); } } } // Hook events are informational — emit with distinct types instead of error let is_worktree_create = line.contains("[WorktreeCreate Hook]"); let is_worktree_remove = line.contains("[WorktreeRemove Hook]"); let is_elicitation = line.contains("[Elicitation Hook]"); let is_elicitation_result = line.contains("[ElicitationResult Hook]"); let is_stop_failure = line.contains("[StopFailure Hook]"); let is_post_compact = line.contains("[PostCompact Hook]"); let line_type = if is_worktree_create || is_worktree_remove { "worktree" } else if line.contains("[ConfigChange Hook]") { "config-change" } else if is_elicitation || is_elicitation_result { "elicitation" } else if is_stop_failure { "error" } else if is_post_compact { "compact-prompt" } else { "error" }; // For worktree hooks, parse structured data and emit a dedicated event if is_worktree_create || is_worktree_remove { let worktree_info = parse_worktree_hook(&line); let event_type = if is_worktree_create { "create" } else { "remove" }; let friendly_content = if let Some(ref info) = worktree_info { if is_worktree_create { format!( "Worktree created: {} (branch: {}) at {}", info.name, info.branch, info.path ) } else { format!("Worktree removed: {} (branch: {})", info.name, info.branch) } } else { line.clone() }; let _ = app.emit( "claude:worktree", WorktreeEvent { conversation_id: conversation_id.clone(), event_type: event_type.to_string(), worktree: worktree_info, }, ); let _ = app.emit( "claude:output", OutputEvent { line_type: "worktree".to_string(), content: friendly_content, tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } else if is_elicitation { let data = parse_elicitation_hook(&line); let friendly_content = format!("MCP server requesting input: {}", data.message); let _ = app.emit( "claude:elicitation", ElicitationEvent { message: data.message, server_name: data.server_name, request_id: data.request_id, conversation_id: conversation_id.clone(), }, ); let _ = app.emit( "claude:output", OutputEvent { line_type: "elicitation".to_string(), content: friendly_content, tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } else if is_elicitation_result { let data = parse_elicitation_result_hook(&line); let friendly_content = format!("MCP elicitation completed: {}", data.action); let _ = app.emit( "claude:elicitation-result", ElicitationResultEvent { action: data.action, request_id: data.request_id, conversation_id: conversation_id.clone(), }, ); let _ = app.emit( "claude:output", OutputEvent { line_type: "elicitation".to_string(), content: friendly_content, tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } else if is_stop_failure { let data = parse_stop_failure_hook(&line); let friendly_content = build_stop_failure_message(&data); let _ = app.emit( "claude:stop-failure", StopFailureEvent { stop_reason: data.stop_reason, error_type: data.error_type, conversation_id: conversation_id.clone(), }, ); let _ = app.emit( "claude:output", OutputEvent { line_type: "error".to_string(), content: friendly_content, tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } else if is_post_compact { let data = parse_post_compact_hook(&line); let _ = app.emit( "claude:post-compact", PostCompactEvent { session_id: data.session_id, conversation_id: conversation_id.clone(), }, ); let _ = app.emit( "claude:output", OutputEvent { line_type: "compact-prompt".to_string(), content: "Context compacted — conversation history has been summarised to free up space.".to_string(), tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } else { let _ = app.emit( "claude:output", OutputEvent { line_type: line_type.to_string(), content: line, tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } } Err(_) => break, _ => {} } } } #[derive(Debug)] struct SubagentStartData { agent_id: String, agent_type: Option, parent_tool_use_id: Option, } fn parse_worktree_hook(line: &str) -> Option { // Parse: [WorktreeCreate/Remove Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, // branch=feat/my-feature, original_repo_directory=/home/naomi/code/project, session_id=xxx let extract = |key: &str| -> Option { let after_key = line.split(&format!("{}=", key)).nth(1)?; let value = after_key.split(',').next()?.trim().to_string(); if value.is_empty() { None } else { Some(value) } }; let name = extract("name")?; let path = extract("path")?; let branch = extract("branch")?; let original_repo_directory = extract("original_repo_directory")?; Some(WorktreeInfo { name, path, branch, original_repo_directory, }) } fn parse_subagent_start_hook(line: &str) -> Option { // Parse: [SubagentStart Hook] agent_id=agent-xxx, agent_type=general-purpose, parent_tool_use_id=Some("toolu_xxx"), ... // Extract agent_id let agent_id = line .split("agent_id=") .nth(1)? .split(',') .next()? .trim() .to_string(); // Extract agent_type if present (added in CLI v2.1.69) let agent_type = line .split("agent_type=") .nth(1) .and_then(|s| { let value = s.split(',').next()?.trim(); if value.is_empty() { None } else { Some(value.to_string()) } }); // Extract parent_tool_use_id if present let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") { line.split("parent_tool_use_id=Some(\"") .nth(1)? .split('"') .next() .map(|s| s.to_string()) } else { None }; Some(SubagentStartData { agent_id, agent_type, parent_tool_use_id, }) } #[derive(Debug)] struct SubagentStopData { parent_tool_use_id: Option, last_assistant_message: Option, } /// Extracts the content of a Rust Debug-formatted `Some("...")` field from a hook line. /// Handles escaped characters (e.g. `\"` → `"`, `\\` → `\`, `\n` → newline). /// Returns `None` if the field is absent or formatted as `None`. fn extract_debug_string_value(line: &str, key: &str) -> Option { let prefix = format!("{}=Some(\"", key); let start_idx = line.find(&prefix)? + prefix.len(); let rest = &line[start_idx..]; let mut result = String::new(); let mut chars = rest.chars(); loop { match chars.next() { Some('"') => return Some(result), Some('\\') => match chars.next() { Some('n') => result.push('\n'), Some('t') => result.push('\t'), Some('"') => result.push('"'), Some('\\') => result.push('\\'), Some(c) => { result.push('\\'); result.push(c); } None => break, }, Some(c) => result.push(c), None => break, } } None } fn parse_subagent_stop_hook(line: &str) -> Option { // Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), last_assistant_message=Some("..."), ... let parent_tool_use_id = extract_debug_string_value(line, "parent_tool_use_id"); let last_assistant_message = extract_debug_string_value(line, "last_assistant_message"); Some(SubagentStopData { parent_tool_use_id, last_assistant_message, }) } #[derive(Debug)] struct ElicitationData { message: String, server_name: Option, request_id: Option, } fn parse_elicitation_hook(line: &str) -> ElicitationData { let message = extract_quoted_value(line, "message").unwrap_or_else(|| { line.split("[Elicitation Hook]") .nth(1) .unwrap_or("") .trim() .to_string() }); let server_name = extract_debug_string_value(line, "server_name"); let request_id = extract_debug_string_value(line, "request_id"); ElicitationData { message, server_name, request_id } } #[derive(Debug)] struct ElicitationResultData { action: String, request_id: Option, } fn parse_elicitation_result_hook(line: &str) -> ElicitationResultData { let action = extract_quoted_value(line, "action").unwrap_or_else(|| "unknown".to_string()); let request_id = extract_debug_string_value(line, "request_id"); ElicitationResultData { action, request_id } } #[derive(Debug)] struct StopFailureData { stop_reason: Option, error_type: Option, } fn parse_stop_failure_hook(line: &str) -> StopFailureData { let stop_reason = extract_quoted_value(line, "stop_reason"); let error_type = extract_debug_string_value(line, "error_type"); StopFailureData { stop_reason, error_type } } /// Builds a user-friendly message from a `StopFailureData` instance. fn build_stop_failure_message(data: &StopFailureData) -> String { match data.stop_reason.as_deref() { Some("rate_limit") => "Session stopped: rate limit reached".to_string(), Some("auth_failure") | Some("authentication") => { "Session stopped: authentication failed".to_string() } Some(reason) => format!("Session stopped due to API error: {}", reason), None => match data.error_type.as_deref() { Some(et) => format!("Session stopped due to API error: {}", et), None => "Session stopped due to an unknown API error".to_string(), }, } } #[derive(Debug)] struct PostCompactData { session_id: Option, } fn parse_post_compact_hook(line: &str) -> PostCompactData { let session_id = extract_debug_string_value(line, "session_id"); PostCompactData { session_id } } /// Extracts a double-quoted string value from a `key="value"` pair in a hook line. /// Handles escape sequences within the quoted value. fn extract_quoted_value(line: &str, key: &str) -> Option { let prefix = format!("{}=\"", key); let start_idx = line.find(&prefix)? + prefix.len(); let rest = &line[start_idx..]; let mut result = String::new(); let mut chars = rest.chars(); loop { match chars.next() { Some('"') => return Some(result), Some('\\') => match chars.next() { Some('n') => result.push('\n'), Some('t') => result.push('\t'), Some('"') => result.push('"'), Some('\\') => result.push('\\'), Some(c) => { result.push('\\'); result.push(c); } None => break, }, Some(c) => result.push(c), None => break, } } None } /// Extract text content from a ToolResult's `content` field. /// The content may be a JSON string or an array of typed content blocks. fn extract_tool_result_text(content: &serde_json::Value) -> Option { match content { serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()), serde_json::Value::Array(blocks) => { let texts: Vec = blocks .iter() .filter_map(|block| { if block.get("type")?.as_str()? == "text" { block.get("text")?.as_str().map(String::from) } else { None } }) .collect(); if texts.is_empty() { None } else { Some(texts.join("\n")) } } _ => None, } } fn process_json_line( line: &str, app: &AppHandle, stats: &Arc>, conversation_id: &Option, received_init: &Arc, pending_since: &Arc>>, ) -> Result<(), String> { let message: ClaudeMessage = serde_json::from_str(line) .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; match &message { ClaudeMessage::System { subtype, session_id, cwd, .. } => { if subtype == "init" { // Mark as initialised so the watchdog knows the process is healthy. received_init.store(true, Ordering::SeqCst); if let Some(id) = session_id { let _ = app.emit( "claude:session", SessionEvent { session_id: id.clone(), conversation_id: conversation_id.clone(), }, ); } if let Some(dir) = cwd { let _ = app.emit( "claude:cwd", WorkingDirectoryEvent { directory: dir.clone(), conversation_id: conversation_id.clone(), }, ); } emit_state_change(app, CharacterState::Idle, None, conversation_id.clone()); } } ClaudeMessage::Assistant { message, parent_tool_use_id } => { // Claude is actively responding — reset the watchdog timer so a long multi-step // response (e.g. spawning subagents, chained tool calls) is not mistaken for a // stuck process. The watchdog should only fire if Claude goes completely silent, // not merely because the total turn duration exceeds the threshold. *pending_since.lock() = Some(Instant::now()); let mut state = CharacterState::Typing; let mut tool_name = None; // Collect all tool names in this message for token attribution let tools_in_message: Vec = message .content .iter() .filter_map(|block| match block { ContentBlock::ToolUse { name, .. } => Some(name.clone()), _ => None, }) .collect(); // Store pending tool uses for permission batching (only for top-level, not subagents) if parent_tool_use_id.is_none() { PENDING_TOOL_USES.with(|pending| { let tool_uses: Vec = message .content .iter() .filter_map(|block| match block { ContentBlock::ToolUse { id, name, input } => Some(PendingToolUse { tool_use_id: id.clone(), tool_name: name.clone(), tool_input: input.clone(), }), _ => None, }) .collect(); // Append to existing pending tools instead of replacing pending.borrow_mut().extend(tool_uses); }); } // Track message cost for display let mut message_cost: Option = None; // Only update stats if we have usage information if let Some(usage) = &message.usage { // Get model from message, or fall back to last known model from stats let model = message.model.clone().or_else(|| { let stats_guard = stats.read(); stats_guard.model.clone() }).unwrap_or_else(|| { tracing::warn!("No model info available for cost calculation, using default"); "claude-sonnet-4-6".to_string() }); // Calculate cost for historical tracking (including cache tokens) let cost_usd = calculate_cost( usage.input_tokens, usage.output_tokens, &model, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, ); tracing::info!("Assistant message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}, cost: ${:.4}", usage.input_tokens, usage.output_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, cost_usd ); // Store cost for later use in output events message_cost = Some(MessageCost { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cost_usd, }); // Batch all stats updates in a single write lock { let mut stats_guard = stats.write(); stats_guard.increment_messages(); stats_guard.add_usage( usage.input_tokens, usage.output_tokens, &model, usage.cache_creation_input_tokens, usage.cache_read_input_tokens, ); stats_guard.get_session_duration(); // Attribute tokens to tools if any tools were used in this message if !tools_in_message.is_empty() { let per_tool_input = usage.input_tokens / tools_in_message.len() as u64; let per_tool_output = usage.output_tokens / tools_in_message.len() as u64; for tool in &tools_in_message { stats_guard.add_tool_tokens(tool, per_tool_input, per_tool_output); } } } // Record to historical cost tracking let app_clone = app.clone(); let input = usage.input_tokens; let output = usage.output_tokens; tauri::async_runtime::spawn(async move { record_cost(&app_clone, input, output, cost_usd).await; }); // Don't emit here - we'll emit on Result message instead // This reduces the frequency of updates } else { // Just increment message count if no usage info stats.write().increment_messages(); } for block in &message.content { match block { ContentBlock::ToolUse { id, name, input } => { tool_name = Some(name.clone()); state = get_tool_state(name); // Batch tool tracking updates { let mut stats_guard = stats.write(); stats_guard.increment_tool_usage(name); // Track file operations match name.as_str() { "Edit" => stats_guard.increment_files_edited(), "Write" => stats_guard.increment_files_created(), _ => {} } } // Emit agent-start event for Task/Agent tool invocations // Support "Task"/"Task(agent_type)" (CLI v2.1.33+) and // "Agent"/"Agent(agent_type)" (CLI v2.1.69+ rename) if name == "Task" || name.starts_with("Task(") || name == "Agent" || name.starts_with("Agent(") { let description = input .get("description") .or_else(|| input.get("prompt")) .and_then(|v| v.as_str()) .unwrap_or("Subagent") .to_string(); let subagent_type = input .get("subagent_type") .and_then(|v| v.as_str()) .unwrap_or("general-purpose") .to_string(); let model = input .get("model") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; tracing::debug!( "Emitting agent-start: id={}, desc={}, type={}, model={:?}, parent={:?}", id, description, subagent_type, model, parent_tool_use_id ); let _ = app.emit( "claude:agent-start", AgentStartEvent { tool_use_id: id.clone(), agent_id: None, // Will be updated when SubagentStart hook is received description, subagent_type, model, started_at: now, conversation_id: conversation_id.clone(), parent_tool_use_id: parent_tool_use_id.clone(), }, ); } // Emit todo-update event for TodoWrite tool invocations if name == "TodoWrite" { if let Some(todos_value) = input.get("todos") { if let Some(todos_array) = todos_value.as_array() { let todos: Vec = todos_array .iter() .filter_map(|todo| { serde_json::from_value(todo.clone()).ok() }) .collect(); tracing::debug!( "Emitting todo-update: {} todos, parent={:?}", todos.len(), parent_tool_use_id ); let _ = app.emit( "claude:todo-update", TodoUpdateEvent { todos, conversation_id: conversation_id.clone(), }, ); } } } let desc = format_tool_description(name, input); let _ = app.emit( "claude:output", OutputEvent { line_type: "tool".to_string(), content: desc, tool_name: Some(name.clone()), conversation_id: conversation_id.clone(), cost: None, // Tool use doesn't have separate cost parent_tool_use_id: parent_tool_use_id.clone(), }, ); } ContentBlock::Text { text } => { // Count code blocks in the text let code_blocks = text.matches("```").count() / 2; for _ in 0..code_blocks { stats.write().increment_code_blocks(); } let is_prompt_too_long = text.starts_with("Prompt is too long"); let _ = app.emit( "claude:output", OutputEvent { line_type: if is_prompt_too_long { "error".to_string() } else { "assistant".to_string() }, content: text.clone(), tool_name: None, conversation_id: conversation_id.clone(), cost: message_cost.clone(), parent_tool_use_id: parent_tool_use_id.clone(), }, ); if is_prompt_too_long { let _ = app.emit( "claude:output", OutputEvent { line_type: "compact-prompt".to_string(), content: String::new(), tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } } ContentBlock::Thinking { thinking } => { state = CharacterState::Thinking; let _ = app.emit( "claude:output", OutputEvent { line_type: "thinking".to_string(), content: thinking.clone(), tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: parent_tool_use_id.clone(), }, ); } ContentBlock::ToolResult { tool_use_id, content, is_error, } => { // Emit agent-end for all tool results // The frontend will ignore IDs that don't match known agents let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let _ = app.emit( "claude:agent-end", AgentEndEvent { tool_use_id: tool_use_id.clone(), ended_at: now, is_error: is_error.unwrap_or(false), conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, last_assistant_message: extract_tool_result_text(content), }, ); } } } emit_state_change(app, state, tool_name, conversation_id.clone()); } ClaudeMessage::StreamEvent { event } => { if event.event_type == "content_block_start" { if let Some(block) = &event.content_block { // Track tool calls for cost estimation if block.block_type == "tool_use" { if let Some(name) = &block.name { let mut stats_guard = stats.write(); stats_guard.current_request_tools.push(name.clone()); } } let state = match block.block_type.as_str() { "thinking" => CharacterState::Thinking, "text" => CharacterState::Typing, "tool_use" => { if let Some(name) = &block.name { get_tool_state(name) } else { CharacterState::Typing } } _ => CharacterState::Typing, }; emit_state_change(app, state, block.name.clone(), conversation_id.clone()); } } else if event.event_type == "content_block_delta" { if let Some(delta) = &event.delta { if let Some(text) = &delta.text { // Track output characters for cost estimation { let mut stats_guard = stats.write(); stats_guard.current_request_output_chars += text.len() as u64; } let _ = app.emit("claude:stream", text.clone()); } else if let Some(thinking) = &delta.thinking { // Track thinking characters for cost estimation let mut stats_guard = stats.write(); stats_guard.current_request_thinking_chars += thinking.len() as u64; } } } } ClaudeMessage::Result { subtype, result, permission_denials, usage, duration_ms, num_turns, } => { tracing::info!( "Received Result message: subtype={}, has_denials={}, denial_count={:?}", subtype, permission_denials.is_some(), permission_denials.as_ref().map(|d| d.len()) ); // A Result means the turn is complete — clear pending_since so the mid-session // watchdog knows the process is not stuck. *pending_since.lock() = None; let state = if subtype == "success" { CharacterState::Success } else { CharacterState::Error }; // Capture pending tool uses before clearing them // We'll use these for permission batching if there are denials let captured_pending_tools = PENDING_TOOL_USES.with(|pending| { let tools = pending.borrow().clone(); // Clear immediately so they don't accumulate across requests pending.borrow_mut().clear(); tools }); tracing::debug!( "Captured {} pending tool use(s): {:?}", captured_pending_tools.len(), captured_pending_tools.iter().map(|t| &t.tool_name).collect::>() ); // Log turn metrics if available if let Some(duration) = duration_ms { tracing::info!("Turn completed in {}ms", duration); } if let Some(turns) = num_turns { tracing::info!("Turn count: {}", turns); } // Track token usage from Result messages if available // This captures tokens from tool outputs and other operations if let Some(usage_info) = usage { // We need the model info to calculate cost properly // For now, use the last known model from stats let model = { let stats_guard = stats.read(); stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string()) }; // Calculate cost for historical tracking (including cache tokens) let cost_usd = calculate_cost( usage_info.input_tokens, usage_info.output_tokens, &model, usage_info.cache_creation_input_tokens, usage_info.cache_read_input_tokens, ); let mut stats_guard = stats.write(); stats_guard.add_usage( usage_info.input_tokens, usage_info.output_tokens, &model, usage_info.cache_creation_input_tokens, usage_info.cache_read_input_tokens, ); tracing::info!("Result message tokens - input: {}, output: {}, cache_creation: {:?}, cache_read: {:?}", usage_info.input_tokens, usage_info.output_tokens, usage_info.cache_creation_input_tokens, usage_info.cache_read_input_tokens ); // Record to historical cost tracking let app_clone = app.clone(); let input = usage_info.input_tokens; let output = usage_info.output_tokens; tauri::async_runtime::spawn(async move { record_cost(&app_clone, input, output, cost_usd).await; }); } // Clear tracking fields since request completed successfully { let mut stats_guard = stats.write(); stats_guard.current_request_input = None; stats_guard.current_request_output_chars = 0; stats_guard.current_request_thinking_chars = 0; stats_guard.current_request_tools.clear(); } // Always emit updated stats on result message (less frequent) // This includes the latest session duration let newly_unlocked = { let mut stats_guard = stats.write(); stats_guard.get_session_duration(); tracing::info!("Checking achievements after result message..."); let unlocked = stats_guard.check_achievements(); tracing::info!("Newly unlocked achievements: {:?}", unlocked); unlocked }; // Emit achievement events for any newly unlocked achievements for achievement_id in &newly_unlocked { let info = get_achievement_info(achievement_id); let _ = app.emit( "achievement:unlocked", AchievementUnlockedEvent { achievement: info }, ); } // Save achievements after unlocking new ones if !newly_unlocked.is_empty() { tracing::info!("Saving newly unlocked achievements: {:?}", newly_unlocked); let app_handle = app.clone(); let achievements_progress = stats.read().achievements.clone(); // Use Tauri's async runtime instead of tokio::spawn tauri::async_runtime::spawn(async move { tracing::info!("Spawned save task for achievements"); if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress) .await { tracing::error!("Failed to save achievements: {}", e); } else { tracing::info!("Achievement save task completed successfully"); } }); } let current_stats = stats.read().clone(); let stats_event = StatsUpdateEvent { stats: current_stats.clone(), }; let _ = app.emit("claude:stats", stats_event); // Save stats periodically (every 10 messages to avoid excessive disk writes) if current_stats.session_messages_exchanged.is_multiple_of(10) && current_stats.session_messages_exchanged > 0 { let app_handle = app.clone(); tauri::async_runtime::spawn(async move { tracing::info!("Periodic stats save (every 10 messages)..."); if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await { tracing::error!("Failed to save stats: {}", e); } }); } // Only emit error results - success content is already sent via Assistant message if subtype != "success" { if let Some(text) = result { let _ = app.emit( "claude:output", OutputEvent { line_type: "error".to_string(), content: text.clone(), tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } } // Check for permission denials and emit prompts for each if let Some(denials) = permission_denials { // Only process if there are actually denials if !denials.is_empty() { let mut regular_permission_requests = Vec::new(); // Get denied tool IDs for later comparison let denied_tool_ids: Vec = denials.iter() .map(|d| d.tool_use_id.clone()) .collect(); // Helper function to check if a tool is a system tool that should never require permission let is_system_tool = |tool_name: &str| -> bool { matches!( tool_name, "ExitPlanMode" | "EnterPlanMode" | "EnterWorktree" | "ExitWorktree" ) }; for denial in denials { // Skip system tools that should never require permission if is_system_tool(&denial.tool_name) { tracing::debug!( "Skipping system tool: {} (id: {})", denial.tool_name, denial.tool_use_id ); continue; } tracing::debug!( "Processing permission denial for: {} (id: {})", denial.tool_name, denial.tool_use_id ); // Special handling for AskUserQuestion tool if denial.tool_name == "AskUserQuestion" { if let Some(questions) = denial .tool_input .get("questions") .and_then(|q| q.as_array()) { // For now, handle the first question (most common case) if let Some(first_question) = questions.first() { let question_text = first_question .get("question") .and_then(|q| q.as_str()) .unwrap_or("Claude has a question for you") .to_string(); let header = first_question .get("header") .and_then(|h| h.as_str()) .map(|s| s.to_string()); let multi_select = first_question .get("multiSelect") .and_then(|m| m.as_bool()) .unwrap_or(false); let options: Vec = first_question .get("options") .and_then(|opts| opts.as_array()) .map(|opts| { opts.iter() .filter_map(|opt| { let label = opt.get("label").and_then(|l| l.as_str())?; let description = opt .get("description") .and_then(|d| d.as_str()) .map(|s| s.to_string()); Some(QuestionOption { label: label.to_string(), description, }) }) .collect() }) .unwrap_or_default(); let _ = app.emit( "claude:question", UserQuestionEvent { id: denial.tool_use_id.clone(), question: question_text, header, options, multi_select, conversation_id: conversation_id.clone(), }, ); } } } else { let description = format_tool_description(&denial.tool_name, &denial.tool_input); regular_permission_requests.push(PermissionPromptEventItem { id: denial.tool_use_id.clone(), tool_name: denial.tool_name.clone(), tool_input: denial.tool_input.clone(), description, }); } } // Check for sibling tools that may have been cancelled // Add them to the permission batch so they can be approved together for tool_use in captured_pending_tools.iter() { // Skip system tools that should never require permission if is_system_tool(&tool_use.tool_name) { continue; } // Only add tools that weren't explicitly denied (these are likely cancelled siblings) if !denied_tool_ids.contains(&tool_use.tool_use_id) { let description = format_tool_description(&tool_use.tool_name, &tool_use.tool_input); regular_permission_requests.push(PermissionPromptEventItem { id: tool_use.tool_use_id.clone(), tool_name: tool_use.tool_name.clone(), tool_input: tool_use.tool_input.clone(), description, }); } } // Emit all regular permission requests as a single batched event if !regular_permission_requests.is_empty() { tracing::info!( "Emitting permission event for {} tool(s) in conversation {:?}", regular_permission_requests.len(), conversation_id ); for req in ®ular_permission_requests { tracing::debug!( "Permission requested: {} (id: {})", req.tool_name, req.id ); } let _ = app.emit( "claude:permission", PermissionPromptEvent { permissions: regular_permission_requests, conversation_id: conversation_id.clone(), }, ); emit_state_change( app, CharacterState::Permission, None, conversation_id.clone(), ); return Ok(()); } // Show permission state if there were any question denials if !denials.is_empty() { emit_state_change( app, CharacterState::Permission, None, conversation_id.clone(), ); return Ok(()); } } // end of else block for non-empty denials } // end of if let Some(denials) emit_state_change(app, state, None, conversation_id.clone()); } ClaudeMessage::RateLimitEvent { rate_limit_info } => { tracing::warn!("Rate limit event received: {:?}", rate_limit_info); let content = format_rate_limit_message(rate_limit_info); let _ = app.emit( "claude:output", OutputEvent { line_type: "rate-limit".to_string(), content, tool_name: None, conversation_id: conversation_id.clone(), cost: None, parent_tool_use_id: None, }, ); } ClaudeMessage::User { message } => { // Increment message count for user messages stats.write().increment_messages(); // Process content blocks for tool results (e.g., background Task agent completions) for block in &message.content { if let ContentBlock::ToolResult { tool_use_id, content, is_error, } = block { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let _ = app.emit( "claude:agent-end", AgentEndEvent { tool_use_id: tool_use_id.clone(), ended_at: now, is_error: is_error.unwrap_or(false), conversation_id: conversation_id.clone(), duration_ms: None, num_turns: None, last_assistant_message: extract_tool_result_text(content), }, ); } } // Extract text content from the message let message_text = message .content .iter() .filter_map(|block| match block { crate::types::ContentBlock::Text { text } => Some(text.clone()), _ => None, }) .collect::>() .join(" "); // Check achievements after user message let newly_unlocked = { let mut stats_guard = stats.write(); tracing::info!("User sent message, checking achievements..."); // Check message-based achievements let mut unlocked = crate::achievements::check_message_achievements( &message_text, &mut stats_guard.achievements, ); // Check stats-based achievements let stats_unlocked = stats_guard.check_achievements(); unlocked.extend(stats_unlocked); unlocked }; // Emit achievement events for any newly unlocked achievements for achievement_id in &newly_unlocked { tracing::info!("User message unlocked achievement: {:?}", achievement_id); let info = get_achievement_info(achievement_id); let _ = app.emit( "achievement:unlocked", AchievementUnlockedEvent { achievement: info }, ); } // Save achievements after unlocking new ones if !newly_unlocked.is_empty() { tracing::info!("Saving newly unlocked achievements from user message"); let app_handle = app.clone(); let achievements_progress = stats.read().achievements.clone(); tauri::async_runtime::spawn(async move { if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress) .await { tracing::error!("Failed to save achievements: {}", e); } else { tracing::info!("Achievements saved after user message"); } }); } emit_state_change(app, CharacterState::Thinking, None, conversation_id.clone()); } } Ok(()) } fn get_tool_state(tool_name: &str) -> CharacterState { if SEARCH_TOOLS.contains(&tool_name) { CharacterState::Searching } else if CODING_TOOLS.contains(&tool_name) { CharacterState::Coding } else if tool_name.starts_with("mcp__") { CharacterState::Mcp } else if tool_name == "Task" || tool_name.starts_with("Task(") || tool_name == "Agent" || tool_name.starts_with("Agent(") { CharacterState::Thinking } else { CharacterState::Typing } } fn format_rate_limit_message(info: &crate::types::RateLimitInfo) -> String { let mut parts = Vec::new(); if let (Some(remaining), Some(limit)) = (info.requests_remaining, info.requests_limit) { parts.push(format!("requests: {}/{}", remaining, limit)); } if let (Some(remaining), Some(limit)) = (info.tokens_remaining, info.tokens_limit) { parts.push(format!("tokens: {}/{}", remaining, limit)); } if let Some(reset) = &info.requests_reset { parts.push(format!("resets at {}", reset)); } else if let Some(reset) = &info.tokens_reset { parts.push(format!("resets at {}", reset)); } if let Some(retry_ms) = info.retry_after_ms { let secs = retry_ms / 1000; parts.push(format!("retry after {}s", secs)); } if parts.is_empty() { "Rate limit reached".to_string() } else { format!("Rate limit reached — {}", parts.join(", ")) } } fn format_tool_description(name: &str, input: &serde_json::Value) -> String { // Helper function to check if a path is a memory file fn is_memory_path(path: &str) -> bool { path.contains("/.claude/") && (path.contains("/memory/") || path.ends_with("/MEMORY.md")) } match name { "Read" => { if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) { if is_memory_path(path) { // Extract just the filename for cleaner display let filename = path.split('/').next_back().unwrap_or(path); format!("📝 Reading memory: {}", filename) } else { format!("Reading file: {}", path) } } else { "Reading file...".to_string() } } "Glob" => { if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) { format!("Searching for files: {}", pattern) } else { "Searching for files...".to_string() } } "Grep" => { if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) { format!("Searching for: {}", pattern) } else { "Searching in files...".to_string() } } "Edit" => { if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) { if is_memory_path(path) { let filename = path.split('/').next_back().unwrap_or(path); format!("💾 Updating memory: {}", filename) } else { format!("Editing: {}", path) } } else { "Editing file...".to_string() } } "Write" => { if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) { if is_memory_path(path) { let filename = path.split('/').next_back().unwrap_or(path); format!("💾 Writing memory: {}", filename) } else { format!("Editing: {}", path) } } else { "Editing file...".to_string() } } "Bash" => { if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { format!("Running: {}", cmd) } else { "Running command...".to_string() } } "EnterWorktree" => { if let Some(path) = input.get("path").and_then(|v| v.as_str()) { format!("Entering worktree: {}", path) } else { "Entering worktree session...".to_string() } } "ExitWorktree" => "Exiting worktree session...".to_string(), "CronCreate" => { if let Some(prompt) = input.get("prompt").and_then(|v| v.as_str()) { format!("Scheduling: {}", prompt) } else { "Scheduling recurring task...".to_string() } } "CronDelete" => { if let Some(id) = input.get("id").and_then(|v| v.as_str()) { format!("Removing scheduled task: {}", id) } else { "Removing scheduled task...".to_string() } } "CronList" => "Listing scheduled tasks...".to_string(), name if name == "Agent" || name.starts_with("Agent(") => { let task = input .get("description") .or_else(|| input.get("prompt")) .and_then(|v| v.as_str()); let agent_type = input.get("subagent_type").and_then(|v| v.as_str()); match (task, agent_type) { (Some(t), Some(a)) => format!("Launching {} agent: {}", a, t), (Some(t), None) => format!("Launching agent: {}", t), (None, Some(a)) => format!("Launching {} agent...", a), (None, None) => "Launching agent...".to_string(), } } _ => format!("Using tool: {}", name), } } fn emit_state_change( app: &AppHandle, state: CharacterState, tool_name: Option, conversation_id: Option, ) { let _ = app.emit( "claude:state", StateChangeEvent { state, tool_name, conversation_id, }, ); } fn emit_connection_status( app: &AppHandle, status: ConnectionStatus, conversation_id: Option, ) { let _ = app.emit( "claude:connection", ConnectionEvent { status, conversation_id, }, ); } #[cfg(test)] mod tests { use super::*; #[test] fn test_get_tool_state_search_tools() { assert!(matches!(get_tool_state("Read"), CharacterState::Searching)); assert!(matches!(get_tool_state("Glob"), CharacterState::Searching)); assert!(matches!(get_tool_state("Grep"), CharacterState::Searching)); assert!(matches!( get_tool_state("WebSearch"), CharacterState::Searching )); assert!(matches!( get_tool_state("WebFetch"), CharacterState::Searching )); } #[test] fn test_get_tool_state_coding_tools() { assert!(matches!(get_tool_state("Edit"), CharacterState::Coding)); assert!(matches!(get_tool_state("Write"), CharacterState::Coding)); assert!(matches!( get_tool_state("NotebookEdit"), CharacterState::Coding )); } #[test] fn test_get_tool_state_mcp_tools() { assert!(matches!( get_tool_state("mcp__github__create_issue"), CharacterState::Mcp )); assert!(matches!( get_tool_state("mcp__notion__search"), CharacterState::Mcp )); } #[test] fn test_get_tool_state_task() { assert!(matches!(get_tool_state("Task"), CharacterState::Thinking)); // Test CLI v2.1.33+ Task(agent_type) syntax assert!(matches!( get_tool_state("Task(Explore)"), CharacterState::Thinking )); assert!(matches!( get_tool_state("Task(Plan)"), CharacterState::Thinking )); assert!(matches!( get_tool_state("Task(general-purpose)"), CharacterState::Thinking )); } #[test] fn test_get_tool_state_agent() { // CLI v2.1.69+ renamed Task to Agent assert!(matches!(get_tool_state("Agent"), CharacterState::Thinking)); assert!(matches!( get_tool_state("Agent(Explore)"), CharacterState::Thinking )); assert!(matches!( get_tool_state("Agent(Plan)"), CharacterState::Thinking )); assert!(matches!( get_tool_state("Agent(general-purpose)"), CharacterState::Thinking )); } #[test] fn test_get_tool_state_unknown() { assert!(matches!( get_tool_state("SomeUnknownTool"), CharacterState::Typing )); assert!(matches!(get_tool_state("Bash"), CharacterState::Typing)); } #[test] fn test_format_tool_description_read() { let input = serde_json::json!({"file_path": "/home/test/file.txt"}); let desc = format_tool_description("Read", &input); assert_eq!(desc, "Reading file: /home/test/file.txt"); } #[test] fn test_format_tool_description_read_no_path() { let input = serde_json::json!({}); let desc = format_tool_description("Read", &input); assert_eq!(desc, "Reading file..."); } #[test] fn test_format_tool_description_glob() { let input = serde_json::json!({"pattern": "**/*.rs"}); let desc = format_tool_description("Glob", &input); assert_eq!(desc, "Searching for files: **/*.rs"); } #[test] fn test_format_tool_description_grep() { let input = serde_json::json!({"pattern": "TODO"}); let desc = format_tool_description("Grep", &input); assert_eq!(desc, "Searching for: TODO"); } #[test] fn test_format_tool_description_edit() { let input = serde_json::json!({"file_path": "/home/test/main.rs"}); let desc = format_tool_description("Edit", &input); assert_eq!(desc, "Editing: /home/test/main.rs"); } #[test] fn test_format_tool_description_write() { let input = serde_json::json!({"file_path": "/home/test/new.txt"}); let desc = format_tool_description("Write", &input); assert_eq!(desc, "Editing: /home/test/new.txt"); } #[test] fn test_format_tool_description_bash_short() { let input = serde_json::json!({"command": "ls -la"}); let desc = format_tool_description("Bash", &input); assert_eq!(desc, "Running: ls -la"); } #[test] fn test_format_tool_description_bash_long() { let long_cmd = "a".repeat(100); let input = serde_json::json!({"command": long_cmd}); let desc = format_tool_description("Bash", &input); assert_eq!(desc, format!("Running: {}", long_cmd)); } #[test] fn test_format_tool_description_unknown() { let input = serde_json::json!({"some": "data"}); let desc = format_tool_description("CustomTool", &input); assert_eq!(desc, "Using tool: CustomTool"); } #[test] fn test_format_tool_description_enter_worktree() { let input = serde_json::json!({"path": "/home/naomi/worktrees/feature-branch"}); let desc = format_tool_description("EnterWorktree", &input); assert_eq!(desc, "Entering worktree: /home/naomi/worktrees/feature-branch"); } #[test] fn test_format_tool_description_enter_worktree_no_path() { let input = serde_json::json!({}); let desc = format_tool_description("EnterWorktree", &input); assert_eq!(desc, "Entering worktree session..."); } #[test] fn test_format_tool_description_exit_worktree() { let input = serde_json::json!({}); let desc = format_tool_description("ExitWorktree", &input); assert_eq!(desc, "Exiting worktree session..."); } #[test] fn test_format_tool_description_cron_create() { let input = serde_json::json!({"prompt": "run tests", "schedule": "*/5 * * * *"}); let desc = format_tool_description("CronCreate", &input); assert_eq!(desc, "Scheduling: run tests"); } #[test] fn test_format_tool_description_cron_create_no_prompt() { let input = serde_json::json!({}); let desc = format_tool_description("CronCreate", &input); assert_eq!(desc, "Scheduling recurring task..."); } #[test] fn test_format_tool_description_cron_delete() { let input = serde_json::json!({"id": "cron-abc123"}); let desc = format_tool_description("CronDelete", &input); assert_eq!(desc, "Removing scheduled task: cron-abc123"); } #[test] fn test_format_tool_description_cron_delete_no_id() { let input = serde_json::json!({}); let desc = format_tool_description("CronDelete", &input); assert_eq!(desc, "Removing scheduled task..."); } #[test] fn test_format_tool_description_cron_list() { let input = serde_json::json!({}); let desc = format_tool_description("CronList", &input); assert_eq!(desc, "Listing scheduled tasks..."); } #[test] fn test_format_tool_description_agent_with_type_and_description() { let input = serde_json::json!({"subagent_type": "general-purpose", "description": "Fetch user info"}); let desc = format_tool_description("Agent", &input); assert_eq!(desc, "Launching general-purpose agent: Fetch user info"); } #[test] fn test_format_tool_description_agent_with_prompt() { let input = serde_json::json!({"subagent_type": "Explore", "prompt": "Look at the repo"}); let desc = format_tool_description("Agent", &input); assert_eq!(desc, "Launching Explore agent: Look at the repo"); } #[test] fn test_format_tool_description_agent_no_description() { let input = serde_json::json!({"subagent_type": "Plan"}); let desc = format_tool_description("Agent", &input); assert_eq!(desc, "Launching Plan agent..."); } #[test] fn test_format_tool_description_agent_no_fields() { let input = serde_json::json!({}); let desc = format_tool_description("Agent", &input); assert_eq!(desc, "Launching agent..."); } #[test] fn test_format_tool_description_agent_with_parenthesized_type() { let input = serde_json::json!({"description": "Check files"}); let desc = format_tool_description("Agent(Explore)", &input); assert_eq!(desc, "Launching agent: Check files"); } #[test] fn test_format_tool_description_memory_read() { let input = serde_json::json!({"file_path": "/home/user/.claude/projects/test/memory/MEMORY.md"}); let desc = format_tool_description("Read", &input); assert_eq!(desc, "📝 Reading memory: MEMORY.md"); } #[test] fn test_format_tool_description_memory_write() { let input = serde_json::json!( {"file_path": "/home/user/.claude/projects/test/memory/notes.md"} ); let desc = format_tool_description("Write", &input); assert_eq!(desc, "💾 Writing memory: notes.md"); } #[test] fn test_format_tool_description_memory_edit() { let input = serde_json::json!( {"file_path": "/home/user/.claude/projects/test/memory/patterns.md"} ); let desc = format_tool_description("Edit", &input); assert_eq!(desc, "💾 Updating memory: patterns.md"); } #[test] fn test_format_tool_description_non_memory_read() { let input = serde_json::json!({"file_path": "/home/user/code/test.txt"}); let desc = format_tool_description("Read", &input); assert_eq!(desc, "Reading file: /home/user/code/test.txt"); } #[test] fn test_wsl_bridge_new() { let bridge = WslBridge::new(); assert!(!bridge.is_running()); assert_eq!(bridge.get_working_directory(), ""); } #[test] fn test_wsl_bridge_default() { let bridge = WslBridge::default(); assert!(!bridge.is_running()); } #[test] fn test_stale_process_detection_with_try_wait() { // Spawn a real process that exits immediately so we can verify try_wait detects it let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'"); // Wait for it to exit let _ = child.wait(); // try_wait on an already-exited process should return Some(_) let status = child.try_wait(); assert!( status.is_ok(), "try_wait should not error on an exited process" ); // The process has already been waited on, so try_wait might return None or Some // depending on the OS - what matters is that the call succeeds } #[test] fn test_stale_process_is_some_after_exit() { // Verify the logic used in start(): a process that has exited is detected // and the handle is cleaned up so start() can proceed let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'"); // Let it exit let _ = child.wait(); // This mirrors the check in start() let has_exited = child .try_wait() .map(|s| s.is_some()) .unwrap_or(false); // After wait(), try_wait() returns None (already reaped), which means // unwrap_or(false) → false. The important thing is the call doesn't panic // and the control flow logic compiles and runs correctly. let _ = has_exited; // suppress unused warning } /// Build the WSL binary check command structure without executing it (for testing) #[cfg(test)] fn build_wsl_binary_check_args() -> Vec<&'static str> { vec!["-e", "bash", "-lc", "which claude"] } #[test] fn test_wsl_binary_check_command_structure() { // Windows path: verify Claude is detected inside WSL via `wsl -e bash -lc "which claude"` let args = build_wsl_binary_check_args(); assert_eq!(args[0], "-e"); assert_eq!(args[1], "bash"); assert_eq!(args[2], "-lc"); assert_eq!(args[3], "which claude"); } #[test] fn test_linux_binary_check_does_not_panic() { // Linux/WSL path: find_claude_binary() searches Linux filesystem paths. // We just verify it runs without panicking; whether it returns Some depends // on whether Claude is actually installed in this environment. let _result = find_claude_binary(); } #[test] fn test_create_shared_bridge_manager() { use crate::bridge_manager::create_shared_bridge_manager; let shared = create_shared_bridge_manager(); let manager = shared.lock(); assert!(manager.get_active_conversations().is_empty()); } // SubagentStart hook parsing tests #[test] fn test_parse_subagent_start_hook_with_parent() { let line = r#"[SubagentStart Hook] agent_id=agent-abc123, parent_tool_use_id=Some("toolu_01XYZ789"), session_id=123"#; let result = parse_subagent_start_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.agent_id, "agent-abc123"); assert_eq!(data.agent_type, None); assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string())); } #[test] fn test_parse_subagent_start_hook_without_parent() { let line = r#"[SubagentStart Hook] agent_id=agent-xyz789, parent_tool_use_id=None, session_id=456"#; let result = parse_subagent_start_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.agent_id, "agent-xyz789"); assert_eq!(data.agent_type, None); assert_eq!(data.parent_tool_use_id, None); } #[test] fn test_parse_subagent_start_hook_invalid() { let line = "[SubagentStart Hook] invalid data"; let result = parse_subagent_start_hook(line); assert!(result.is_none()); } #[test] fn test_parse_subagent_start_hook_with_extra_fields() { let line = r#"[SubagentStart Hook] agent_id=agent-test, parent_tool_use_id=Some("toolu_test"), session_id=789, cwd=/home/user"#; let result = parse_subagent_start_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.agent_id, "agent-test"); assert_eq!(data.agent_type, None); assert_eq!(data.parent_tool_use_id, Some("toolu_test".to_string())); } #[test] fn test_parse_subagent_start_hook_with_agent_type() { let line = r#"[SubagentStart Hook] agent_id=agent-abc123, agent_type=general-purpose, parent_tool_use_id=Some("toolu_01XYZ789"), session_id=123"#; let result = parse_subagent_start_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.agent_id, "agent-abc123"); assert_eq!(data.agent_type, Some("general-purpose".to_string())); assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string())); } #[test] fn test_parse_subagent_start_hook_with_explore_type() { let line = r#"[SubagentStart Hook] agent_id=agent-xyz789, agent_type=Explore, parent_tool_use_id=None, session_id=456"#; let result = parse_subagent_start_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.agent_id, "agent-xyz789"); assert_eq!(data.agent_type, Some("Explore".to_string())); assert_eq!(data.parent_tool_use_id, None); } // SubagentStop hook parsing tests #[test] fn test_parse_subagent_stop_hook_with_parent() { let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), session_id=123"#; let result = parse_subagent_stop_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string())); } #[test] fn test_parse_subagent_stop_hook_without_parent() { let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=None, session_id=456"#; let result = parse_subagent_stop_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.parent_tool_use_id, None); } #[test] fn test_parse_subagent_stop_hook_minimal() { let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_minimal")"#; let result = parse_subagent_stop_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.parent_tool_use_id, Some("toolu_minimal".to_string())); } #[test] fn test_parse_subagent_stop_hook_with_extra_fields() { let line = r#"[SubagentStop Hook] stop_hook_active=false, parent_tool_use_id=Some("toolu_extra"), session_id=789, transcript_path=/path/to/transcript"#; let result = parse_subagent_stop_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.parent_tool_use_id, Some("toolu_extra".to_string())); } #[test] fn test_parse_subagent_stop_hook_empty() { let line = "[SubagentStop Hook]"; let result = parse_subagent_stop_hook(line); // Should still return Some with None parent_tool_use_id assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.parent_tool_use_id, None); assert_eq!(data.last_assistant_message, None); } #[test] fn test_parse_subagent_stop_hook_with_last_message() { let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=Some("Task completed successfully."), session_id=123"#; let result = parse_subagent_stop_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string())); assert_eq!( data.last_assistant_message, Some("Task completed successfully.".to_string()) ); } #[test] fn test_parse_subagent_stop_hook_with_last_message_none() { let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=None, session_id=123"#; let result = parse_subagent_stop_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!(data.last_assistant_message, None); } #[test] fn test_extract_debug_string_value_simple() { let line = r#"key=Some("hello world")"#; assert_eq!( extract_debug_string_value(line, "key"), Some("hello world".to_string()) ); } #[test] fn test_extract_debug_string_value_with_escaped_quotes() { let line = r#"key=Some("say \"hi\" there")"#; assert_eq!( extract_debug_string_value(line, "key"), Some(r#"say "hi" there"#.to_string()) ); } #[test] fn test_extract_debug_string_value_none_variant() { let line = "key=None"; assert_eq!(extract_debug_string_value(line, "key"), None); } #[test] fn test_extract_debug_string_value_missing_key() { let line = "other=Some(\"value\")"; assert_eq!(extract_debug_string_value(line, "key"), None); } #[test] fn test_parse_subagent_stop_hook_with_commas_in_message() { let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_01"), last_assistant_message=Some("Found 3 files, all passing.")"#; let result = parse_subagent_stop_hook(line); assert!(result.is_some()); let data = result.unwrap(); assert_eq!( data.last_assistant_message, Some("Found 3 files, all passing.".to_string()) ); } // extract_tool_result_text tests #[test] fn test_extract_tool_result_text_plain_string() { let content = serde_json::json!("Hello from agent"); assert_eq!( extract_tool_result_text(&content), Some("Hello from agent".to_string()) ); } #[test] fn test_extract_tool_result_text_empty_string() { let content = serde_json::json!(""); assert_eq!(extract_tool_result_text(&content), None); } #[test] fn test_extract_tool_result_text_array_single_text_block() { let content = serde_json::json!([{"type": "text", "text": "Agent completed the task."}]); assert_eq!( extract_tool_result_text(&content), Some("Agent completed the task.".to_string()) ); } #[test] fn test_extract_tool_result_text_array_multiple_text_blocks() { let content = serde_json::json!([ {"type": "text", "text": "First part."}, {"type": "text", "text": "Second part."} ]); assert_eq!( extract_tool_result_text(&content), Some("First part.\nSecond part.".to_string()) ); } #[test] fn test_extract_tool_result_text_array_non_text_block() { let content = serde_json::json!([{"type": "image", "source": {"type": "base64"}}]); assert_eq!(extract_tool_result_text(&content), None); } #[test] fn test_extract_tool_result_text_array_mixed_blocks() { let content = serde_json::json!([ {"type": "image", "source": {}}, {"type": "text", "text": "Found results."} ]); assert_eq!( extract_tool_result_text(&content), Some("Found results.".to_string()) ); } #[test] fn test_extract_tool_result_text_null() { let content = serde_json::Value::Null; assert_eq!(extract_tool_result_text(&content), None); } #[test] fn test_extract_tool_result_text_empty_array() { let content = serde_json::json!([]); assert_eq!(extract_tool_result_text(&content), None); } // Verify the 50K tool result persistence threshold (CLI v2.1.51+). // Results > 50K chars are now persisted to disk; the stream may send null // or a large inline string. Both must be handled without panicking. #[test] fn test_extract_tool_result_text_large_content_above_50k_threshold() { let large_text = "x".repeat(60_000); let content = serde_json::Value::String(large_text.clone()); assert_eq!(extract_tool_result_text(&content), Some(large_text)); } #[test] fn test_tool_result_deserializes_with_null_content() { let json = r#"{"type":"tool_result","tool_use_id":"toolu_abc","content":null}"#; let block: ContentBlock = serde_json::from_str(json).unwrap(); if let ContentBlock::ToolResult { tool_use_id, content, is_error } = block { assert_eq!(tool_use_id, "toolu_abc"); assert!(content.is_null()); assert_eq!(is_error, None); // Persisted-to-disk results produce null content → no preview shown assert_eq!(extract_tool_result_text(&content), None); } else { panic!("Expected ToolResult variant"); } } // Mid-session watchdog: pending_since lifecycle tests #[test] fn test_pending_since_starts_as_none() { let bridge = WslBridge::new(); assert!(bridge.pending_since.lock().is_none()); } #[test] fn test_watchdog_generation_starts_at_zero() { let bridge = WslBridge::new(); assert_eq!(bridge.watchdog_generation.load(Ordering::SeqCst), 0); } #[test] fn test_pending_since_set_reflects_elapsed_time() { let pending_since: Arc>> = Arc::new(Mutex::new(None)); // Simulate send_message setting pending_since *pending_since.lock() = Some(Instant::now()); // Should be Some and elapsed should be tiny (< 1 second) let elapsed = (*pending_since.lock()).map(|t| t.elapsed()); assert!(elapsed.is_some()); assert!(elapsed.unwrap() < Duration::from_secs(1)); } #[test] fn test_pending_since_cleared_on_result_simulates_watchdog_safe() { let pending_since: Arc>> = Arc::new(Mutex::new(None)); // Simulate send_message *pending_since.lock() = Some(Instant::now()); assert!(pending_since.lock().is_some()); // Simulate Result message arriving (as process_json_line does) *pending_since.lock() = None; assert!(pending_since.lock().is_none()); // Watchdog check: elapsed is None → no kill triggered let elapsed = (*pending_since.lock()).map(|t| t.elapsed()); assert!(elapsed.is_none()); } #[test] fn test_watchdog_generation_increments_per_session() { let bridge = WslBridge::new(); assert_eq!(bridge.watchdog_generation.load(Ordering::SeqCst), 0); // Simulate what start() does: fetch_add(1) returns old value, +1 gives new generation let gen1 = bridge.watchdog_generation.fetch_add(1, Ordering::SeqCst) + 1; assert_eq!(gen1, 1); assert_eq!(bridge.watchdog_generation.load(Ordering::SeqCst), 1); let gen2 = bridge.watchdog_generation.fetch_add(1, Ordering::SeqCst) + 1; assert_eq!(gen2, 2); assert_eq!(bridge.watchdog_generation.load(Ordering::SeqCst), 2); } #[test] fn test_watchdog_generation_mismatch_means_old_session() { // Simulate: watchdog captured generation=1, but start() was called again → generation=2. // The watchdog should detect this and exit without killing. let generation_arc: Arc = Arc::new(AtomicU64::new(1)); let captured_generation: u64 = 1; assert_eq!(generation_arc.load(Ordering::SeqCst), captured_generation, "same session"); // New start() call increments generation generation_arc.fetch_add(1, Ordering::SeqCst); assert_ne!( generation_arc.load(Ordering::SeqCst), captured_generation, "old watchdog detects new session and should exit" ); } #[test] fn test_stuck_timeout_threshold() { // The timeout constant used in the mid-session watchdog is 5 minutes. // This test documents and validates that threshold. const STUCK_TIMEOUT: Duration = Duration::from_secs(5 * 60); assert_eq!(STUCK_TIMEOUT, Duration::from_secs(300)); // A message sent 4m59s ago should NOT trigger the watchdog let just_under = Duration::from_secs(299); assert!(just_under < STUCK_TIMEOUT); // A message sent 5m0s ago SHOULD trigger the watchdog let exactly_at = Duration::from_secs(300); assert!(exactly_at >= STUCK_TIMEOUT); } #[test] fn test_pending_since_reset_on_assistant_message_simulates_long_response() { // Regression test: an Assistant message arriving during a long multi-step response // (e.g. subagents, chained tool calls) must reset pending_since to Instant::now() // so the watchdog timer measures silence since the *last Claude activity*, not the // total wall-clock time since the user's message was sent. let pending_since: Arc>> = Arc::new(Mutex::new(None)); // User sends a message — watchdog timer starts *pending_since.lock() = Some(Instant::now()); let original_instant = (*pending_since.lock()).unwrap(); // Simulate some time passing before Claude first responds (not enough to sleep in tests, // but we verify the reset logic by recording the original instant and confirming it // is replaced after an Assistant message arrives). // In production this represents minutes of subagent work. // Assistant message arrives — timer must be reset, not cleared *pending_since.lock() = Some(Instant::now()); let after_reset = (*pending_since.lock()).unwrap(); // Still Some (watchdog still active until Result arrives) assert!(pending_since.lock().is_some(), "pending_since must remain Some after an Assistant message"); // The reset instant must be >= the original (monotonic clock) assert!( after_reset >= original_instant, "reset instant should be at least as recent as the original" ); // Elapsed since reset is tiny — watchdog would NOT fire let elapsed_since_reset = after_reset.elapsed(); assert!( elapsed_since_reset < Duration::from_secs(1), "elapsed since reset should be under 1 second in tests" ); // Final Result clears it entirely *pending_since.lock() = None; assert!(pending_since.lock().is_none(), "pending_since cleared on Result"); } #[test] fn test_parse_worktree_hook_create_with_all_fields() { let line = r#"[WorktreeCreate Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, branch=feat/my-feature, original_repo_directory=/home/naomi/code/project, session_id=123"#; let result = parse_worktree_hook(line); assert!(result.is_some()); let info = result.unwrap(); assert_eq!(info.name, "worktree-abc"); assert_eq!(info.path, "/tmp/worktrees/worktree-abc"); assert_eq!(info.branch, "feat/my-feature"); assert_eq!(info.original_repo_directory, "/home/naomi/code/project"); } #[test] fn test_parse_worktree_hook_remove_with_all_fields() { let line = r#"[WorktreeRemove Hook] name=worktree-xyz, path=/tmp/worktrees/worktree-xyz, branch=fix/bug-123, original_repo_directory=/home/naomi/code/other, session_id=456"#; let result = parse_worktree_hook(line); assert!(result.is_some()); let info = result.unwrap(); assert_eq!(info.name, "worktree-xyz"); assert_eq!(info.branch, "fix/bug-123"); assert_eq!(info.original_repo_directory, "/home/naomi/code/other"); } #[test] fn test_parse_worktree_hook_missing_field_returns_none() { // Missing branch field — should return None let line = r#"[WorktreeCreate Hook] name=worktree-abc, path=/tmp/worktrees/worktree-abc, original_repo_directory=/home/naomi/code/project, session_id=123"#; let result = parse_worktree_hook(line); assert!(result.is_none()); } #[test] fn test_parse_worktree_hook_invalid_returns_none() { let line = "[WorktreeCreate Hook] no structured data here"; let result = parse_worktree_hook(line); assert!(result.is_none()); } /// Build the auto-memory settings JSON without executing a command (for testing) #[cfg(test)] fn build_auto_memory_settings_arg(dir: &str) -> String { format!(r#"{{"autoMemoryDirectory":"{}"}}"#, dir) } #[test] fn test_e2e_auto_memory_settings_structure() { let settings_json = build_auto_memory_settings_arg("/custom/memory/dir"); assert_eq!( settings_json, r#"{"autoMemoryDirectory":"/custom/memory/dir"}"# ); } #[test] fn test_e2e_auto_memory_settings_empty_path_skipped() { let dir = ""; assert!(dir.is_empty(), "Empty directory should be skipped"); } /// Build the combined settings JSON for both memory directory and model overrides (for testing) #[cfg(test)] fn build_combined_settings_arg( memory_dir: Option<&str>, model_overrides: Option<&std::collections::HashMap>, ) -> String { let mut settings = serde_json::Map::new(); if let Some(dir) = memory_dir { if !dir.is_empty() { settings.insert( "autoMemoryDirectory".to_string(), serde_json::Value::String(dir.to_string()), ); } } if let Some(overrides) = model_overrides { if !overrides.is_empty() { if let Ok(val) = serde_json::to_value(overrides) { settings.insert("modelOverrides".to_string(), val); } } } serde_json::to_string(&settings).unwrap_or_default() } #[test] fn test_e2e_combined_settings_memory_only() { let result = build_combined_settings_arg(Some("/custom/dir"), None); assert_eq!(result, r#"{"autoMemoryDirectory":"/custom/dir"}"#); } #[test] fn test_e2e_combined_settings_overrides_only() { let mut overrides = std::collections::HashMap::new(); overrides.insert( "claude-opus-4-6".to_string(), "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(), ); let result = build_combined_settings_arg(None, Some(&overrides)); assert!(result.contains("modelOverrides")); assert!(result.contains("claude-opus-4-6")); assert!(result.contains("arn:aws:bedrock")); } #[test] fn test_e2e_combined_settings_both_fields() { let mut overrides = std::collections::HashMap::new(); overrides.insert("claude-opus-4-6".to_string(), "custom-model-id".to_string()); let result = build_combined_settings_arg(Some("/mem/dir"), Some(&overrides)); assert!(result.contains("autoMemoryDirectory")); assert!(result.contains("modelOverrides")); assert!(result.contains("/mem/dir")); assert!(result.contains("custom-model-id")); } #[test] fn test_e2e_combined_settings_empty_produces_empty_object() { let result = build_combined_settings_arg(Some(""), None); assert_eq!(result, "{}"); } #[test] fn test_extract_quoted_value_basic() { let line = r#"[Elicitation Hook] message="Hello world", server_name=Some("srv")"#; let result = extract_quoted_value(line, "message"); assert_eq!(result, Some("Hello world".to_string())); } #[test] fn test_extract_quoted_value_with_escapes() { let line = r#"[Elicitation Hook] message="Line one\nLine two", request_id=Some("r1")"#; let result = extract_quoted_value(line, "message"); assert_eq!(result, Some("Line one\nLine two".to_string())); } #[test] fn test_extract_quoted_value_missing() { let line = r#"[Elicitation Hook] server_name=Some("srv")"#; let result = extract_quoted_value(line, "message"); assert_eq!(result, None); } #[test] fn test_parse_elicitation_hook_with_all_fields() { let line = r#"[Elicitation Hook] message="Please enter your API key", server_name=Some("my-mcp"), request_id=Some("req-456")"#; let data = parse_elicitation_hook(line); assert_eq!(data.message, "Please enter your API key"); assert_eq!(data.server_name, Some("my-mcp".to_string())); assert_eq!(data.request_id, Some("req-456".to_string())); } #[test] fn test_parse_elicitation_hook_missing_optional_fields() { let line = r#"[Elicitation Hook] message="What is the endpoint?", server_name=None, request_id=None"#; let data = parse_elicitation_hook(line); assert_eq!(data.message, "What is the endpoint?"); assert_eq!(data.server_name, None); assert_eq!(data.request_id, None); } #[test] fn test_parse_elicitation_hook_invalid_line() { let line = "[Elicitation Hook] some unstructured data"; let data = parse_elicitation_hook(line); assert_eq!(data.message, "some unstructured data"); assert_eq!(data.server_name, None); assert_eq!(data.request_id, None); } #[test] fn test_parse_elicitation_result_hook_accept() { let line = r#"[ElicitationResult Hook] action="accept", request_id=Some("req-789")"#; let data = parse_elicitation_result_hook(line); assert_eq!(data.action, "accept"); assert_eq!(data.request_id, Some("req-789".to_string())); } #[test] fn test_parse_elicitation_result_hook_cancel() { let line = r#"[ElicitationResult Hook] action="cancel", request_id=None"#; let data = parse_elicitation_result_hook(line); assert_eq!(data.action, "cancel"); assert_eq!(data.request_id, None); } #[test] fn test_parse_stop_failure_hook_with_all_fields() { let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=Some("rate_limit"), conversation_id=Some("conv-123")"#; let data = parse_stop_failure_hook(line); assert_eq!(data.stop_reason, Some("api_error".to_string())); assert_eq!(data.error_type, Some("rate_limit".to_string())); } #[test] fn test_parse_stop_failure_hook_missing_optional_error_type() { let line = r#"[StopFailure Hook] stop_reason="api_error", error_type=None"#; let data = parse_stop_failure_hook(line); assert_eq!(data.stop_reason, Some("api_error".to_string())); assert_eq!(data.error_type, None); } #[test] fn test_parse_stop_failure_hook_invalid_line() { let line = "[StopFailure Hook] some unstructured data"; let data = parse_stop_failure_hook(line); assert_eq!(data.stop_reason, None); assert_eq!(data.error_type, None); } #[test] fn test_build_stop_failure_message_rate_limit() { let data = StopFailureData { stop_reason: Some("rate_limit".to_string()), error_type: None, }; assert_eq!(build_stop_failure_message(&data), "Session stopped: rate limit reached"); } #[test] fn test_build_stop_failure_message_auth_failure() { let data = StopFailureData { stop_reason: Some("auth_failure".to_string()), error_type: None, }; assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed"); } #[test] fn test_build_stop_failure_message_authentication() { let data = StopFailureData { stop_reason: Some("authentication".to_string()), error_type: None, }; assert_eq!(build_stop_failure_message(&data), "Session stopped: authentication failed"); } #[test] fn test_build_stop_failure_message_unknown_reason() { let data = StopFailureData { stop_reason: Some("server_error".to_string()), error_type: None, }; assert_eq!( build_stop_failure_message(&data), "Session stopped due to API error: server_error" ); } #[test] fn test_build_stop_failure_message_no_reason_with_error_type() { let data = StopFailureData { stop_reason: None, error_type: Some("timeout".to_string()), }; assert_eq!( build_stop_failure_message(&data), "Session stopped due to API error: timeout" ); } #[test] fn test_parse_post_compact_hook_with_session_id() { let line = r#"[PostCompact Hook] session_id=Some("sess-abc123"), conversation_id=Some("conv-xyz")"#; let data = parse_post_compact_hook(line); assert_eq!(data.session_id, Some("sess-abc123".to_string())); } #[test] fn test_parse_post_compact_hook_without_session_id() { let line = "[PostCompact Hook] session_id=None"; let data = parse_post_compact_hook(line); assert_eq!(data.session_id, None); } #[test] fn test_parse_post_compact_hook_empty_line() { let line = "[PostCompact Hook]"; let data = parse_post_compact_hook(line); assert_eq!(data.session_id, None); } #[test] fn test_build_stop_failure_message_no_fields() { let data = StopFailureData { stop_reason: None, error_type: None, }; assert_eq!( build_stop_failure_message(&data), "Session stopped due to an unknown API error" ); } /// Build the --name argument string without executing a command (for testing) #[cfg(test)] fn build_session_name_arg(name: &str) -> Option<(String, String)> { if name.is_empty() { return None; } Some(("--name".to_string(), name.to_string())) } #[test] fn test_e2e_session_name_passed_when_set() { let name = "Sakura"; let result = build_session_name_arg(name); assert!(result.is_some()); let (flag, value) = result.unwrap(); assert_eq!(flag, "--name"); assert_eq!(value, "Sakura"); } #[test] fn test_e2e_session_name_not_passed_when_none() { let options = ClaudeStartOptions { session_name: None, ..Default::default() }; // When session_name is None, no --name arg should be produced let has_name = options.session_name.as_deref().map(|n| !n.is_empty()).unwrap_or(false); assert!(!has_name); } #[test] fn test_e2e_session_name_not_passed_when_empty() { let options = ClaudeStartOptions { session_name: Some(String::new()), ..Default::default() }; // When session_name is Some(""), no --name arg should be produced let has_name = options.session_name.as_deref().map(|n| !n.is_empty()).unwrap_or(false); assert!(!has_name); } }