Files
hikari-desktop/src-tauri/src/wsl_bridge.rs
T

3793 lines
146 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, CwdChangedEvent, ElicitationEvent, ElicitationResultEvent,
FileChangedEvent, MessageCost, OutputEvent, PermissionPromptEvent, PermissionPromptEventItem,
PostCompactEvent, QuestionOption, SessionEvent, 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<Vec<PendingToolUse>> = 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<String> {
// 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<Mutex<Option<Child>>>,
stdin: Option<ChildStdin>,
working_directory: String,
session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
/// Set to true once the `system:init` message arrives, false at the start of every new session.
received_init: Arc<AtomicBool>,
/// 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<AtomicBool>,
/// 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<Mutex<Option<Instant>>>,
/// Incremented each time `start()` is called so each session's watchdog knows when to exit.
watchdog_generation: Arc<AtomicU64>,
}
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::<serde_json::Value>(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();
// 5-minute stuck timeout is intentionally larger than the CLI's 2-minute per-attempt
// non-streaming fallback (added in v2.1.79). A non-streaming response will either
// succeed or fail with a Result message well within this window.
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<RwLock<UsageStats>>,
conversation_id: Option<String>,
received_init: Arc<AtomicBool>,
intentional_stop: Arc<AtomicBool>,
pending_since: Arc<Mutex<Option<Instant>>>,
) {
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<String>,
) {
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 is_cwd_changed = line.contains("[CwdChanged Hook]");
let is_file_changed = line.contains("[FileChanged 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 if is_cwd_changed {
"cwd-changed"
} else if is_file_changed {
"file-changed"
} 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 if is_cwd_changed {
let data = parse_cwd_changed_hook(&line);
let friendly_content =
format!("Working directory changed to: {}", data.cwd);
let _ = app.emit(
"claude:cwd-changed",
CwdChangedEvent {
cwd: data.cwd,
conversation_id: conversation_id.clone(),
},
);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "cwd-changed".to_string(),
content: friendly_content,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
} else if is_file_changed {
let data = parse_file_changed_hook(&line);
let friendly_content = if data.file.is_empty() {
"File changed".to_string()
} else {
format!("File changed: {}", data.file)
};
let _ = app.emit(
"claude:file-changed",
FileChangedEvent {
file: data.file,
conversation_id: conversation_id.clone(),
},
);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "file-changed".to_string(),
content: friendly_content,
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<String>,
parent_tool_use_id: Option<String>,
}
fn parse_worktree_hook(line: &str) -> Option<WorktreeInfo> {
// 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<String> {
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<SubagentStartData> {
// 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<String>,
last_assistant_message: Option<String>,
}
/// 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<String> {
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<SubagentStopData> {
// 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<String>,
request_id: Option<String>,
}
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<String>,
}
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<String>,
error_type: Option<String>,
}
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<String>,
}
fn parse_post_compact_hook(line: &str) -> PostCompactData {
let session_id = extract_debug_string_value(line, "session_id");
PostCompactData { session_id }
}
#[derive(Debug)]
struct CwdChangedData {
cwd: String,
}
fn parse_cwd_changed_hook(line: &str) -> CwdChangedData {
let cwd = extract_quoted_value(line, "cwd")
.or_else(|| extract_quoted_value(line, "path"))
.or_else(|| {
line.split("[CwdChanged Hook]")
.nth(1)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_default();
CwdChangedData { cwd }
}
#[derive(Debug)]
struct FileChangedData {
file: String,
}
fn parse_file_changed_hook(line: &str) -> FileChangedData {
let file = extract_quoted_value(line, "file")
.or_else(|| extract_quoted_value(line, "path"))
.or_else(|| {
line.split("[FileChanged Hook]")
.nth(1)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
})
.unwrap_or_default();
FileChangedData { file }
}
/// 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<String> {
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<String> {
match content {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Array(blocks) => {
let texts: Vec<String> = 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<RwLock<UsageStats>>,
conversation_id: &Option<String>,
received_init: &Arc<AtomicBool>,
pending_since: &Arc<Mutex<Option<Instant>>>,
) -> 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<String> = 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<PendingToolUse> = 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<MessageCost> = 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<TodoItem> = 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::<Vec<_>>()
);
// 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, &current_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<String> = 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<QuestionOption> = 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 &regular_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::<Vec<String>>()
.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<String>,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:state",
StateChangeEvent {
state,
tool_name,
conversation_id,
},
);
}
fn emit_connection_status(
app: &AppHandle,
status: ConnectionStatus,
conversation_id: Option<String>,
) {
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<Mutex<Option<Instant>>> = 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<Mutex<Option<Instant>>> = 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<AtomicU64> = 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<Mutex<Option<Instant>>> = 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, String>>,
) -> 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_parse_cwd_changed_hook_with_cwd_key() {
let line = r#"[CwdChanged Hook] cwd="/home/naomi/code/my-project""#;
let data = parse_cwd_changed_hook(line);
assert_eq!(data.cwd, "/home/naomi/code/my-project");
}
#[test]
fn test_parse_cwd_changed_hook_with_path_key() {
let line = r#"[CwdChanged Hook] path="/tmp/workspace""#;
let data = parse_cwd_changed_hook(line);
assert_eq!(data.cwd, "/tmp/workspace");
}
#[test]
fn test_parse_cwd_changed_hook_bare_path_fallback() {
let line = "[CwdChanged Hook] /home/naomi/code/project";
let data = parse_cwd_changed_hook(line);
assert_eq!(data.cwd, "/home/naomi/code/project");
}
#[test]
fn test_parse_cwd_changed_hook_empty_line() {
let line = "[CwdChanged Hook]";
let data = parse_cwd_changed_hook(line);
assert_eq!(data.cwd, "");
}
#[test]
fn test_parse_file_changed_hook_with_file_key() {
let line = r#"[FileChanged Hook] file="/home/naomi/code/project/src/main.rs""#;
let data = parse_file_changed_hook(line);
assert_eq!(data.file, "/home/naomi/code/project/src/main.rs");
}
#[test]
fn test_parse_file_changed_hook_with_path_key() {
let line = r#"[FileChanged Hook] path="/tmp/test.txt""#;
let data = parse_file_changed_hook(line);
assert_eq!(data.file, "/tmp/test.txt");
}
#[test]
fn test_parse_file_changed_hook_bare_path_fallback() {
let line = "[FileChanged Hook] /home/naomi/code/project/README.md";
let data = parse_file_changed_hook(line);
assert_eq!(data.file, "/home/naomi/code/project/README.md");
}
#[test]
fn test_parse_file_changed_hook_empty_line() {
let line = "[FileChanged Hook]";
let data = parse_file_changed_hook(line);
assert_eq!(data.file, "");
}
#[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);
}
}