pub use crate::cli_agent_runtime::AgentStreamRequest; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Stdio}; /// Status returned by `check_claude_cli`. #[derive(Debug, Serialize, Clone)] pub struct ClaudeCliStatus { pub installed: bool, pub version: Option, } /// Event emitted to the frontend during a streaming claude session. #[derive(Debug, Serialize, Clone)] #[serde(tag = "kind")] pub enum ClaudeStreamEvent { /// Session initialised — carries the session ID for future `--resume`. Init { session_id: String }, /// Incremental text chunk. TextDelta { text: String }, /// Incremental thinking/reasoning chunk. ThinkingDelta { text: String }, /// A tool call started (agent mode only). ToolStart { tool_name: String, tool_id: String, #[serde(skip_serializing_if = "Option::is_none")] input: Option, }, /// A tool call finished (agent mode only). ToolDone { tool_id: String, #[serde(skip_serializing_if = "Option::is_none")] output: Option, }, /// Final result text + session ID. Result { text: String, session_id: String }, /// Something went wrong. Error { message: String }, /// Stream finished. Done, } /// Parameters accepted by `stream_claude_chat`. #[derive(Debug, Deserialize)] pub struct ChatStreamRequest { pub message: String, pub system_prompt: Option, pub session_id: Option, } // --------------------------------------------------------------------------- // Finding the `claude` binary // --------------------------------------------------------------------------- pub(crate) fn find_claude_binary() -> Result { if let Some(binary) = find_claude_binary_on_path() { return Ok(binary); } if let Some(binary) = find_claude_binary_in_user_shell() { return Ok(binary); } if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( claude_binary_candidates(), "Claude CLI", )? { return Ok(binary); } Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into()) } fn find_claude_binary_on_path() -> Option { crate::hidden_command(claude_path_lookup_command()) .arg("claude") .output() .ok() .and_then(|output| path_from_successful_output(&output)) } fn claude_path_lookup_command() -> &'static str { if cfg!(windows) { "where" } else { "which" } } fn find_claude_binary_in_user_shell() -> Option { user_shell_candidates() .into_iter() .filter(|shell| shell.exists()) .find_map(|shell| claude_path_from_shell(&shell)) } fn user_shell_candidates() -> Vec { let mut shells = Vec::new(); if let Some(shell) = std::env::var_os("SHELL") { if !shell.is_empty() { shells.push(PathBuf::from(shell)); } } shells.push(PathBuf::from("/bin/zsh")); shells.push(PathBuf::from("/bin/bash")); shells } fn claude_path_from_shell(shell: &Path) -> Option { crate::hidden_command(shell) .arg("-lc") .arg("command -v claude") .output() .ok() .and_then(|output| path_from_successful_output(&output)) } fn path_from_successful_output(output: &std::process::Output) -> Option { if output.status.success() { first_existing_path(&String::from_utf8_lossy(&output.stdout)) } else { None } } fn first_existing_path(stdout: &str) -> Option { first_existing_path_for_platform(stdout, cfg!(windows)) } fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option { let mut paths = stdout.lines().filter_map(existing_path); if windows { return paths.find(|path| crate::cli_agent_runtime::has_windows_cli_extension(path)); } paths.next() } fn existing_path(line: &str) -> Option { let trimmed = line.trim(); if trimmed.is_empty() { return None; } let candidate = PathBuf::from(trimmed); candidate.exists().then_some(candidate) } fn claude_binary_candidates() -> Vec { dirs::home_dir() .map(|home| claude_binary_candidates_for_home(&home)) .unwrap_or_default() } fn claude_binary_candidates_for_home(home: &Path) -> Vec { let mut candidates = vec![ home.join(".local/bin/claude"), home.join(".local/bin/claude.exe"), home.join(".claude/local/claude"), home.join(".claude/local/claude.exe"), home.join(".local/share/mise/shims/claude"), home.join(".local/share/mise/shims/claude.exe"), home.join(".asdf/shims/claude"), home.join(".asdf/shims/claude.exe"), home.join(".npm-global/bin/claude"), home.join(".npm-global/bin/claude.cmd"), home.join(".npm-global/bin/claude.exe"), home.join(".npm/bin/claude"), home.join(".npm/bin/claude.cmd"), home.join(".npm/bin/claude.exe"), home.join(".linuxbrew/bin/claude"), home.join("AppData/Roaming/npm/claude.cmd"), home.join("AppData/Roaming/npm/claude.exe"), home.join("AppData/Local/pnpm/claude.cmd"), home.join("AppData/Local/pnpm/claude.exe"), home.join("scoop/shims/claude.exe"), PathBuf::from("/home/linuxbrew/.linuxbrew/bin/claude"), PathBuf::from("/opt/homebrew/bin/claude"), PathBuf::from("/usr/local/bin/claude"), ]; candidates.extend(nvm_claude_binary_candidates_for_home(home)); candidates } fn nvm_claude_binary_candidates_for_home(home: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else { return Vec::new(); }; let mut candidates = entries .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.is_dir()) .map(|path| path.join("bin").join("claude")) .collect::>(); candidates.sort(); candidates } // --------------------------------------------------------------------------- // Public Tauri commands // --------------------------------------------------------------------------- /// Check whether the `claude` CLI is installed and return its version. pub fn check_cli() -> ClaudeCliStatus { let bin = match find_claude_binary() { Ok(b) => b, Err(_) => { return ClaudeCliStatus { installed: false, version: None, } } }; ClaudeCliStatus { installed: true, version: crate::cli_agent_runtime::version_for_binary(&bin), } } /// Spawn `claude -p` for a simple chat (no tools) and stream events via the /// provided callback. Returns the session ID for future `--resume` calls. pub fn run_chat_stream(req: ChatStreamRequest, mut emit: F) -> Result where F: FnMut(ClaudeStreamEvent), { let bin = find_claude_binary()?; let invocation = crate::claude_invocation::chat(&req); run_claude_subprocess( ClaudeSubprocessRequest { bin: &bin, args: &invocation.args, fallback_args: &invocation.fallback_args, stdin_text: invocation.stdin_text.as_deref(), cwd: None, }, &mut emit, ) } /// Spawn `claude -p` with full tool access and MCP vault tools for an agent task. pub fn run_agent_stream(req: AgentStreamRequest, mut emit: F) -> Result where F: FnMut(ClaudeStreamEvent), { let bin = find_claude_binary()?; let invocation = crate::claude_invocation::agent(&req)?; run_claude_subprocess( ClaudeSubprocessRequest { bin: &bin, args: &invocation.args, fallback_args: &invocation.fallback_args, stdin_text: invocation.stdin_text.as_deref(), cwd: Some(&req.vault_path), }, &mut emit, ) } /// Mutable state accumulated across the JSON stream for a single subprocess. struct StreamState { session_id: String, /// Accumulates `input_json_delta` chunks keyed by tool_use id. tool_inputs: HashMap, /// The tool_use id of the block currently being streamed. current_tool_id: Option, /// Tracks whether response text has already been emitted for this run. emitted_text: bool, } struct ClaudeSubprocessRequest<'a> { bin: &'a Path, args: &'a [String], fallback_args: &'a [Vec], stdin_text: Option<&'a str>, cwd: Option<&'a str>, } struct ClaudeCommandRequest<'a> { bin: &'a Path, args: &'a [String], cwd: Option<&'a str>, } #[derive(Clone, Copy)] struct ClaudeStderr<'a>(&'a str); impl<'a> ClaudeStderr<'a> { fn is_empty(self) -> bool { self.0.is_empty() } fn lines(self) -> std::str::Lines<'a> { self.0.lines() } fn to_ascii_lowercase(self) -> String { self.0.to_ascii_lowercase() } } struct ClaudeFailure<'a> { stderr: ClaudeStderr<'a>, status: ExitStatus, } #[derive(Clone, Copy)] struct TextDeltaChunk<'a>(&'a str); impl<'a> TextDeltaChunk<'a> { fn is_empty(self) -> bool { self.0.is_empty() } fn as_str(self) -> &'a str { self.0 } } #[derive(Clone, Copy)] struct ToolUseId<'a>(&'a str); impl<'a> ToolUseId<'a> { fn as_str(self) -> &'a str { self.0 } } /// Core subprocess runner shared by chat and agent modes. /// When `cwd` is `Some`, the subprocess starts with that working directory. fn run_claude_subprocess( request: ClaudeSubprocessRequest<'_>, emit: &mut F, ) -> Result where F: FnMut(ClaudeStreamEvent), { let attempts = std::iter::once(request.args) .chain(request.fallback_args.iter().map(Vec::as_slice)) .enumerate(); for (index, attempt_args) in attempts { let mut state = StreamState { session_id: String::new(), tool_inputs: HashMap::new(), current_tool_id: None, emitted_text: false, }; let cmd = build_claude_command(ClaudeCommandRequest { bin: request.bin, args: attempt_args, cwd: request.cwd, })?; let process = crate::cli_agent_runtime::JsonLineProcess::new(cmd, "claude") .with_stdin(request.stdin_text); let run = crate::cli_agent_runtime::run_json_line_process_with_stdin( process, emit, |message| ClaudeStreamEvent::Error { message }, |json, emit, session_id| { dispatch_event(json, &mut state, emit); *session_id = state.session_id.clone(); }, )?; if !run.status.success() && state.session_id.is_empty() { let has_fallback = index < request.fallback_args.len(); let stderr = ClaudeStderr(&run.stderr_output); if has_fallback && is_unsupported_claude_flag_error(stderr) { continue; } emit(ClaudeStreamEvent::Error { message: format_failed_claude_exit(ClaudeFailure { stderr, status: run.status, }), }); } emit(ClaudeStreamEvent::Done); return Ok(state.session_id); } Ok(String::new()) } fn build_claude_command( request: ClaudeCommandRequest<'_>, ) -> Result { let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(request.bin)?; let mut cmd = crate::hidden_command(&target.program); crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, request.bin); if let Some(first_arg) = target.first_arg { cmd.arg(first_arg); } cmd.args(request.args) .env_remove("CLAUDECODE") // prevent "nested session" guard .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(dir) = request.cwd { cmd.current_dir(dir); } Ok(cmd) } fn format_failed_claude_exit(failure: ClaudeFailure<'_>) -> String { if is_claude_auth_error(failure.stderr) { return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into(); } if failure.stderr.is_empty() { format!("claude exited with status {}", failure.status) } else { failure .stderr .lines() .take(3) .collect::>() .join("\n") } } fn is_claude_auth_error(stderr: ClaudeStderr<'_>) -> bool { let lower = stderr.to_ascii_lowercase(); ["not logged in", "authentication", "auth"] .iter() .any(|pattern| lower.contains(pattern)) } fn is_unsupported_claude_flag_error(stderr: ClaudeStderr<'_>) -> bool { let lower = stderr.to_ascii_lowercase(); let mentions_compat_flag = ["--tools", "--no-session-persistence"] .iter() .any(|flag| lower.contains(flag)); let looks_unsupported = [ "unknown option", "unknown argument", "unrecognized option", "unexpected argument", "unknown command-line option", ] .iter() .any(|pattern| lower.contains(pattern)); mentions_compat_flag && looks_unsupported } /// Parse a single JSON line from the stream and emit the appropriate event. fn dispatch_event(json: &serde_json::Value, state: &mut StreamState, emit: &mut F) where F: FnMut(ClaudeStreamEvent), { let msg_type = json["type"].as_str().unwrap_or(""); match msg_type { // --- System init → capture session_id --- "system" if json["subtype"].as_str() == Some("init") => { if let Some(sid) = json["session_id"].as_str() { state.session_id = sid.to_string(); emit(ClaudeStreamEvent::Init { session_id: sid.to_string(), }); } } // --- Streaming partial events (text deltas, tool_use starts) --- "stream_event" => { dispatch_stream_event(json, state, emit); } // --- Tool progress (agent mode) --- "tool_progress" => { if let (Some(name), Some(id)) = (json["tool_name"].as_str(), json["tool_use_id"].as_str()) { emit(ClaudeStreamEvent::ToolStart { tool_name: name.to_string(), tool_id: id.to_string(), input: None, }); } } // --- Tool result (agent mode) --- "tool_result" => { if let Some(id) = json["tool_use_id"].as_str() { let output = extract_tool_result_text(json); emit(ClaudeStreamEvent::ToolDone { tool_id: id.to_string(), output, }); } } // --- Final result --- "result" => { let sid = json["session_id"].as_str().unwrap_or("").to_string(); if !sid.is_empty() { state.session_id = sid.clone(); } let text = if state.emitted_text { String::new() } else { let text = json["result"].as_str().unwrap_or("").to_string(); if !text.is_empty() { state.emitted_text = true; } text }; emit(ClaudeStreamEvent::Result { text, session_id: sid, }); } // --- Complete assistant message (fallback for text when no partials) --- "assistant" => { if let Some(content) = json["message"]["content"].as_array() { let emit_text = !state.emitted_text; for block in content { dispatch_assistant_content_block(block, emit_text, state, emit); } } } _ => {} // ignore other event types } } /// Handle a `stream_event` (partial assistant message). fn dispatch_stream_event(json: &serde_json::Value, state: &mut StreamState, emit: &mut F) where F: FnMut(ClaudeStreamEvent), { let event = &json["event"]; let event_type = event["type"].as_str().unwrap_or(""); match event_type { "content_block_delta" => { let delta = &event["delta"]; match delta["type"].as_str() { Some("text_delta") => { if let Some(text) = delta["text"].as_str() { emit_text_delta(TextDeltaChunk(text), state, emit); } } Some("thinking_delta") => { if let Some(text) = delta["thinking"].as_str() { emit(ClaudeStreamEvent::ThinkingDelta { text: text.to_string(), }); } } Some("input_json_delta") => { if let (Some(partial), Some(ref tid)) = (delta["partial_json"].as_str(), &state.current_tool_id) { state .tool_inputs .entry(tid.clone()) .or_default() .push_str(partial); } } _ => {} } } "content_block_start" => { let block = &event["content_block"]; if block["type"].as_str() == Some("tool_use") { if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) { state.current_tool_id = Some(id.to_string()); state.tool_inputs.entry(id.to_string()).or_default(); emit(ClaudeStreamEvent::ToolStart { tool_name: name.to_string(), tool_id: id.to_string(), input: None, }); } } } "content_block_stop" => { state.current_tool_id = None; } _ => {} } } fn dispatch_assistant_content_block( block: &serde_json::Value, emit_text: bool, state: &mut StreamState, emit: &mut F, ) where F: FnMut(ClaudeStreamEvent), { match block["type"].as_str() { Some("text") if emit_text => { if let Some(text) = block["text"].as_str() { emit_text_delta(TextDeltaChunk(text), state, emit); } } Some("tool_use") => { if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) { let input = format_tool_input(&block["input"], state, ToolUseId(id)); emit(ClaudeStreamEvent::ToolStart { tool_name: name.to_string(), tool_id: id.to_string(), input, }); } } _ => {} } } fn emit_text_delta(text: TextDeltaChunk<'_>, state: &mut StreamState, emit: &mut F) where F: FnMut(ClaudeStreamEvent), { if !text.is_empty() { state.emitted_text = true; } emit(ClaudeStreamEvent::TextDelta { text: text.as_str().to_string(), }); } /// Build the tool input string, preferring accumulated delta chunks over the /// block's `input` field (which may be empty at stream start). fn format_tool_input( block_input: &serde_json::Value, state: &StreamState, tool_id: ToolUseId<'_>, ) -> Option { if let Some(accumulated) = state.tool_inputs.get(tool_id.as_str()) { if !accumulated.is_empty() { return Some(accumulated.clone()); } } if !block_input.is_null() && block_input.as_object().is_some_and(|o| !o.is_empty()) { return Some(block_input.to_string()); } None } /// Extract displayable text from a `tool_result` event. fn extract_tool_result_text(json: &serde_json::Value) -> Option { // String content field if let Some(s) = json["content"].as_str() { return Some(s.to_string()); } // Array of content blocks (Claude format) if let Some(arr) = json["content"].as_array() { let texts: Vec<&str> = arr.iter().filter_map(|b| b["text"].as_str()).collect(); if !texts.is_empty() { return Some(texts.join("\n")); } } // Fallback: "output" field json["output"].as_str().map(|s| s.to_string()) } #[cfg(test)] mod tests { use super::*; use crate::ai_agents::AiAgentPermissionMode; use std::ffi::OsStr; use std::ffi::OsString; use std::process::Command; #[cfg(target_os = "linux")] fn current_test_binary() -> PathBuf { std::fs::read_link("/proc/self/exe").unwrap() } #[cfg(target_os = "macos")] fn current_test_binary() -> PathBuf { let pid = std::process::id().to_string(); let output = Command::new("/bin/ps") .args(["-p", pid.as_str(), "-o", "comm="]) .output() .unwrap(); let path = String::from_utf8(output.stdout).unwrap(); PathBuf::from(path.trim()) } fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) { let candidates = claude_binary_candidates_for_home(home); for candidate in expected { assert!( candidates.contains(candidate), "missing {}", candidate.display() ); } } #[test] fn check_cli_returns_status() { let status = check_cli(); if status.installed { assert!(status.version.is_some()); } else { assert!(status.version.is_none()); } } #[test] fn claude_binary_candidates_include_nvm_managed_node_installs() { let home = tempfile::tempdir().unwrap(); let claude = home.path().join(".nvm/versions/node/v22.12.0/bin/claude"); std::fs::create_dir_all(claude.parent().unwrap()).unwrap(); std::fs::write(&claude, "#!/bin/sh\n").unwrap(); let candidates = claude_binary_candidates_for_home(home.path()); assert!(candidates.contains(&claude), "missing {}", claude.display()); } #[test] fn windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script() { let dir = tempfile::tempdir().unwrap(); let shell_script = dir.path().join("claude"); let cmd_shim = dir.path().join("claude.cmd"); std::fs::write(&shell_script, "#!/bin/sh\n").unwrap(); std::fs::write(&cmd_shim, "@ECHO off\n").unwrap(); let stdout = format!("{}\n{}\n", shell_script.display(), cmd_shim.display()); assert_eq!( first_existing_path_for_platform(&stdout, true), Some(cmd_shim) ); } #[test] fn unsupported_claude_flag_errors_are_detected() { assert!(is_unsupported_claude_flag_error(ClaudeStderr( "error: unknown option '--tools'" ))); assert!(is_unsupported_claude_flag_error(ClaudeStderr( "Unknown argument: --no-session-persistence" ))); assert!(!is_unsupported_claude_flag_error(ClaudeStderr( "Claude CLI is not authenticated" ))); } // --- dispatch_event / dispatch_stream_event --- fn new_state() -> StreamState { StreamState { session_id: String::new(), tool_inputs: HashMap::new(), current_tool_id: None, emitted_text: false, } } /// Run dispatch_event on the given JSON and return (session_id, events). fn run_dispatch(json: serde_json::Value) -> (String, Vec) { let mut state = new_state(); let mut events = vec![]; dispatch_event(&json, &mut state, &mut |e| events.push(e)); (state.session_id, events) } #[derive(Clone, Copy)] struct InitialSessionId<'a>(&'a str); /// Run dispatch_event with a pre-set session_id. fn run_dispatch_with_sid( json: serde_json::Value, initial_sid: InitialSessionId<'_>, ) -> (String, Vec) { let mut state = new_state(); state.session_id = initial_sid.0.to_string(); let mut events = vec![]; dispatch_event(&json, &mut state, &mut |e| events.push(e)); (state.session_id, events) } /// Run multiple dispatch_event calls sharing state (for multi-event sequences). fn run_dispatch_sequence( events_json: Vec, ) -> (StreamState, Vec) { let mut state = new_state(); let mut events = vec![]; for json in &events_json { dispatch_event(json, &mut state, &mut |e| events.push(e)); } (state, events) } #[test] fn dispatch_event_handles_init() { let (sid, events) = run_dispatch(serde_json::json!({ "type": "system", "subtype": "init", "session_id": "test-session-123" })); assert_eq!(sid, "test-session-123"); assert!( matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "test-session-123") ); } #[test] fn dispatch_event_system_without_init_subtype_is_ignored() { let (_, events) = run_dispatch(serde_json::json!({ "type": "system", "subtype": "other" })); assert!(events.is_empty()); } #[test] fn dispatch_event_system_init_without_session_id_is_ignored() { let (sid, events) = run_dispatch(serde_json::json!({ "type": "system", "subtype": "init" })); assert!(events.is_empty()); assert!(sid.is_empty()); } #[test] fn dispatch_event_handles_text_delta() { let (_, events) = run_dispatch(serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } } })); assert!(matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Hello")); } #[test] fn dispatch_event_handles_tool_start() { let (_, events) = run_dispatch(serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } } })); assert!( matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "read_note" && tool_id == "tool_abc") ); } #[test] fn dispatch_event_handles_result() { let (sid, events) = run_dispatch(serde_json::json!({ "type": "result", "subtype": "success", "result": "All done!", "session_id": "sess-456" })); assert_eq!(sid, "sess-456"); assert!( matches!(&events[0], ClaudeStreamEvent::Result { text, session_id } if text == "All done!" && session_id == "sess-456") ); } #[test] fn dispatch_event_result_with_empty_session_id() { let (sid, events) = run_dispatch_with_sid( serde_json::json!({ "type": "result", "result": "text here" }), InitialSessionId("prev-session"), ); assert_eq!(sid, "prev-session"); assert!( matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "text here") ); } #[test] fn dispatch_event_handles_tool_progress() { let (_, events) = run_dispatch(serde_json::json!({ "type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz" })); assert!( matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tool_xyz") ); } #[test] fn dispatch_event_tool_progress_missing_fields_is_ignored() { let (_, events) = run_dispatch(serde_json::json!({ "type": "tool_progress", "tool_name": "x" })); assert!(events.is_empty()); } #[test] fn dispatch_event_handles_assistant_with_tool_use() { let (_, events) = run_dispatch(serde_json::json!({ "type": "assistant", "message": { "content": [ { "type": "text", "text": "Let me search." }, { "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} } ] } })); assert_eq!(events.len(), 2); assert!( matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Let me search.") ); assert!( matches!(&events[1], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1") ); } #[test] fn dispatch_event_result_after_text_delta_does_not_duplicate_response_text() { let (state, events) = run_dispatch_sequence(vec![ serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "Visible reply" } } }), serde_json::json!({ "type": "result", "session_id": "session-1", "result": "Visible reply" }), ]); assert_eq!(state.session_id, "session-1"); assert!(matches!(&events[..], [ ClaudeStreamEvent::TextDelta { text }, ClaudeStreamEvent::Result { text: result_text, session_id }, ] if text == "Visible reply" && result_text.is_empty() && session_id == "session-1")); } #[test] fn dispatch_event_assistant_without_content_is_noop() { let (_, events) = run_dispatch(serde_json::json!({ "type": "assistant", "message": {} })); assert!(events.is_empty()); } #[test] fn dispatch_event_ignores_unknown() { let (_, events) = run_dispatch(serde_json::json!({ "type": "some_future_type", "data": 42 })); assert!(events.is_empty()); } #[test] fn dispatch_stream_event_input_json_delta_accumulates_silently() { // input_json_delta doesn't emit events directly — it accumulates in state let (_, events) = run_dispatch(serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } } })); assert!(events.is_empty()); } #[test] fn dispatch_stream_event_non_tool_block_start_is_ignored() { let (_, events) = run_dispatch(serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } } })); assert!(events.is_empty()); } #[test] fn dispatch_stream_event_unknown_type_is_ignored() { let (_, events) = run_dispatch(serde_json::json!({ "type": "stream_event", "event": { "type": "message_stop" } })); assert!(events.is_empty()); } #[test] fn dispatch_event_handles_tool_result_string_content() { let (_, events) = run_dispatch(serde_json::json!({ "type": "tool_result", "tool_use_id": "tool_abc", "content": "Found 3 notes matching query" })); assert_eq!(events.len(), 1); assert!( matches!(&events[0], ClaudeStreamEvent::ToolDone { tool_id, output } if tool_id == "tool_abc" && output.as_deref() == Some("Found 3 notes matching query")) ); } #[test] fn dispatch_event_handles_tool_result_array_content() { let (_, events) = run_dispatch(serde_json::json!({ "type": "tool_result", "tool_use_id": "tool_def", "content": [{ "type": "text", "text": "Line 1" }, { "type": "text", "text": "Line 2" }] })); assert!( matches!(&events[0], ClaudeStreamEvent::ToolDone { output, .. } if output.as_deref() == Some("Line 1\nLine 2")) ); } #[test] fn dispatch_event_tool_result_missing_tool_id_is_ignored() { let (_, events) = run_dispatch(serde_json::json!({ "type": "tool_result", "content": "result text" })); assert!(events.is_empty()); } #[test] fn dispatch_accumulates_input_json_deltas() { let (_, events) = run_dispatch_sequence(vec![ // Start tool_use block serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "search_notes", "input": {} } } }), // Input delta chunks serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "{\"query\":" } } }), serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "\"test\"}" } } }), // Stop block serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_stop" } }), // Assistant message triggers ToolStart with accumulated input serde_json::json!({ "type": "assistant", "message": { "content": [ { "type": "tool_use", "id": "t1", "name": "search_notes", "input": { "query": "test" } } ] } }), ]); // First event: ToolStart with no input (from content_block_start) assert!(matches!( &events[0], ClaudeStreamEvent::ToolStart { input: None, .. } )); // Second event: ToolStart with accumulated input (from assistant) assert!( matches!(&events[1], ClaudeStreamEvent::ToolStart { input: Some(inp), .. } if inp == "{\"query\":\"test\"}") ); } #[test] fn dispatch_assistant_uses_block_input_when_no_deltas() { let (_, events) = run_dispatch(serde_json::json!({ "type": "assistant", "message": { "content": [ { "type": "tool_use", "id": "tu_x", "name": "create_note", "input": { "title": "Hello", "content": "world" } } ] } })); assert!( matches!(&events[0], ClaudeStreamEvent::ToolStart { input: Some(inp), .. } if inp.contains("title") && inp.contains("Hello")) ); } #[test] fn content_block_stop_clears_current_tool() { let (state, _) = run_dispatch_sequence(vec![ serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "x", "input": {} } } }), serde_json::json!({ "type": "stream_event", "event": { "type": "content_block_stop" } }), ]); assert!(state.current_tool_id.is_none()); } // --- run_claude_subprocess with mock scripts --- #[test] fn build_claude_command_keeps_streaming_process_contract() { let bin = PathBuf::from("claude"); let args = vec!["-p".to_string(), "hello".to_string()]; let command = build_claude_command(ClaudeCommandRequest { bin: &bin, args: &args, cwd: Some("/tmp/vault"), }) .unwrap(); let actual_args: Vec = command.get_args().map(OsStr::to_os_string).collect(); let claude_code_env = command .get_envs() .find(|(key, _)| *key == OsStr::new("CLAUDECODE")) .map(|(_, value)| value.map(OsStr::to_os_string)); assert_eq!( ( command.get_program().to_os_string(), actual_args, command.get_current_dir().map(Path::to_path_buf), claude_code_env, ), ( OsString::from("claude"), vec![OsString::from("-p"), OsString::from("hello")], Some(PathBuf::from("/tmp/vault")), Some(None), ), ); } #[test] fn build_claude_command_avoids_windows_cmd_shim_for_prompt_args() { let dir = tempfile::tempdir().unwrap(); let shim = dir.path().join("claude.cmd"); let script = dir .path() .join("node_modules") .join("@anthropic-ai") .join("claude-code") .join("cli.js"); std::fs::create_dir_all(script.parent().unwrap()).unwrap(); std::fs::write(&script, "console.log('claude')\n").unwrap(); std::fs::write( &shim, r#"@ECHO off "%_prog%" "%~dp0\node_modules\@anthropic-ai\claude-code\cli.js" %* "#, ) .unwrap(); let args = vec![ "-p".to_string(), "Rename the note after reading the active vault".to_string(), ]; let command = build_claude_command(ClaudeCommandRequest { bin: &shim, args: &args, cwd: Some("/tmp/vault"), }) .unwrap(); let actual_args = command.get_args().collect::>(); assert_ne!( command.get_program(), shim.as_os_str(), "Claude npm .cmd shims cannot safely receive prompt args directly" ); assert_eq!(actual_args.first().copied(), Some(script.as_os_str())); assert!(actual_args.iter().any(|arg| *arg == OsStr::new("-p"))); assert!(actual_args .iter() .any(|arg| *arg == OsStr::new("Rename the note after reading the active vault"))); assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); } #[cfg(unix)] #[derive(Clone, Copy)] struct MockClaudeScript<'a>(&'a str); #[cfg(unix)] #[derive(Clone, Copy)] struct MockClaudeArgs<'a>(&'a [String]); #[cfg(unix)] #[derive(Clone, Copy)] struct MockClaudeStdin<'a>(Option<&'a str>); #[cfg(unix)] fn run_mock_script( script: MockClaudeScript<'_>, ) -> (Result, Vec) { run_mock_script_with_args(script, MockClaudeArgs(&[])) } #[cfg(unix)] fn run_mock_script_with_args( script: MockClaudeScript<'_>, args: MockClaudeArgs<'_>, ) -> (Result, Vec) { run_mock_script_with_args_and_stdin(script, args, MockClaudeStdin(None)) } #[cfg(unix)] fn run_mock_script_with_args_and_stdin( script: MockClaudeScript<'_>, args: MockClaudeArgs<'_>, stdin_text: MockClaudeStdin<'_>, ) -> (Result, Vec) { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mock-claude"); std::fs::write(&path, script.0).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); let mut events = vec![]; let result = run_claude_subprocess( ClaudeSubprocessRequest { bin: &path, args: args.0, fallback_args: &[], stdin_text: stdin_text.0, cwd: None, }, &mut |e| events.push(e), ); (result, events) } #[cfg(unix)] #[test] fn run_subprocess_parses_ndjson_stream() { let (result, events) = run_mock_script(MockClaudeScript(concat!( "#!/bin/sh\n", "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}'\n", "echo '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}}'\n", "echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"s1\"}'\n", ))); assert_eq!(result.unwrap(), "s1"); assert!(matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "s1")); assert!(matches!(&events[1], ClaudeStreamEvent::TextDelta { text } if text == "Hi")); assert!(matches!(&events[2], ClaudeStreamEvent::Result { .. })); assert!(matches!(&events[3], ClaudeStreamEvent::Done)); } #[cfg(unix)] #[test] fn run_subprocess_retries_with_fallback_args_for_removed_flags() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mock-claude"); std::fs::write( &path, concat!( "#!/bin/sh\n", "for arg in \"$@\"; do\n", " if [ \"$arg\" = \"--tools\" ]; then\n", " echo \"error: unknown option '--tools'\" >&2\n", " exit 1\n", " fi\n", "done\n", "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"fallback\"}'\n", "echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"fallback\"}'\n", ), ) .unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); let args = vec!["--tools".to_string(), "Read".to_string()]; let fallback_args = vec![vec!["--allowedTools".to_string(), "Read".to_string()]]; let mut events = vec![]; let result = run_claude_subprocess( ClaudeSubprocessRequest { bin: &path, args: &args, fallback_args: &fallback_args, stdin_text: None, cwd: None, }, &mut |event| events.push(event), ); assert_eq!(result.unwrap(), "fallback"); assert!(matches!( events.first(), Some(ClaudeStreamEvent::Init { session_id }) if session_id == "fallback" )); assert!(!events .iter() .any(|event| matches!(event, ClaudeStreamEvent::Error { .. }))); assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done))); } #[test] fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() { use std::io::Read; use std::time::{Duration, Instant}; let mut child = Command::new(current_test_binary()) .arg("stdin_probe_parent_child") .arg("--ignored") .arg("--nocapture") .env("TOLARIA_STDIN_PROBE_CHILD", "1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let child_stdin = child.stdin.take().unwrap(); let mut stdout = child.stdout.take().unwrap(); let mut stderr = child.stderr.take().unwrap(); let deadline = Instant::now() + Duration::from_secs(5); let status = loop { if let Some(status) = child.try_wait().unwrap() { break status; } if Instant::now() >= deadline { child.kill().unwrap(); drop(child_stdin); panic!("stdin probe child timed out"); } std::thread::sleep(Duration::from_millis(10)); }; drop(child_stdin); let mut stdout_text = String::new(); let mut stderr_text = String::new(); stdout.read_to_string(&mut stdout_text).unwrap(); stderr.read_to_string(&mut stderr_text).unwrap(); assert!( status.success(), "stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}" ); } #[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"] #[test] fn stdin_probe_parent_child() { if std::env::var_os("TOLARIA_STDIN_PROBE_CHILD").is_none() { return; } let fake_bin = current_test_binary(); let args = vec![ "stdin_probe_mock_claude_child".to_string(), "--ignored".to_string(), "--nocapture".to_string(), ]; std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1"); let mut events = vec![]; let result = run_claude_subprocess( ClaudeSubprocessRequest { bin: &fake_bin, args: &args, fallback_args: &[], stdin_text: None, cwd: None, }, &mut |event| events.push(event), ); std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD"); assert_eq!(result.unwrap(), "stdin-ok"); assert!(matches!( events.first(), Some(ClaudeStreamEvent::Result { text, session_id }) if text == "stdin closed" && session_id == "stdin-ok" )); assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done))); } #[ignore = "spawned by stdin_probe_parent_child"] #[test] fn stdin_probe_mock_claude_child() { if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() { return; } use std::io::Read; let mut stdin = String::new(); std::io::stdin().read_to_string(&mut stdin).unwrap(); assert!(stdin.is_empty(), "stdin was not EOF"); println!( "{}", serde_json::json!({ "type": "result", "result": "stdin closed", "session_id": "stdin-ok" }) ); } #[cfg(unix)] #[test] fn run_subprocess_skips_blank_and_non_json_lines() { let (result, events) = run_mock_script(MockClaudeScript(concat!( "#!/bin/sh\n", "echo ''\n", "echo 'not json at all'\n", "echo '{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"s2\"}'\n", ))); assert_eq!(result.unwrap(), "s2"); assert!(matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "ok")); assert!(matches!(&events[1], ClaudeStreamEvent::Done)); } #[cfg(unix)] #[test] fn run_subprocess_emits_error_on_nonzero_exit() { let (_, events) = run_mock_script(MockClaudeScript( "#!/bin/sh\necho 'auth problem' >&2\nexit 1\n", )); assert!(events .iter() .any(|e| matches!(e, ClaudeStreamEvent::Error { .. }))); assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done)); } #[cfg(unix)] #[test] fn run_subprocess_detects_auth_error_in_stderr() { let (_, events) = run_mock_script(MockClaudeScript( "#!/bin/sh\necho 'not logged in' >&2\nexit 1\n", )); assert!(events.iter().any(|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("not authenticated")))); } #[cfg(unix)] #[test] fn run_subprocess_reports_exit_code_on_empty_stderr() { let (_, events) = run_mock_script(MockClaudeScript("#!/bin/sh\nexit 2\n")); assert!(events.iter().any( |e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("exited with")) )); } #[cfg(unix)] #[test] fn run_subprocess_success_with_no_events() { let (result, events) = run_mock_script(MockClaudeScript("#!/bin/sh\nexit 0\n")); assert!(result.is_ok()); assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done)); } #[cfg(unix)] #[test] fn run_subprocess_passes_args_through() { let args: Vec = vec!["--foo".into(), "bar".into()]; let (_, events) = run_mock_script_with_args( MockClaudeScript(concat!( "#!/bin/sh\n", "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"$*\\\",\\\"session_id\\\":\\\"sx\\\"}\"\n", )), MockClaudeArgs(&args), ); let text = events.iter().find_map(|e| match e { ClaudeStreamEvent::Result { text, .. } => Some(text.as_str()), _ => None, }); assert!(text.unwrap().contains("--foo")); } #[cfg(unix)] #[test] fn run_subprocess_writes_prompt_to_stdin() { let args: Vec = vec!["-p".into()]; let (result, events) = run_mock_script_with_args_and_stdin( MockClaudeScript(concat!( "#!/bin/sh\n", "stdin=$(cat)\n", "if [ \"$stdin\" != \"hello from stdin\" ]; then\n", " echo \"unexpected stdin: $stdin\" >&2\n", " exit 3\n", "fi\n", "echo '{\"type\":\"result\",\"result\":\"stdin ok\",\"session_id\":\"sx\"}'\n", )), MockClaudeArgs(&args), MockClaudeStdin(Some("hello from stdin")), ); assert_eq!(result.unwrap(), "sx"); assert!(events.iter().any( |event| matches!(event, ClaudeStreamEvent::Result { text, .. } if text == "stdin ok") )); } // --- find_claude_binary --- #[test] fn claude_binary_candidates_include_supported_local_and_toolchain_installs() { let home = PathBuf::from("/Users/alex"); let expected = [ home.join(".local/bin/claude"), home.join(".claude/local/claude"), home.join(".local/share/mise/shims/claude"), home.join(".npm-global/bin/claude"), ]; assert_binary_candidates_include(&home, &expected); } #[test] fn claude_binary_candidates_include_linuxbrew_installs() { let home = PathBuf::from("/home/alex"); let expected = [ home.join(".linuxbrew/bin/claude"), PathBuf::from("/home/linuxbrew/.linuxbrew/bin/claude"), ]; assert_binary_candidates_include(&home, &expected); } #[test] fn claude_binary_candidates_include_windows_exe_installs() { let home = PathBuf::from(r"C:\Users\alex"); let expected = [ home.join(".local/bin/claude.exe"), home.join(".claude/local/claude.exe"), home.join("AppData/Roaming/npm/claude.cmd"), ]; assert_binary_candidates_include(&home, &expected); } #[test] fn claude_path_lookup_command_matches_current_platform() { let expected = if cfg!(windows) { "where" } else { "which" }; assert_eq!(claude_path_lookup_command(), expected); } #[test] fn find_existing_binary_finds_windows_exe_candidate() { let dir = tempfile::tempdir().unwrap(); let claude = dir.path().join(".local/bin/claude.exe"); std::fs::create_dir_all(claude.parent().unwrap()).unwrap(); std::fs::write(&claude, "").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); } assert_eq!( crate::cli_agent_runtime::find_executable_binary_candidate( claude_binary_candidates_for_home(dir.path()), "Claude CLI", ) .unwrap(), Some(claude) ); } #[test] fn find_claude_binary_returns_result() { let result = find_claude_binary(); // On dev machines claude may be installed; on CI it may not. // Either way, the function should return Ok(path) or Err(message). match &result { Ok(path) => assert!(path.exists()), Err(msg) => assert!(msg.contains("not found")), } } // --- run_chat_stream / run_agent_stream error paths --- #[test] fn run_chat_stream_returns_result() { let req = ChatStreamRequest { message: "test".into(), system_prompt: None, session_id: None, }; let mut events = vec![]; // This will either succeed (if claude is installed) or fail (if not). let result = run_chat_stream(req, |e| events.push(e)); // Either way the function should have returned without panicking. assert!(result.is_ok() || result.is_err()); } #[test] fn run_agent_stream_returns_result() { let req = AgentStreamRequest { message: "test".into(), system_prompt: Some("sys".into()), vault_path: "/tmp/nonexistent".into(), vault_paths: Vec::new(), permission_mode: AiAgentPermissionMode::Safe, }; let mut events = vec![]; let result = run_agent_stream(req, |e| events.push(e)); assert!(result.is_ok() || result.is_err()); } #[test] fn run_subprocess_spawn_failure() { let fake_bin = PathBuf::from("/nonexistent/binary/path"); let mut events = vec![]; let result = run_claude_subprocess( ClaudeSubprocessRequest { bin: &fake_bin, args: &[], fallback_args: &[], stdin_text: None, cwd: None, }, &mut |e| events.push(e), ); assert!(result.is_err()); assert!(result.unwrap_err().contains("Failed to start claude")); } #[cfg(unix)] #[test] fn run_subprocess_with_tool_progress_and_assistant() { let (result, events) = run_mock_script(MockClaudeScript(concat!( "#!/bin/sh\n", "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s3\"}'\n", "echo '{\"type\":\"tool_progress\",\"tool_name\":\"search\",\"tool_use_id\":\"t1\"}'\n", "echo '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"read\",\"input\":{}}]}}'\n", "echo '{\"type\":\"result\",\"result\":\"fin\",\"session_id\":\"s3\"}'\n", ))); assert_eq!(result.unwrap(), "s3"); assert!(events.len() >= 4); } #[cfg(unix)] #[test] fn run_subprocess_success_exit_with_session_id_skips_error() { let (_, events) = run_mock_script(MockClaudeScript(concat!( "#!/bin/sh\n", "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s4\"}'\n", "echo 'some warning' >&2\n", "exit 1\n", ))); // Should NOT have an error event because session_id is non-empty assert!(!events .iter() .any(|e| matches!(e, ClaudeStreamEvent::Error { .. }))); } }