use crate::ai_agents::AiAgentPermissionMode; 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| command_path_from_shell(&shell, "claude")) } 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 command_path_from_shell(shell: &Path, command: &str) -> Option { crate::hidden_command(shell) .arg("-lc") .arg(format!("command -v {command}")) .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 { stdout.lines().find_map(|line| { 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_node_binary_candidates_for_home(home, "claude")); candidates } fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> 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(binary_name)) .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 args = build_chat_args(&req); run_claude_subprocess(&bin, &args, None, &mut emit) } /// Build CLI arguments for a chat stream request. fn build_chat_args(req: &ChatStreamRequest) -> Vec { let mut args: Vec = vec![ "-p".into(), req.message.clone(), "--output-format".into(), "stream-json".into(), "--verbose".into(), "--include-partial-messages".into(), "--tools".into(), String::new(), // empty string → disable all built-in tools ]; if let Some(ref sp) = req.system_prompt { if !sp.is_empty() { args.push("--system-prompt".into()); args.push(sp.clone()); } } if let Some(ref sid) = req.session_id { args.push("--resume".into()); args.push(sid.clone()); } args } /// 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 args = build_agent_args(&req)?; run_claude_subprocess(&bin, &args, Some(&req.vault_path), &mut emit) } /// Build CLI arguments for an agent stream request. /// Native tools (bash, read, write, edit) are enabled by default — no `--tools ""`. fn build_agent_args(req: &AgentStreamRequest) -> Result, String> { let mcp_config = build_mcp_config(&req.vault_path)?; let mut args: Vec = vec![ "-p".into(), req.message.clone(), "--output-format".into(), "stream-json".into(), "--verbose".into(), "--include-partial-messages".into(), "--mcp-config".into(), mcp_config, "--strict-mcp-config".into(), "--permission-mode".into(), "acceptEdits".into(), "--tools".into(), agent_tools(req.permission_mode).into(), "--no-session-persistence".into(), ]; if let Some(allowed_tools) = preapproved_agent_tools(req.permission_mode) { args.push("--allowedTools".into()); args.push(allowed_tools.into()); } if let Some(ref sp) = req.system_prompt { if !sp.is_empty() { args.push("--append-system-prompt".into()); args.push(sp.clone()); } } Ok(args) } fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str { match permission_mode { AiAgentPermissionMode::Safe => "Read,Edit,MultiEdit,Write,Glob,Grep,LS", AiAgentPermissionMode::PowerUser => "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash", } } fn preapproved_agent_tools(permission_mode: AiAgentPermissionMode) -> Option<&'static str> { match permission_mode { AiAgentPermissionMode::Safe => None, AiAgentPermissionMode::PowerUser => Some("Bash"), } } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /// Build a temporary MCP config JSON string pointing to the vault MCP server. fn build_mcp_config(vault_path: &str) -> Result { let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; let config = serde_json::json!({ "mcpServers": { "tolaria": { "command": "node", "args": [mcp_server_path], "env": { "VAULT_PATH": vault_path } } } }); serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}")) } /// 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, } /// Core subprocess runner shared by chat and agent modes. /// When `cwd` is `Some`, the subprocess starts with that working directory. fn run_claude_subprocess( bin: &PathBuf, args: &[String], cwd: Option<&str>, emit: &mut F, ) -> Result where F: FnMut(ClaudeStreamEvent), { let mut state = StreamState { session_id: String::new(), tool_inputs: HashMap::new(), current_tool_id: None, emitted_text: false, }; let cmd = build_claude_command(bin, args, cwd); let run = crate::cli_agent_runtime::run_json_line_process( cmd, "claude", 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() { emit(ClaudeStreamEvent::Error { message: format_failed_claude_exit(&run.stderr_output, run.status), }); } emit(ClaudeStreamEvent::Done); Ok(state.session_id) } fn build_claude_command( bin: &PathBuf, args: &[String], cwd: Option<&str>, ) -> std::process::Command { let mut cmd = crate::hidden_command(bin); crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, bin); cmd.args(args) .env_remove("CLAUDECODE") // prevent "nested session" guard .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(dir) = cwd { cmd.current_dir(dir); } cmd } fn format_failed_claude_exit(stderr_output: &str, status: ExitStatus) -> String { if is_claude_auth_error(stderr_output) { return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into(); } if stderr_output.is_empty() { format!("claude exited with status {status}") } else { stderr_output.lines().take(3).collect::>().join("\n") } } fn is_claude_auth_error(stderr_output: &str) -> bool { let lower = stderr_output.to_ascii_lowercase(); ["not logged in", "authentication", "auth"] .iter() .any(|pattern| lower.contains(pattern)) } /// 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(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(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, id); emit(ClaudeStreamEvent::ToolStart { tool_name: name.to_string(), tool_id: id.to_string(), input, }); } } _ => {} } } fn emit_text_delta(text: &str, state: &mut StreamState, emit: &mut F) where F: FnMut(ClaudeStreamEvent), { if !text.is_empty() { state.emitted_text = true; } emit(ClaudeStreamEvent::TextDelta { text: text.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: &str, ) -> Option { if let Some(accumulated) = state.tool_inputs.get(tool_id) { 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 std::ffi::OsStr; use std::ffi::OsString; use std::process::Command; macro_rules! chat_request { ($message:expr, None, None $(,)?) => { ChatStreamRequest { message: $message.into(), system_prompt: None, session_id: None, } }; ($message:expr, Some($system_prompt:expr), None $(,)?) => { ChatStreamRequest { message: $message.into(), system_prompt: Some($system_prompt.to_string()), session_id: None, } }; ($message:expr, None, Some($session_id:expr) $(,)?) => { ChatStreamRequest { message: $message.into(), system_prompt: None, session_id: Some($session_id.to_string()), } }; } macro_rules! agent_request { ($message:expr, None, $permission_mode:expr $(,)?) => { AgentStreamRequest { message: $message.into(), system_prompt: None, vault_path: "/tmp/vault".into(), permission_mode: $permission_mode, } }; ($message:expr, Some($system_prompt:expr), $permission_mode:expr $(,)?) => { AgentStreamRequest { message: $message.into(), system_prompt: Some($system_prompt.to_string()), vault_path: "/tmp/vault".into(), permission_mode: $permission_mode, } }; } macro_rules! assert_args_contain { ($args:expr, [$($value:expr),+ $(,)?] $(,)?) => { $( assert!($args.contains(&$value.to_string()), "missing {}", $value); )+ }; } macro_rules! assert_args_lack { ($args:expr, [$($value:expr),+ $(,)?] $(,)?) => { $( assert!(!$args.contains(&$value.to_string()), "unexpected {}", $value); )+ }; } macro_rules! assert_no_arg_contains { ($args:expr, $fragment:expr $(,)?) => { assert!(!$args.iter().any(|arg| arg.contains($fragment))); }; } fn arg_value_after<'a>(args: &'a [String], name: &str) -> Option<&'a str> { let index = args.iter().position(|arg| arg == name)?; args.get(index + 1).map(String::as_str) } 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 agent_args_use_safe_mode_without_bash_by_default() { let args = build_agent_args(&agent_request!( "Rename the note", None, AiAgentPermissionMode::Safe, )) .unwrap(); assert_args_contain!( args, ["--strict-mcp-config", "--permission-mode", "acceptEdits"] ); assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]); assert_no_arg_contains!(args, "Bash"); assert_args_lack!(args, ["--allowedTools"]); assert_args_lack!(args, ["--dangerously-skip-permissions"]); } #[test] fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() { let args = build_agent_args(&agent_request!( "Rename the note", None, AiAgentPermissionMode::PowerUser, )) .unwrap(); assert_args_contain!(args, ["--strict-mcp-config"]); assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]); assert_args_lack!(args, ["--dangerously-skip-permissions"]); } #[test] fn agent_args_preapprove_bash_for_power_user_runs() { let args = build_agent_args(&agent_request!( "Run a local script", None, AiAgentPermissionMode::PowerUser, )) .unwrap(); assert_eq!(arg_value_after(&args, "--allowedTools"), Some("Bash")); } #[test] fn build_mcp_config_is_valid_json() { if let Ok(config_str) = build_mcp_config("/tmp/test-vault") { let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap(); assert!(parsed["mcpServers"]["tolaria"]["command"].is_string()); assert_eq!( parsed["mcpServers"]["tolaria"]["env"]["VAULT_PATH"], "/tmp/test-vault" ); } } // --- 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) } /// Run dispatch_event with a pre-set session_id. fn run_dispatch_with_sid( json: serde_json::Value, initial_sid: &str, ) -> (String, Vec) { let mut state = new_state(); state.session_id = initial_sid.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" }), "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(&bin, &args, Some("/tmp/vault")); 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), ), ); } #[cfg(unix)] fn run_mock_script(script: &str) -> (Result, Vec) { run_mock_script_with_args(script, &[]) } #[cfg(unix)] fn run_mock_script_with_args( script: &str, args: &[String], ) -> (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).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); let mut events = vec![]; let result = run_claude_subprocess(&path, args, None, &mut |e| events.push(e)); (result, events) } #[cfg(unix)] #[test] fn run_subprocess_parses_ndjson_stream() { let (result, events) = run_mock_script(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)); } #[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(std::env::current_exe().unwrap()) .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 = std::env::current_exe().unwrap(); 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(&fake_bin, &args, 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(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("#!/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("#!/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("#!/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("#!/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(concat!( "#!/bin/sh\n", "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"$*\\\",\\\"session_id\\\":\\\"sx\\\"}\"\n", ), &args); let text = events.iter().find_map(|e| match e { ClaudeStreamEvent::Result { text, .. } => Some(text.as_str()), _ => None, }); assert!(text.unwrap().contains("--foo")); } // --- build_chat_args --- #[test] fn build_chat_args_basic() { let args = build_chat_args(&chat_request!("hello", None, None)); assert_args_contain!(args, ["-p", "hello", "stream-json"]); assert_args_lack!(args, ["--system-prompt", "--resume"]); } #[test] fn build_chat_args_with_system_prompt() { let args = build_chat_args(&chat_request!("hi", Some("You are helpful."), None)); assert_args_contain!(args, ["--system-prompt", "You are helpful."]); } #[test] fn build_chat_args_empty_system_prompt_is_skipped() { let args = build_chat_args(&chat_request!("hi", Some(""), None)); assert!(!args.contains(&"--system-prompt".to_string())); } #[test] fn build_chat_args_with_session_id() { let args = build_chat_args(&chat_request!("continue", None, Some("sess-abc"))); assert_args_contain!(args, ["--resume", "sess-abc"]); } // --- build_agent_args --- #[test] fn build_agent_args_basic() { // build_agent_args calls build_mcp_config which needs mcp_server_dir if let Ok(args) = build_agent_args(&agent_request!( "create note", None, AiAgentPermissionMode::Safe, )) { assert_args_contain!(args, ["-p", "create note", "--mcp-config"]); assert_args_contain!(args, ["--strict-mcp-config", "--permission-mode"]); assert_args_contain!(args, ["acceptEdits", "--tools"]); assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]); assert_args_contain!(args, ["--no-session-persistence"]); assert_args_lack!( args, [ "--dangerously-skip-permissions", "bypassPermissions", "--append-system-prompt", ], ); assert_no_arg_contains!(args, "Bash"); } } #[test] fn build_agent_args_with_system_prompt() { if let Ok(args) = build_agent_args(&agent_request!( "do it", Some("Act as expert."), AiAgentPermissionMode::Safe, )) { assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]); } } #[test] fn build_agent_args_empty_system_prompt_is_skipped() { if let Ok(args) = build_agent_args(&agent_request!("x", Some(""), AiAgentPermissionMode::Safe)) { assert!(!args.contains(&"--append-system-prompt".to_string())); } } // --- 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(), 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(&fake_bin, &[], 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(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(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 { .. }))); } }