use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent}; use serde::Deserialize; use std::ffi::OsString; use std::io::BufRead; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; #[derive(Debug, Clone, Deserialize)] pub struct AgentStreamRequest { pub message: String, pub system_prompt: Option, pub vault_path: String, pub permission_mode: AiAgentPermissionMode, } pub(crate) struct JsonLineRun { pub session_id: String, pub stderr_output: String, pub status: ExitStatus, } pub(crate) fn build_prompt(message: &str, system_prompt: Option<&str>) -> String { match system_prompt .map(str::trim) .filter(|prompt| !prompt.is_empty()) { Some(system_prompt) => { format!("System instructions:\n{system_prompt}\n\nUser request:\n{message}") } None => message.to_string(), } } pub(crate) fn mcp_server_path_string() -> Result { Ok(crate::mcp::mcp_server_dir()? .join("index.js") .to_str() .ok_or("Invalid MCP server path")? .to_string()) } pub(crate) fn version_for_binary(binary: &PathBuf) -> Option { let mut command = crate::hidden_command(binary); configure_agent_command_environment(&mut command, binary); command .arg("--version") .output() .ok() .filter(|output| output.status.success()) .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) } pub(crate) fn configure_agent_command_environment(command: &mut Command, binary: &Path) { if let Some(path) = expanded_agent_path(binary) { command.env("PATH", path); } } fn expanded_agent_path(binary: &Path) -> Option { let mut paths = std::env::var_os("PATH") .map(|path| std::env::split_paths(&path).collect::>()) .unwrap_or_default(); for candidate in agent_path_candidates(binary) { push_unique_path(&mut paths, candidate); } std::env::join_paths(paths).ok() } fn agent_path_candidates(binary: &Path) -> Vec { let mut candidates = Vec::new(); if let Some(parent) = binary .parent() .filter(|parent| !parent.as_os_str().is_empty()) { candidates.push(parent.to_path_buf()); } if let Some(home) = dirs::home_dir() { candidates.extend([ home.join(".local/bin"), home.join(".local/share/mise/shims"), home.join(".asdf/shims"), home.join(".npm-global/bin"), home.join(".npm/bin"), home.join(".bun/bin"), home.join(".linuxbrew/bin"), home.join("AppData/Roaming/npm"), home.join("AppData/Local/pnpm"), home.join("scoop/shims"), ]); } candidates.extend([ PathBuf::from("/opt/homebrew/bin"), PathBuf::from("/usr/local/bin"), PathBuf::from("/home/linuxbrew/.linuxbrew/bin"), ]); candidates } fn push_unique_path(paths: &mut Vec, candidate: PathBuf) { if candidate.as_os_str().is_empty() { return; } if paths.iter().any(|path| path == &candidate) { return; } paths.push(candidate); } pub(crate) fn find_executable_binary_candidate( candidates: Vec, agent_label: &str, ) -> Result, String> { let mut first_unusable_candidate = None; for candidate in candidates { if !candidate.exists() { continue; } if is_executable_file(&candidate) { return Ok(Some(candidate)); } if first_unusable_candidate.is_none() { first_unusable_candidate = Some(candidate); } } match first_unusable_candidate { Some(candidate) => Err(format!( "{agent_label} binary found at {} but it is not executable. Fix the file permissions or reinstall the CLI.", candidate.display() )), None => Ok(None), } } fn is_executable_file(path: &Path) -> bool { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::metadata(path) .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) .unwrap_or(false) } #[cfg(not(unix))] { path.is_file() } } pub(crate) fn parse_json_line( line: Result, ) -> Result, String> { let line = line.map_err(|error| format!("Read error: {error}"))?; let trimmed = line.trim(); if trimmed.is_empty() { return Ok(None); } Ok(serde_json::from_str::(trimmed).ok()) } #[cfg(test)] pub(crate) fn parse_ai_agent_json_line( line: Result, emit: &mut F, ) -> Option where F: FnMut(AiAgentStreamEvent), { match parse_json_line(line) { Ok(json) => json, Err(message) => { emit(AiAgentStreamEvent::Error { message }); None } } } pub(crate) fn run_json_line_process( mut command: Command, process_name: &'static str, emit: &mut F, error_event: impl Fn(String) -> Event, mut handle_json: H, ) -> Result where F: FnMut(Event), H: FnMut(&serde_json::Value, &mut F, &mut String), { let mut child = command .spawn() .map_err(|error| format_spawn_error(process_name, &error))?; let stdout = child.stdout.take().ok_or("No stdout handle")?; let reader = std::io::BufReader::new(stdout); let mut session_id = String::new(); for line in reader.lines() { match parse_json_line(line) { Ok(Some(json)) => handle_json(&json, emit, &mut session_id), Ok(None) => {} Err(message) => { emit(error_event(message)); break; } } } let stderr_output = child .stderr .take() .and_then(|stderr| std::io::read_to_string(stderr).ok()) .unwrap_or_default(); let status = child .wait() .map_err(|error| format!("Wait failed: {error}"))?; Ok(JsonLineRun { session_id, stderr_output, status, }) } fn format_spawn_error(process_name: &str, error: &std::io::Error) -> String { if error.kind() == std::io::ErrorKind::NotFound { return format!( "Failed to start {process_name}: the CLI or one of its runtime dependencies was not found. If it was installed with Homebrew, make sure /opt/homebrew/bin or /usr/local/bin contains the CLI and Node.js, then restart Tolaria. Details: {error}" ); } format!("Failed to spawn {process_name}: {error}") } pub(crate) fn run_ai_agent_json_stream( command: Command, process_name: &'static str, mut emit: F, session_id: impl Fn(&serde_json::Value) -> Option<&str>, dispatch_event: impl Fn(&serde_json::Value, &mut F), format_error: impl Fn(String, String) -> String, ) -> Result where F: FnMut(AiAgentStreamEvent), { let run = run_json_line_process( command, process_name, &mut emit, |message| AiAgentStreamEvent::Error { message }, |json, emit, active_session_id| { if let Some(id) = session_id(json) { *active_session_id = id.to_string(); } dispatch_event(json, emit); }, )?; if !run.status.success() { emit(AiAgentStreamEvent::Error { message: format_error(run.stderr_output, run.status.to_string()), }); } emit(AiAgentStreamEvent::Done); Ok(run.session_id) } #[cfg(test)] mod tests { use super::*; #[test] fn build_prompt_keeps_system_prompt_first() { let prompt = build_prompt("Rename the note", Some("Be concise")); assert!(prompt.starts_with("System instructions:\nBe concise")); assert!(prompt.contains("User request:\nRename the note")); } #[test] fn build_prompt_skips_blank_system_prompt() { assert_eq!( build_prompt("Rename the note", Some(" ")), "Rename the note" ); } #[test] fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines() { assert!(parse_json_line(Ok(" ".into())).unwrap().is_none()); assert!(parse_json_line(Ok("not json".into())).unwrap().is_none()); let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err(); assert!(error.contains("broken pipe")); } #[test] fn agent_command_environment_keeps_homebrew_shims_available() { let mut command = Command::new("/opt/homebrew/bin/codex"); configure_agent_command_environment(&mut command, Path::new("/opt/homebrew/bin/codex")); let path = command .get_envs() .find(|(key, _)| *key == std::ffi::OsStr::new("PATH")) .and_then(|(_, value)| value) .expect("PATH should be set"); let paths = std::env::split_paths(path).collect::>(); assert!(paths.contains(&PathBuf::from("/opt/homebrew/bin"))); assert!(paths.contains(&PathBuf::from("/usr/local/bin"))); } #[test] fn spawn_not_found_errors_explain_gui_path_runtime_dependencies() { let error = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory"); let message = format_spawn_error("codex", &error); assert!(message.contains("Failed to start codex")); assert!(message.contains("/opt/homebrew/bin")); assert!(message.contains("Node.js")); } #[cfg(unix)] #[test] fn executable_binary_candidate_skips_unusable_file_when_later_candidate_works() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let unusable = dir.path().join("codex-unusable"); let executable = dir.path().join("codex"); std::fs::write(&unusable, "#!/bin/sh\n").unwrap(); std::fs::write(&executable, "#!/bin/sh\n").unwrap(); std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap(); std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); let found = find_executable_binary_candidate(vec![unusable, executable.clone()], "Codex CLI") .unwrap(); assert_eq!(found, Some(executable)); } #[cfg(unix)] #[test] fn executable_binary_candidate_reports_unusable_file_when_no_candidate_works() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let unusable = dir.path().join("opencode"); std::fs::write(&unusable, "#!/bin/sh\n").unwrap(); std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap(); let error = find_executable_binary_candidate(vec![unusable.clone()], "OpenCode CLI").unwrap_err(); assert!(error.contains("OpenCode CLI binary found")); assert!(error.contains(&unusable.display().to_string())); assert!(error.contains("not executable")); } }