generated from nhcarrigan/template
3c892b95cf
Adds session_name to ClaudeStartOptions and passes --name to Claude Code on initial session start, keeping tab names in sync with Claude Code.
3637 lines
140 KiB
Rust
3637 lines
140 KiB
Rust
use std::io::{BufRead, BufReader, Write};
|
||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||
use std::sync::Arc;
|
||
use std::thread;
|
||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||
use parking_lot::Mutex;
|
||
use tauri::{AppHandle, Emitter};
|
||
use tempfile::NamedTempFile;
|
||
|
||
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||
use crate::commands::record_cost;
|
||
use crate::config::ClaudeStartOptions;
|
||
use crate::process_ext::HideWindow;
|
||
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
|
||
use crate::types::{
|
||
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
|
||
ConnectionStatus, ContentBlock, ElicitationEvent, ElicitationResultEvent, MessageCost,
|
||
OutputEvent, PermissionPromptEvent, PermissionPromptEventItem, QuestionOption, SessionEvent,
|
||
PostCompactEvent, StateChangeEvent, StopFailureEvent, TodoItem, TodoUpdateEvent,
|
||
UserQuestionEvent, WorkingDirectoryEvent, WorktreeEvent, WorktreeInfo,
|
||
};
|
||
use parking_lot::RwLock;
|
||
use std::cell::RefCell;
|
||
|
||
thread_local! {
|
||
/// Stores pending tool uses from the most recent Assistant message
|
||
/// to enable batching permission requests for sibling cancelled tools
|
||
static PENDING_TOOL_USES: RefCell<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 5 minutes we kill it so the user isn't stuck forever.
|
||
// handle_stdout will surface the error when stdout closes after the kill.
|
||
let process_watchdog = self.process.clone();
|
||
let received_init_watchdog = self.received_init.clone();
|
||
thread::spawn(move || {
|
||
thread::sleep(Duration::from_secs(60));
|
||
if !received_init_watchdog.load(Ordering::SeqCst) {
|
||
if let Some(mut proc) = process_watchdog.lock().take() {
|
||
let _ = proc.kill();
|
||
let _ = proc.wait();
|
||
}
|
||
}
|
||
});
|
||
|
||
// Reset the pending-since tracker for this new session so stale state from a previous
|
||
// session never triggers the mid-session watchdog immediately.
|
||
*self.pending_since.lock() = None;
|
||
|
||
// Mid-session watchdog: if a user message is sent but no Result arrives within 5 minutes,
|
||
// the Claude Code process is stuck. Kill it so the user gets a disconnect event and can
|
||
// reconnect. The generation counter ensures old watchdogs from previous sessions exit
|
||
// cleanly when `start()` is called again.
|
||
let generation = self.watchdog_generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||
let process_mid_watchdog = self.process.clone();
|
||
let pending_since_watchdog = self.pending_since.clone();
|
||
let generation_watchdog = self.watchdog_generation.clone();
|
||
const STUCK_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||
const POLL_INTERVAL: Duration = Duration::from_secs(30);
|
||
thread::spawn(move || {
|
||
loop {
|
||
thread::sleep(POLL_INTERVAL);
|
||
// Exit if a newer session has started.
|
||
if generation_watchdog.load(Ordering::SeqCst) != generation {
|
||
break;
|
||
}
|
||
// Exit if the process has already been taken (killed or stopped).
|
||
if process_mid_watchdog.lock().is_none() {
|
||
break;
|
||
}
|
||
let elapsed = (*pending_since_watchdog.lock()).map(|t| t.elapsed());
|
||
if let Some(elapsed) = elapsed {
|
||
if elapsed >= STUCK_TIMEOUT {
|
||
tracing::warn!(
|
||
"Mid-session watchdog: no Result received in {:?}; killing stuck process",
|
||
elapsed
|
||
);
|
||
if let Some(mut proc) = process_mid_watchdog.lock().take() {
|
||
let _ = proc.kill();
|
||
let _ = proc.wait();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub fn send_message(&mut self, message: &str) -> Result<(), String> {
|
||
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
|
||
|
||
// Track input for cost estimation on interrupt
|
||
{
|
||
let mut stats = self.stats.write();
|
||
stats.current_request_input = Some(message.to_string());
|
||
stats.current_request_output_chars = 0;
|
||
stats.current_request_thinking_chars = 0;
|
||
stats.current_request_tools.clear();
|
||
}
|
||
|
||
let input = serde_json::json!({
|
||
"type": "user",
|
||
"message": {
|
||
"role": "user",
|
||
"content": [{
|
||
"type": "text",
|
||
"text": message
|
||
}]
|
||
}
|
||
});
|
||
|
||
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
|
||
|
||
stdin
|
||
.write_all(format!("{}\n", json_line).as_bytes())
|
||
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
||
|
||
stdin
|
||
.flush()
|
||
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
|
||
|
||
// Record the time this message was sent so the mid-session watchdog can detect
|
||
// if no Result ever arrives (i.e. the process is stuck).
|
||
*self.pending_since.lock() = Some(Instant::now());
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub fn send_tool_result(
|
||
&mut self,
|
||
tool_use_id: &str,
|
||
result: serde_json::Value,
|
||
) -> Result<(), String> {
|
||
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
|
||
|
||
// The content should be a JSON string representation of the result
|
||
let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?;
|
||
|
||
let input = serde_json::json!({
|
||
"type": "user",
|
||
"message": {
|
||
"role": "user",
|
||
"content": [{
|
||
"type": "tool_result",
|
||
"tool_use_id": tool_use_id,
|
||
"content": content_str
|
||
}]
|
||
}
|
||
});
|
||
|
||
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
|
||
|
||
stdin
|
||
.write_all(format!("{}\n", json_line).as_bytes())
|
||
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
||
|
||
stdin
|
||
.flush()
|
||
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub fn interrupt(&mut self, app: &AppHandle) -> Result<(), String> {
|
||
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
|
||
// we have to kill the process. This is the only reliable way to stop it.
|
||
// See: https://github.com/anthropics/claude-code/issues/3455
|
||
// Extract the process first so the MutexGuard is dropped before we mutably
|
||
// borrow `self` again via estimate_interrupted_request_cost.
|
||
|
||
// Signal handle_stdout that this is an intentional stop so it doesn't emit
|
||
// a second Disconnected event after stdout closes due to the kill.
|
||
self.intentional_stop.store(true, Ordering::SeqCst);
|
||
|
||
let maybe_process = self.process.lock().take();
|
||
if let Some(mut process) = maybe_process {
|
||
// Estimate cost for interrupted request before killing
|
||
self.estimate_interrupted_request_cost(app);
|
||
|
||
// Kill the process immediately
|
||
let _ = process.kill();
|
||
let _ = process.wait();
|
||
|
||
// Clear stdin
|
||
self.stdin = None;
|
||
|
||
// Clear tracking fields
|
||
{
|
||
let mut stats = self.stats.write();
|
||
stats.current_request_input = None;
|
||
stats.current_request_output_chars = 0;
|
||
stats.current_request_thinking_chars = 0;
|
||
stats.current_request_tools.clear();
|
||
}
|
||
|
||
// Keep session_id and working directory for user reference
|
||
// The user will see what session was interrupted
|
||
|
||
// Emit disconnected status
|
||
emit_connection_status(
|
||
app,
|
||
ConnectionStatus::Disconnected,
|
||
self.conversation_id.clone(),
|
||
);
|
||
|
||
Ok(())
|
||
} else {
|
||
Err("No active process to interrupt".to_string())
|
||
}
|
||
}
|
||
|
||
fn estimate_interrupted_request_cost(&mut self, app: &AppHandle) {
|
||
// Read tracking data from stats
|
||
let (input_chars, output_chars, thinking_chars, tools, model) = {
|
||
let stats = self.stats.read();
|
||
|
||
// Only estimate if we have tracked content
|
||
if stats.current_request_input.is_none()
|
||
&& stats.current_request_output_chars == 0
|
||
&& stats.current_request_thinking_chars == 0
|
||
&& stats.current_request_tools.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let input_chars = stats.current_request_input.as_ref().map(|s| s.len() as u64).unwrap_or(0);
|
||
let model = stats.model.clone().unwrap_or_else(|| "claude-sonnet-4-5-20250929".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");
|
||
|
||
// Use conservative 3.5 chars/token for estimation (vs standard 4)
|
||
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 line_type = if is_worktree_create || is_worktree_remove {
|
||
"worktree"
|
||
} else if line.contains("[ConfigChange Hook]") {
|
||
"config-change"
|
||
} else if is_elicitation || is_elicitation_result {
|
||
"elicitation"
|
||
} else if is_stop_failure {
|
||
"error"
|
||
} else if is_post_compact {
|
||
"compact-prompt"
|
||
} else {
|
||
"error"
|
||
};
|
||
|
||
// For worktree hooks, parse structured data and emit a dedicated event
|
||
if is_worktree_create || is_worktree_remove {
|
||
let worktree_info = parse_worktree_hook(&line);
|
||
let event_type = if is_worktree_create { "create" } else { "remove" };
|
||
let friendly_content = if let Some(ref info) = worktree_info {
|
||
if is_worktree_create {
|
||
format!(
|
||
"Worktree created: {} (branch: {}) at {}",
|
||
info.name, info.branch, info.path
|
||
)
|
||
} else {
|
||
format!("Worktree removed: {} (branch: {})", info.name, info.branch)
|
||
}
|
||
} else {
|
||
line.clone()
|
||
};
|
||
|
||
let _ = app.emit(
|
||
"claude:worktree",
|
||
WorktreeEvent {
|
||
conversation_id: conversation_id.clone(),
|
||
event_type: event_type.to_string(),
|
||
worktree: worktree_info,
|
||
},
|
||
);
|
||
|
||
let _ = app.emit(
|
||
"claude:output",
|
||
OutputEvent {
|
||
line_type: "worktree".to_string(),
|
||
content: friendly_content,
|
||
tool_name: None,
|
||
conversation_id: conversation_id.clone(),
|
||
cost: None,
|
||
parent_tool_use_id: None,
|
||
},
|
||
);
|
||
} else if is_elicitation {
|
||
let data = parse_elicitation_hook(&line);
|
||
let friendly_content =
|
||
format!("MCP server requesting input: {}", data.message);
|
||
|
||
let _ = app.emit(
|
||
"claude:elicitation",
|
||
ElicitationEvent {
|
||
message: data.message,
|
||
server_name: data.server_name,
|
||
request_id: data.request_id,
|
||
conversation_id: conversation_id.clone(),
|
||
},
|
||
);
|
||
|
||
let _ = app.emit(
|
||
"claude:output",
|
||
OutputEvent {
|
||
line_type: "elicitation".to_string(),
|
||
content: friendly_content,
|
||
tool_name: None,
|
||
conversation_id: conversation_id.clone(),
|
||
cost: None,
|
||
parent_tool_use_id: None,
|
||
},
|
||
);
|
||
} else if is_elicitation_result {
|
||
let data = parse_elicitation_result_hook(&line);
|
||
let friendly_content =
|
||
format!("MCP elicitation completed: {}", data.action);
|
||
|
||
let _ = app.emit(
|
||
"claude:elicitation-result",
|
||
ElicitationResultEvent {
|
||
action: data.action,
|
||
request_id: data.request_id,
|
||
conversation_id: conversation_id.clone(),
|
||
},
|
||
);
|
||
|
||
let _ = app.emit(
|
||
"claude:output",
|
||
OutputEvent {
|
||
line_type: "elicitation".to_string(),
|
||
content: friendly_content,
|
||
tool_name: None,
|
||
conversation_id: conversation_id.clone(),
|
||
cost: None,
|
||
parent_tool_use_id: None,
|
||
},
|
||
);
|
||
} else if is_stop_failure {
|
||
let data = parse_stop_failure_hook(&line);
|
||
let friendly_content = build_stop_failure_message(&data);
|
||
|
||
let _ = app.emit(
|
||
"claude:stop-failure",
|
||
StopFailureEvent {
|
||
stop_reason: data.stop_reason,
|
||
error_type: data.error_type,
|
||
conversation_id: conversation_id.clone(),
|
||
},
|
||
);
|
||
|
||
let _ = app.emit(
|
||
"claude:output",
|
||
OutputEvent {
|
||
line_type: "error".to_string(),
|
||
content: friendly_content,
|
||
tool_name: None,
|
||
conversation_id: conversation_id.clone(),
|
||
cost: None,
|
||
parent_tool_use_id: None,
|
||
},
|
||
);
|
||
} else if is_post_compact {
|
||
let data = parse_post_compact_hook(&line);
|
||
|
||
let _ = app.emit(
|
||
"claude:post-compact",
|
||
PostCompactEvent {
|
||
session_id: data.session_id,
|
||
conversation_id: conversation_id.clone(),
|
||
},
|
||
);
|
||
|
||
let _ = app.emit(
|
||
"claude:output",
|
||
OutputEvent {
|
||
line_type: "compact-prompt".to_string(),
|
||
content: "Context compacted — conversation history has been summarised to free up space.".to_string(),
|
||
tool_name: None,
|
||
conversation_id: conversation_id.clone(),
|
||
cost: None,
|
||
parent_tool_use_id: None,
|
||
},
|
||
);
|
||
} else {
|
||
let _ = app.emit(
|
||
"claude:output",
|
||
OutputEvent {
|
||
line_type: line_type.to_string(),
|
||
content: line,
|
||
tool_name: None,
|
||
conversation_id: conversation_id.clone(),
|
||
cost: None,
|
||
parent_tool_use_id: None,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
Err(_) => break,
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct SubagentStartData {
|
||
agent_id: String,
|
||
agent_type: Option<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 }
|
||
}
|
||
|
||
/// 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-5-20250929".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, ¤t_stats).await {
|
||
tracing::error!("Failed to save stats: {}", e);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Only emit error results - success content is already sent via Assistant message
|
||
if subtype != "success" {
|
||
if let Some(text) = result {
|
||
let _ = app.emit(
|
||
"claude:output",
|
||
OutputEvent {
|
||
line_type: "error".to_string(),
|
||
content: text.clone(),
|
||
tool_name: None,
|
||
conversation_id: conversation_id.clone(),
|
||
cost: None,
|
||
parent_tool_use_id: None,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// Check for permission denials and emit prompts for each
|
||
if let Some(denials) = permission_denials {
|
||
// Only process if there are actually denials
|
||
if !denials.is_empty() {
|
||
let mut regular_permission_requests = Vec::new();
|
||
|
||
// Get denied tool IDs for later comparison
|
||
let denied_tool_ids: Vec<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 ®ular_permission_requests {
|
||
tracing::debug!(
|
||
"Permission requested: {} (id: {})",
|
||
req.tool_name,
|
||
req.id
|
||
);
|
||
}
|
||
let _ = app.emit(
|
||
"claude:permission",
|
||
PermissionPromptEvent {
|
||
permissions: regular_permission_requests,
|
||
conversation_id: conversation_id.clone(),
|
||
},
|
||
);
|
||
emit_state_change(
|
||
app,
|
||
CharacterState::Permission,
|
||
None,
|
||
conversation_id.clone(),
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
// Show permission state if there were any question denials
|
||
if !denials.is_empty() {
|
||
emit_state_change(
|
||
app,
|
||
CharacterState::Permission,
|
||
None,
|
||
conversation_id.clone(),
|
||
);
|
||
return Ok(());
|
||
}
|
||
} // end of else block for non-empty denials
|
||
} // end of if let Some(denials)
|
||
|
||
emit_state_change(app, state, None, conversation_id.clone());
|
||
}
|
||
|
||
ClaudeMessage::RateLimitEvent { rate_limit_info } => {
|
||
tracing::warn!("Rate limit event received: {:?}", rate_limit_info);
|
||
|
||
let content = format_rate_limit_message(rate_limit_info);
|
||
let _ = app.emit(
|
||
"claude:output",
|
||
OutputEvent {
|
||
line_type: "rate-limit".to_string(),
|
||
content,
|
||
tool_name: None,
|
||
conversation_id: conversation_id.clone(),
|
||
cost: None,
|
||
parent_tool_use_id: None,
|
||
},
|
||
);
|
||
}
|
||
|
||
ClaudeMessage::User { message } => {
|
||
// Increment message count for user messages
|
||
stats.write().increment_messages();
|
||
|
||
// Process content blocks for tool results (e.g., background Task agent completions)
|
||
for block in &message.content {
|
||
if let ContentBlock::ToolResult {
|
||
tool_use_id,
|
||
content,
|
||
is_error,
|
||
} = block
|
||
{
|
||
let now = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_millis() as u64;
|
||
|
||
let _ = app.emit(
|
||
"claude:agent-end",
|
||
AgentEndEvent {
|
||
tool_use_id: tool_use_id.clone(),
|
||
ended_at: now,
|
||
is_error: is_error.unwrap_or(false),
|
||
conversation_id: conversation_id.clone(),
|
||
duration_ms: None,
|
||
num_turns: None,
|
||
last_assistant_message: extract_tool_result_text(content),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
// Extract text content from the message
|
||
let message_text = message
|
||
.content
|
||
.iter()
|
||
.filter_map(|block| match block {
|
||
crate::types::ContentBlock::Text { text } => Some(text.clone()),
|
||
_ => None,
|
||
})
|
||
.collect::<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_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);
|
||
}
|
||
}
|